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:
241
tests/server/db/soft-delete.test.ts
Normal file
241
tests/server/db/soft-delete.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user