feat: add metadata catalog cleanup commands

This commit is contained in:
Codex
2026-08-28 00:37:43 +02:00
parent 79c4c925b5
commit 58ee9cffe4
19 changed files with 942 additions and 66 deletions
@@ -0,0 +1,20 @@
import { expect, test } from "vitest";
import { CatalogOperationCoordinator } from "../src/catalog/operation-coordinator.js";
test("releases earlier database reservations when a batch reservation cannot be completed", async () => {
const coordinator = new CatalogOperationCoordinator();
const releaseBusyDatabase = coordinator.reserve("database-b");
await expect(coordinator.runMany(["database-a", "database-b"], async () => "unreachable"))
.rejects.toThrow("A database operation is already in progress");
expect(await coordinator.run("database-a", async () => "available")).toBe("available");
releaseBusyDatabase();
});
test("reserves duplicate database ids only once for a batch operation", async () => {
const coordinator = new CatalogOperationCoordinator();
expect(await coordinator.runMany(["database-a", "database-a"], async () => "completed"))
.toBe("completed");
});
@@ -123,3 +123,80 @@ test.skipIf(!dockerAvailable)("PostgreSQL migration enforces one database per wo
await container.stop();
}
}, 60_000);
test.skipIf(!dockerAvailable)("PostgreSQL repository performs scoped metadata cleanup with catalog cascades", async () => {
const container = await new PostgreSqlContainer("postgres:17.6-bookworm").start();
const db = new Kysely<CatalogDatabase>({
dialect: new PostgresDialect({ pool: new Pool({ connectionString: container.getConnectionUri() }) }),
plugins: [new CamelCasePlugin()],
});
try {
await upDatabases(db);
await upTables(db);
await upSchemaSync(db);
const repository = new KyselyCatalogRepository(db);
const database = await repository.create({
workspaceId: "cleanup-test",
engine: "postgres",
databaseName: "warehouse",
schema: "public",
binding: { transport: "postgres_direct", host: "db.internal", port: 5432, username: "reader" },
});
const snapshot: ObservedSchemaSnapshot = {
schemaVersion: 1,
capabilities: { tables: "available", columns: "available", relationships: "available" },
tables: [
{ name: "patients", sourceComment: null },
{ name: "visits", sourceComment: null },
],
columns: [
{ tableName: "patients", name: "id", ordinalPosition: 1, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: 1, sourceComment: null },
{ tableName: "patients", name: "name", ordinalPosition: 2, dataType: "text", isNullable: true, defaultExpression: null, primaryKeyPosition: null, sourceComment: null },
{ tableName: "visits", name: "id", ordinalPosition: 1, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: 1, sourceComment: null },
{ tableName: "visits", name: "patient_id", ordinalPosition: 2, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: null, sourceComment: null },
],
relationships: [{
constraintName: "visits_patient_id_fkey",
sourceTableName: "visits",
targetTableName: "patients",
updateRule: "NO ACTION",
deleteRule: "CASCADE",
deferrable: false,
initiallyDeferred: false,
columns: [{ position: 1, sourceColumnName: "patient_id", targetColumnName: "id" }],
}],
};
await repository.applySchemaSync(database.id, database.version, "all", [], snapshot);
const patients = (await repository.listTables(database.id)).find((table) => table.name === "patients")!;
expect(await repository.deleteTableMetadata(database.id, [patients.id], "relationships"))
.toEqual({ tables: 0, columns: 0, relationships: 1 });
expect(await repository.listRelationships(database.id)).toEqual([]);
expect(await repository.listColumns(database.id, patients.id)).toHaveLength(2);
expect((await repository.get(database.id))?.schemaSyncedVersion).toBeUndefined();
await repository.applySchemaSync(database.id, database.version, "relationships", [], snapshot);
expect(await repository.deleteTableMetadata(database.id, [patients.id], "columns"))
.toEqual({ tables: 0, columns: 2, relationships: 0 });
expect(await repository.listColumns(database.id, patients.id)).toEqual([]);
expect(await repository.listRelationships(database.id)).toMatchObject([
{ constraintName: "visits_patient_id_fkey", columns: [] },
]);
await repository.applySchemaSync(database.id, database.version, "columns", [patients.id], snapshot);
await repository.applySchemaSync(database.id, database.version, "relationships", [], snapshot);
expect(await repository.deleteDatabaseMetadata([
database.id,
"99999999-9999-4999-8999-999999999999",
], "tables")).toBeUndefined();
expect(await repository.listTables(database.id)).toHaveLength(2);
expect(await repository.deleteDatabaseMetadata([database.id], "tables"))
.toEqual({ tables: 2, columns: 4, relationships: 1 });
expect(await repository.get(database.id)).toBeDefined();
expect(await repository.listTables(database.id)).toEqual([]);
expect(await repository.listRelationships(database.id)).toEqual([]);
} finally {
await db.destroy();
await container.stop();
}
}, 60_000);
+150
View File
@@ -51,6 +51,41 @@ function snapshot(): ObservedSchemaSnapshot {
};
}
function expandedSnapshot(): ObservedSchemaSnapshot {
const expanded = snapshot();
expanded.tables.push(
{ name: "encounters", sourceComment: null },
{ name: "departments", sourceComment: null },
);
expanded.columns.push(
{ tableName: "encounters", name: "visit_id", ordinalPosition: 1, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: null, sourceComment: null },
{ tableName: "departments", name: "patient_id", ordinalPosition: 1, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: null, sourceComment: null },
);
expanded.relationships.push(
{
constraintName: "encounters_visit_id_fkey",
sourceTableName: "encounters",
targetTableName: "visits",
updateRule: "NO ACTION",
deleteRule: "CASCADE",
deferrable: false,
initiallyDeferred: false,
columns: [{ position: 1, sourceColumnName: "visit_id", targetColumnName: "id" }],
},
{
constraintName: "departments_patient_id_fkey",
sourceTableName: "departments",
targetTableName: "patients",
updateRule: "NO ACTION",
deleteRule: "CASCADE",
deferrable: false,
initiallyDeferred: false,
columns: [{ position: 1, sourceColumnName: "patient_id", targetColumnName: "id" }],
},
);
return expanded;
}
async function waitFor(repository: MemoryCatalogRepository, runId: string, state: CatalogSyncRun["state"]): Promise<CatalogSyncRun> {
for (let attempt = 0; attempt < 100; attempt += 1) {
const run = await repository.getSyncRun(runId);
@@ -100,6 +135,14 @@ async function setup() {
};
}
async function seedCatalog(
repository: MemoryCatalogRepository,
database: { id: string; version: number },
observed: ObservedSchemaSnapshot = snapshot(),
): Promise<void> {
await repository.applySchemaSync(database.id, database.version, "all", [], observed);
}
test("synchronizes a full physical schema and derives primary and foreign key flags", async () => {
const { app, repository, database } = await setup();
const started = await app.inject({
@@ -222,3 +265,110 @@ test("waits for confirmation and rescans before applying destructive changes", a
expect((await repository.listTables(database.id)).map((table) => table.name)).toEqual(["patients"]);
expect(scan).toHaveBeenCalledTimes(3);
});
test("deletes every catalog table for multiple selected databases and cascades dependent metadata", async () => {
const { app, repository, database } = await setup();
const second = await repository.create({
workspaceId: "radiology", engine: "postgres", databaseName: "radiology", schema: "public",
binding: { transport: "postgres_direct", host: "db.internal", port: 5432, username: "reader" },
});
await seedCatalog(repository, database);
await seedCatalog(repository, second);
const response = await app.inject({
method: "POST",
url: "/catalog/databases/metadata-cleanup",
payload: { databaseIds: [database.id, second.id], target: "tables" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ tables: 4, columns: 6, relationships: 2 });
expect(await repository.get(database.id)).toBeDefined();
expect(await repository.get(second.id)).toBeDefined();
expect(await repository.listTables(database.id)).toEqual([]);
expect(await repository.listTables(second.id)).toEqual([]);
expect(await repository.listRelationships(database.id)).toEqual([]);
expect(await repository.listRelationships(second.id)).toEqual([]);
});
test("deletes relationships for selected databases without deleting their tables or columns", async () => {
const { app, repository, database } = await setup();
await seedCatalog(repository, database);
const tables = await repository.listTables(database.id);
const response = await app.inject({
method: "POST",
url: "/catalog/databases/metadata-cleanup",
payload: { databaseIds: [database.id], target: "relationships" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ tables: 0, columns: 0, relationships: 1 });
expect(await repository.listTables(database.id)).toHaveLength(2);
expect(await repository.listColumns(database.id, tables[0]!.id)).not.toEqual([]);
expect(await repository.listRelationships(database.id)).toEqual([]);
expect((await repository.get(database.id))?.schemaSyncedVersion).toBeUndefined();
});
test("deletes columns from multiple selected catalog tables only", async () => {
const { app, repository, database } = await setup();
await seedCatalog(repository, database, expandedSnapshot());
const tables = await repository.listTables(database.id);
const patients = tables.find((table) => table.name === "patients")!;
const visits = tables.find((table) => table.name === "visits")!;
const encounters = tables.find((table) => table.name === "encounters")!;
const departments = tables.find((table) => table.name === "departments")!;
const response = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/tables/metadata-cleanup`,
payload: { tableIds: [patients.id, visits.id], target: "columns" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ tables: 0, columns: 3, relationships: 0 });
expect(await repository.listColumns(database.id, patients.id)).toEqual([]);
expect(await repository.listColumns(database.id, visits.id)).toEqual([]);
expect((await repository.listColumns(database.id, encounters.id)).map((column) => column.name))
.toEqual(["visit_id"]);
expect((await repository.listColumns(database.id, departments.id)).map((column) => column.name))
.toEqual(["patient_id"]);
expect((await repository.get(database.id))?.schemaSyncedVersion).toBeUndefined();
});
test("deletes incoming and outgoing relationships for multiple selected catalog tables only", async () => {
const { app, repository, database } = await setup();
await seedCatalog(repository, database, expandedSnapshot());
const tables = await repository.listTables(database.id);
const visits = tables.find((table) => table.name === "visits")!;
const encounters = tables.find((table) => table.name === "encounters")!;
const response = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/tables/metadata-cleanup`,
payload: { tableIds: [visits.id, encounters.id], target: "relationships" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ tables: 0, columns: 0, relationships: 2 });
expect((await repository.listRelationships(database.id)).map((relationship) => relationship.constraintName))
.toEqual(["departments_patient_id_fkey"]);
expect(await repository.listTables(database.id)).toHaveLength(4);
});
test("rejects a missing database without partially cleaning valid selections", async () => {
const { app, repository, database } = await setup();
await seedCatalog(repository, database);
const response = await app.inject({
method: "POST",
url: "/catalog/databases/metadata-cleanup",
payload: {
databaseIds: [database.id, "99999999-9999-4999-8999-999999999999"],
target: "tables",
},
});
expect(response.statusCode).toBe(404);
expect(await repository.listTables(database.id)).toHaveLength(2);
});