feat: consolidate database management work

Add catalog-owned logical relationships and runtime snapshots, extend the database-management UI and validation coverage, and document the updated operational workflow.

Keep active sensitive-generation status in a tooltip and indicator, and update the layout E2E to follow the history action in its new database-scoped location.
This commit is contained in:
Codex
2026-09-01 14:46:55 +02:00
parent f586152636
commit 076c9742c5
73 changed files with 6966 additions and 610 deletions
+123
View File
@@ -122,6 +122,7 @@ async function setup(env: Record<string, string> = {}) {
list: vi.fn(async () => [revision]),
listCatalog: vi.fn(async () => [{ id: "psd-clinical", name: "Policlinico San Donato", configurationState: "ready", revision }]),
read: vi.fn(async () => ({ workspace, revision })),
readPinned: vi.fn(async () => ({ workspace, workspaceConfigPath: revision.snapshotPath })),
} as unknown as WorkspaceRegistry;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/missing", NODE_ENV: "test", ...env }), {
thtRunner: {} as never,
@@ -605,3 +606,125 @@ test("rejects a missing database without partially cleaning valid selections", a
expect(response.statusCode).toBe(404);
expect(await repository.listTables(database.id)).toHaveLength(2);
});
test("serves one relationship map and supports the manual relationship lifecycle", async () => {
const { app, repository, database } = await setup();
await seedCatalog(repository, database);
const tables = await repository.listTables(database.id);
const patients = tables.find((table) => table.name === "patients")!;
const visits = tables.find((table) => table.name === "visits")!;
const patientId = (await repository.listColumns(database.id, patients.id)).find((item) => item.name === "id")!;
const visitId = (await repository.listColumns(database.id, visits.id)).find((item) => item.name === "id")!;
const created = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/relationships`,
payload: { sourceColumnId: visitId.id, targetColumnId: patientId.id },
});
expect(created.statusCode).toBe(201);
expect(created.json()).toMatchObject({ origin: "manual", status: "active", constraintName: null });
const listed = (await app.inject({
method: "GET", url: `/catalog/databases/${database.id}/relationships`,
})).json();
expect(listed.map((item: { origin: string }) => item.origin).sort()).toEqual(["manual", "physical"]);
const relationshipId = created.json().id;
const excluded = await app.inject({
method: "PATCH",
url: `/catalog/databases/${database.id}/relationships/${relationshipId}`,
payload: { status: "excluded" },
});
expect(excluded.statusCode).toBe(200);
expect(excluded.json()).toMatchObject({ origin: "manual", status: "excluded" });
const restored = await app.inject({
method: "PATCH",
url: `/catalog/databases/${database.id}/relationships/${relationshipId}`,
payload: { status: "active" },
});
expect(restored.json()).toMatchObject({ status: "active" });
expect((await app.inject({
method: "DELETE", url: `/catalog/databases/${database.id}/relationships/${relationshipId}`,
})).statusCode).toBe(204);
});
test("rejects generated inference while the Catalog schema is not current", async () => {
const { app, database } = await setup();
const response = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/relationships/rebuild-generated`,
});
expect(response.statusCode).toBe(409);
expect(response.json()).toEqual({
code: "relationship_schema_stale",
message: "Synchronize the current database schema before managing logical relationships.",
});
});
test("rebuilds generated relationships and returns the exact summary", async () => {
const { app, repository, database } = await setup();
const observed = snapshot();
observed.relationships = [];
await seedCatalog(repository, database, observed);
const first = await app.inject({
method: "POST", url: `/catalog/databases/${database.id}/relationships/rebuild-generated`,
});
expect(first.statusCode).toBe(200);
expect(first.json()).toEqual({ added: 1, alreadyPresent: 0, excluded: 0, ambiguous: 0 });
const generated = (await repository.listLogicalRelationships(database.id))[0]!;
await app.inject({
method: "PATCH",
url: `/catalog/databases/${database.id}/relationships/${generated.id}`,
payload: { status: "excluded" },
});
const second = await app.inject({
method: "POST", url: `/catalog/databases/${database.id}/relationships/rebuild-generated`,
});
expect(second.json()).toEqual({ added: 0, alreadyPresent: 0, excluded: 1, ambiguous: 0 });
});
test("validates relationship requests and keeps physical relationships read-only", async () => {
const { app, repository, database } = await setup();
await seedCatalog(repository, database);
const physical = (await repository.listRelationships(database.id))[0]!;
const invalid = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/relationships`,
payload: { sourceColumnId: physical.columns[0].sourceColumnId, targetColumnId: physical.columns[0].targetColumnId, generated: true },
});
expect(invalid.statusCode).toBe(400);
expect(invalid.json()).toMatchObject({ code: "relationship_request_invalid" });
const readOnly = await app.inject({
method: "PATCH",
url: `/catalog/databases/${database.id}/relationships/${physical.id}`,
payload: { status: "excluded" },
});
expect(readOnly.statusCode).toBe(409);
expect(readOnly.json()).toMatchObject({ code: "relationship_read_only" });
});
test("requires database.manage for relationship map mutations", async () => {
const { app, repository, database } = await setup({ AUTH_MODE: "upstream" });
const observed = snapshot();
observed.relationships = [];
await seedCatalog(repository, database, observed);
const response = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/relationships/rebuild-generated`,
headers: {
"x-thoth-principal-issuer": "portal",
"x-thoth-principal-subject": "catalog-reader",
"x-thoth-is-admin": "0",
},
});
expect(response.statusCode).toBe(403);
expect(response.json()).toEqual({ code: "auth_forbidden", error: "This operation is not permitted" });
});