feat: 增加项目管理功能
引入 SQLite 数据库(Drizzle ORM + bun:sqlite),实现项目 CRUD 与归档/恢复/删除 生命周期管理,新增项目管理前端页面,migration 嵌入单文件构建产物保持部署体验。 - src/server/db: schema、connection、migration 执行器、项目数据访问层 - src/server/routes/projects: 7 个 API 端点(列表/创建/详情/更新/归档/恢复/删除) - src/web: 项目管理页面(TDesign Table/Tabs/Dialog/Form),TanStack Query hooks - scripts: 构建时嵌入 migration SQL,开发期独立 generate-migrations-data 脚本 - tests: 60 个后端测试 + 27 个前端测试,覆盖 DB/migration/API/路由/页面 - docs: 更新架构、后端、发布、配置、部署、使用文档
This commit is contained in:
153
tests/server/db/migrate.test.ts
Normal file
153
tests/server/db/migrate.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { MigrationRecord } from "../../../src/server/db/load-migrations";
|
||||
|
||||
import { createDatabase } from "../../../src/server/db/connection";
|
||||
import { runMigrations } from "../../../src/server/db/migrate";
|
||||
import { createMemoryLogger } from "../../../src/server/logger";
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = join(tmpdir(), `migration-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
const MIGRATION_001: MigrationRecord = {
|
||||
checksum: "fake-checksum-001",
|
||||
id: "0001_initial",
|
||||
sql: `
|
||||
CREATE TABLE test_table (id TEXT PRIMARY KEY, name TEXT NOT NULL);
|
||||
`,
|
||||
};
|
||||
|
||||
const MIGRATION_002: MigrationRecord = {
|
||||
checksum: "fake-checksum-002",
|
||||
id: "0002_add_desc",
|
||||
sql: `
|
||||
ALTER TABLE test_table ADD COLUMN description TEXT DEFAULT '';
|
||||
`,
|
||||
};
|
||||
|
||||
describe("migration 执行器", () => {
|
||||
test("应用待执行 migration 并记录", () => {
|
||||
const dir = makeTempDir();
|
||||
const logger = createMemoryLogger();
|
||||
try {
|
||||
const db = createDatabase(dir, logger);
|
||||
runMigrations(db, [MIGRATION_001], dir, logger);
|
||||
|
||||
const rows = db.query("SELECT id, checksum FROM schema_migrations").all() as Array<{
|
||||
checksum: string;
|
||||
id: string;
|
||||
}>;
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0]!.id).toBe("0001_initial");
|
||||
expect(rows[0]!.checksum).toBe("fake-checksum-001");
|
||||
|
||||
db.exec("INSERT INTO test_table (id, name) VALUES ('1', 'test')");
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("跳过已应用的 migration", () => {
|
||||
const dir = makeTempDir();
|
||||
const logger = createMemoryLogger();
|
||||
try {
|
||||
const db = createDatabase(dir, logger);
|
||||
runMigrations(db, [MIGRATION_001], dir, logger);
|
||||
runMigrations(db, [MIGRATION_001], dir, logger);
|
||||
|
||||
const rows = db.query("SELECT id FROM schema_migrations").all() as Array<{ id: string }>;
|
||||
expect(rows.length).toBe(1);
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("按顺序应用多个 migration", () => {
|
||||
const dir = makeTempDir();
|
||||
const logger = createMemoryLogger();
|
||||
try {
|
||||
const db = createDatabase(dir, logger);
|
||||
runMigrations(db, [MIGRATION_001, MIGRATION_002], dir, logger);
|
||||
|
||||
const rows = db.query("SELECT id FROM schema_migrations ORDER BY id").all() as Array<{ id: string }>;
|
||||
expect(rows.length).toBe(2);
|
||||
expect(rows[0]!.id).toBe("0001_initial");
|
||||
expect(rows[1]!.id).toBe("0002_add_desc");
|
||||
|
||||
db.exec("INSERT INTO test_table (id, name, description) VALUES ('1', 'test', 'desc')");
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("无待执行 migration 时不做变更", () => {
|
||||
const dir = makeTempDir();
|
||||
const logger = createMemoryLogger();
|
||||
try {
|
||||
const db = createDatabase(dir, logger);
|
||||
runMigrations(db, [], dir, logger);
|
||||
|
||||
const tableExists = db
|
||||
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'")
|
||||
.get();
|
||||
expect(tableExists).toBeNull();
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("执行 migration 前创建备份", () => {
|
||||
const dir = makeTempDir();
|
||||
const logger = createMemoryLogger();
|
||||
try {
|
||||
const db = createDatabase(dir, logger);
|
||||
db.exec("CREATE TABLE existing (id TEXT)");
|
||||
db.exec("INSERT INTO existing (id) VALUES ('x')");
|
||||
db.close();
|
||||
|
||||
const db2 = createDatabase(dir, logger);
|
||||
runMigrations(db2, [MIGRATION_001], dir, logger);
|
||||
db2.close();
|
||||
|
||||
const backupsDir = join(dir, "backups");
|
||||
expect(existsSync(backupsDir)).toBe(true);
|
||||
const backupFiles = readdirSync(backupsDir);
|
||||
expect(backupFiles.length).toBe(1);
|
||||
expect(backupFiles[0]!).toMatch(/^alfred-.*\.db$/);
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("失败的 migration 不留下部分记录", () => {
|
||||
const dir = makeTempDir();
|
||||
const logger = createMemoryLogger();
|
||||
const BAD_MIGRATION: MigrationRecord = {
|
||||
checksum: "bad",
|
||||
id: "0003_bad",
|
||||
sql: "INVALID SQL STATEMENT;",
|
||||
};
|
||||
try {
|
||||
const db = createDatabase(dir, logger);
|
||||
expect(() => {
|
||||
runMigrations(db, [MIGRATION_001, BAD_MIGRATION], dir, logger);
|
||||
}).toThrow();
|
||||
|
||||
const rows = db.query("SELECT id FROM schema_migrations").all() as Array<{ id: string }>;
|
||||
expect(rows.length).toBe(0);
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user