refactor(db): 统一数据库 schema — 软删除、命名规范、约束标准化

- 全表新增 deleted_at 列,统一软删除替代硬删除+archived_at
- models.model_id 重命名为 external_id,消除语义混淆
- conversations.model_id 改为可空(模型为建议而非绑定)
- messages 新增 updated_at,移除 CASCADE 改为 DAO 层级联
- 移除 DB 层 UNIQUE 约束,改为应用层检查(配合软删除)
- 新增 helpers.ts(baseColumns + 构造层防御)、ESLint 规则、契约测试
- 迁移 0004 补全 CHECK 约束(providers.type/materials.status/messages.role)
- DAO 层全面重写:级联软删除、应用层唯一、provider 删除保护
- 路由/前端/测试全量适配 externalId 重命名及类型变更
This commit is contained in:
2026-06-05 01:02:23 +08:00
parent e25b2537fd
commit db40d04dc5
37 changed files with 1564 additions and 324 deletions

View File

@@ -0,0 +1,241 @@
import type Database from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import { createConversation, createMessage, deleteConversation } from "../../../src/server/db/conversations";
import { createMaterial, deleteMaterial, listMaterials } from "../../../src/server/db/materials";
import { createModel, deleteModel, listModels } from "../../../src/server/db/models";
import { createProject, deleteProject, listProjects, updateProject } from "../../../src/server/db/projects";
import { createProvider, deleteProvider } from "../../../src/server/db/providers";
import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedTestDatabase } from "../../helpers";
const log = createNoopLogger();
function withDb(callback: (db: Database) => void): void {
const handle = createMigratedTestDatabase("soft-delete-test");
try {
callback(handle.db);
handle.close();
} finally {
handle.cleanup();
}
}
describe("软删除与级联", () => {
describe("V1: 删除 project 级联软删 conversations + materials + messages", () => {
test("归档项目软删除后,关联会话/消息/素材均被软删", () => {
withDb((db) => {
const projectRes = createProject(db, { name: "P1" }, log);
const projectId = (projectRes as { project: { id: string } }).project.id;
const providerRes = createProvider(
db,
{ apiKey: "sk", baseUrl: "https://a.com", name: "Prov", type: "openai" },
log,
);
const modelRes = createModel(
db,
{
capabilities: ["text"],
externalId: "gpt-4",
name: "GPT",
providerId: (providerRes as { provider: { id: string } }).provider.id,
},
log,
);
const modelId = (modelRes as { model: { id: string } }).model.id;
const convRes = createConversation(db, projectId, log, modelId);
const convId = (convRes as { conversation: { id: string } }).conversation.id;
createMessage(db, { content: "hi", conversationId: convId, role: "user" }, log);
createMaterial(db, projectId, { associatedDate: "2024-01-01", description: "M1" }, log);
db.exec("UPDATE projects SET status = 'archived' WHERE id = ?", [projectId]);
const del = deleteProject(db, projectId, log);
expect("error" in del).toBe(false);
const projectRow = db.query("SELECT deleted_at FROM projects WHERE id = ?").get(projectId) as {
deleted_at: null | string;
};
const convRow = db.query("SELECT deleted_at FROM conversations WHERE id = ?").get(convId) as {
deleted_at: null | string;
};
const messageRow = db.query("SELECT deleted_at FROM messages WHERE conversation_id = ?").get(convId) as {
deleted_at: null | string;
};
const materialRow = db.query("SELECT deleted_at FROM materials WHERE project_id = ?").get(projectId) as {
deleted_at: null | string;
};
expect(projectRow.deleted_at).not.toBeNull();
expect(convRow.deleted_at).not.toBeNull();
expect(messageRow.deleted_at).not.toBeNull();
expect(materialRow.deleted_at).not.toBeNull();
});
});
});
describe("V2: 删除 conversation 级联软删 messages", () => {
test("会话软删除后,其下消息均被软删", () => {
withDb((db) => {
const projectRes = createProject(db, { name: "P2" }, log);
const projectId = (projectRes as { project: { id: string } }).project.id;
const providerRes = createProvider(
db,
{ apiKey: "sk", baseUrl: "https://a.com", name: "Prov2", type: "openai" },
log,
);
const modelRes = createModel(
db,
{
capabilities: ["text"],
externalId: "claude",
name: "Claude",
providerId: (providerRes as { provider: { id: string } }).provider.id,
},
log,
);
const modelId = (modelRes as { model: { id: string } }).model.id;
const convRes = createConversation(db, projectId, log, modelId);
const convId = (convRes as { conversation: { id: string } }).conversation.id;
createMessage(db, { content: "m1", conversationId: convId, role: "user" }, log);
createMessage(db, { content: "m2", conversationId: convId, role: "assistant" }, log);
const del = deleteConversation(db, convId, log);
expect("error" in del).toBe(false);
const messages = db.query("SELECT deleted_at FROM messages WHERE conversation_id = ?").all(convId) as Array<{
deleted_at: null | string;
}>;
expect(messages.length).toBe(2);
expect(messages.every((m) => m.deleted_at !== null)).toBe(true);
});
});
});
describe("V3: paginateQuery softDelete 自动过滤已删除行", () => {
test("listProjects 自动排除软删除项目", () => {
withDb((db) => {
createProject(db, { name: "Alive1" }, log);
createProject(db, { name: "Alive2" }, log);
const toDelete = createProject(db, { name: "Dying" }, log);
const dyingId = (toDelete as { project: { id: string } }).project.id;
db.exec("UPDATE projects SET status = 'archived' WHERE id = ?", [dyingId]);
deleteProject(db, dyingId, log);
const result = listProjects(db, { page: 1, pageSize: 20 });
expect(result.total).toBe(2);
expect(result.items.map((p) => p.name).sort()).toEqual(["Alive1", "Alive2"]);
});
});
test("listMaterials 自动排除软删除素材", () => {
withDb((db) => {
const proj = createProject(db, { name: "PM" }, log);
const projectId = (proj as { project: { id: string } }).project.id;
createMaterial(db, projectId, { associatedDate: "2024-01-01", description: "K1" }, log);
createMaterial(db, projectId, { associatedDate: "2024-01-02", description: "K2" }, log);
const toDelete = createMaterial(db, projectId, { associatedDate: "2024-01-03", description: "K3" }, log);
const materialId = (toDelete as { material: { id: string } }).material.id;
deleteMaterial(db, projectId, materialId, log);
const result = listMaterials(db, projectId, { page: 1, pageSize: 20 });
expect(result.total).toBe(2);
expect(result.items.map((m) => m.description).sort()).toEqual(["K1", "K2"]);
});
});
test("listModels 自动排除软删除模型", () => {
withDb((db) => {
const prov = createProvider(db, { apiKey: "sk", baseUrl: "https://x.com", name: "Px", type: "openai" }, log);
const providerId = (prov as { provider: { id: string } }).provider.id;
createModel(db, { capabilities: ["text"], externalId: "a", name: "A", providerId }, log);
createModel(db, { capabilities: ["text"], externalId: "b", name: "B", providerId }, log);
const dying = createModel(db, { capabilities: ["text"], externalId: "c", name: "C", providerId }, log);
deleteModel(db, (dying as { model: { id: string } }).model.id, log);
const result = listModels(db, { page: 1, pageSize: 20 });
expect(result.total).toBe(2);
expect(result.items.map((m) => m.name).sort()).toEqual(["A", "B"]);
});
});
});
describe("V4: 应用层唯一约束(软删后同名复活)", () => {
test("软删除项目后可以创建同名项目", () => {
withDb((db) => {
const first = createProject(db, { name: "SameName" }, log);
const firstId = (first as { project: { id: string } }).project.id;
db.exec("UPDATE projects SET status = 'archived' WHERE id = ?", [firstId]);
deleteProject(db, firstId, log);
const second = createProject(db, { name: "SameName" }, log);
expect("error" in second).toBe(false);
});
});
test("未删除同名项目存在时创建失败409", () => {
withDb((db) => {
createProject(db, { name: "ClashName" }, log);
const result = createProject(db, { name: "ClashName" }, log);
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(409);
});
});
test("更新项目名称不与自身冲突", () => {
withDb((db) => {
const created = createProject(db, { name: "SelfUpdate" }, log);
const id = (created as { project: { id: string } }).project.id;
const result = updateProject(db, id, { name: "SelfUpdate" }, log);
expect("error" in result).toBe(false);
});
});
});
describe("V5: 删除 provider 时阻止(存在未删除 model", () => {
test("存在未删除 model 时删除 provider 返回错误", () => {
withDb((db) => {
const prov = createProvider(
db,
{ apiKey: "sk", baseUrl: "https://p.com", name: "BlockProv", type: "openai" },
log,
);
const providerId = (prov as { provider: { id: string } }).provider.id;
createModel(db, { capabilities: ["text"], externalId: "blocking-model", name: "BlockM", providerId }, log);
const result = deleteProvider(db, providerId, log);
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(409);
});
});
test("所有 model 已软删除后可以删除 provider", () => {
withDb((db) => {
const prov = createProvider(
db,
{ apiKey: "sk", baseUrl: "https://p.com", name: "FreeProv", type: "openai" },
log,
);
const providerId = (prov as { provider: { id: string } }).provider.id;
const m = createModel(db, { capabilities: ["text"], externalId: "free-model", name: "FreeM", providerId }, log);
deleteModel(db, (m as { model: { id: string } }).model.id, log);
const result = deleteProvider(db, providerId, log);
expect("error" in result).toBe(false);
});
});
});
});