From 58ee9cffe45fe8adb388e0e580cd3d9cf2fb0975 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 00:28:02 +0200 Subject: [PATCH] feat: add metadata catalog cleanup commands --- CONTEXT.md | 16 +- PROJECT_STATE.md | 14 +- backend/src/app.ts | 6 +- backend/src/catalog/memory-repository.ts | 80 ++++++++++ backend/src/catalog/operation-coordinator.ts | 14 +- backend/src/catalog/repository.ts | 103 ++++++++++++ backend/src/catalog/types.ts | 18 +++ backend/src/routes/catalog-schema.ts | 54 ++++++- .../catalog-operation-coordinator.test.ts | 20 +++ .../catalog-repository.integration.test.ts | 77 +++++++++ backend/test/catalog-schema-routes.test.ts | 150 ++++++++++++++++++ ...e-catalog-tables-during-synchronization.md | 4 + ...8-allow-manual-catalog-metadata-cleanup.md | 16 ++ ...026-08-26-metadata-catalog-from-thothai.md | 17 +- frontend/src/api/catalog-databases.ts | 26 +++ .../src/shell/DatabaseManagementPage.test.tsx | 110 ++++++++++++- frontend/src/shell/DatabaseManagementPage.tsx | 25 +++ .../database-management/DatabaseGrid.tsx | 130 +++++++++++---- .../database-management/DatabaseTables.tsx | 128 +++++++++++++-- 19 files changed, 942 insertions(+), 66 deletions(-) create mode 100644 backend/test/catalog-operation-coordinator.test.ts create mode 100644 docs/adr/0008-allow-manual-catalog-metadata-cleanup.md diff --git a/CONTEXT.md b/CONTEXT.md index 6a5aeb25..807899f3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -274,8 +274,8 @@ associato a un Workspace Database. La sua identità e il suo nome appartengono al database esterno, non al Metadata Catalog. **Catalog Table** — La rappresentazione persistita di una Physical Table nel Metadata Catalog. -La sua appartenenza e identità fisica derivano esclusivamente dall'introspezione; soltanto i suoi -Catalog Metadata possono essere curati amministrativamente. +La sua appartenenza e identità fisica derivano dall'introspezione: non può essere creata o +rinominata manualmente, ma può essere rimossa tramite Catalog Metadata Cleanup. _Avoid_: SqlTable, managed table **Physical Column** — Una colonna osservata in una Physical Table, inclusi nome, posizione, @@ -284,15 +284,16 @@ al database esterno. **Catalog Column** — La rappresentazione persistita di una Physical Column nel Metadata Catalog. I fatti osservati sono governati dalla sincronizzazione; Description e Generated Description -sono metadati amministrativi modificabili. +sono metadati amministrativi modificabili e la rappresentazione può essere rimossa tramite +Catalog Metadata Cleanup. _Avoid_: SqlColumn, managed column **Physical Relationship** — Un vincolo foreign key dichiarato nel database esterno. La sua identità comprende il vincolo e la sequenza ordinata delle coppie di colonne che lo compongono. **Catalog Relationship** — La rappresentazione persistita di una Physical Relationship nel -Metadata Catalog. È governata esclusivamente dall'introspezione e non è creata o modificata -manualmente. +Metadata Catalog. Non è creata o modificata manualmente, ma può essere rimossa tramite Catalog +Metadata Cleanup. _Avoid_: denormalized FK, relationship string **Logical Relationship** — Una relazione semantica curata o inferita che non corrisponde @@ -317,6 +318,11 @@ un unico snapshot completo tramite Synchronize All. **Catalog Sync Run** — L'esecuzione durevole in background di una Schema Synchronization, con scope, stato, avanzamento e log propri. Al massimo un run per Workspace Database può essere attivo. +**Catalog Metadata Cleanup** — La rimozione amministrativa esplicita di Catalog Table, Catalog +Column o Catalog Relationship selezionate. Non modifica il Workspace Database, la Database Binding +o i segreti, e può lasciare il Metadata Catalog intenzionalmente incompleto fino alla prossima +Schema Synchronization. + **Catalog Freshness** — La corrispondenza fra uno scope sincronizzato e la versione corrente della Database Binding. Uno scope rimane consultabile ma è stale finché non viene sincronizzato con la binding corrente. diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 2c6e30e1..d9ee526c 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -74,13 +74,19 @@ connector bindings. Configured databases use pure hierarchical navigation through `Overview`, `Tables`, and `Relationships`; a selected table has `Overview` and `Columns`. Physical membership, source comments, column types/default/nullability/PK positions, and constraint-level ordered FK pairs are -immutable projections of the external schema. Curated and generated descriptions are editable; -generated descriptions start null and AI generation/consolidation is deferred. +projections of the external schema. They cannot be created, renamed, or structurally edited by +hand, but administrators can explicitly clear catalog tables, columns, or relationships without +touching the source database, binding, configuration, or secrets. Table deletion cascades through +columns and relationships; table-scoped relationship cleanup includes incoming and outgoing +relationships. Curated and generated descriptions are editable; generated descriptions start null +and AI generation/consolidation is deferred. Schema refresh is one durable asynchronous engine with database-table, database-column, selected-table-column, relationship, and full-database actions. Database-level menus expose the table, all-column, relationship, and full scopes separately; selecting tables exposes column -synchronization for that subset. Runs have one-active-job-per-database exclusion, leases and +synchronization plus manual column and relationship cleanup for that subset. Database selections +also expose manual table and relationship cleanup. Cleanup selections are atomic and share the +one-active-operation-per-database exclusion with synchronization. Runs have leases and restart recovery, atomic apply, destructive-diff confirmation with re-scan, cancellation before apply, retained history, and a live SSE log with polling fallback. Null metadata renders blank rather than as a placeholder. @@ -97,7 +103,7 @@ one-shot `catalog-migrate` operation; `scripts/run-stack.sh` runs it before loca sessions still consume the existing workspace configuration in this slice: database-management records do not yet change the NL→SQL handoff. The accepted design is recorded in `docs/plans/2026-08-26-metadata-catalog-from-thothai.md`, the snapshot contract under -`docs/contracts/`, and ADRs 0001–0007. +`docs/contracts/`, and ADRs 0001–0008. Semantic aliases, value descriptions, synonyms, concepts, AI metadata generation/consolidation, and logical relationships remain deferred to their dedicated slices. diff --git a/backend/src/app.ts b/backend/src/app.ts index 453527db..1f93b726 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -385,7 +385,11 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc }); catalogDatabaseRoutes(app, { repository: catalogRepository, service: catalogService, operations: catalogOperationCoordinator }); catalogTableRoutes(app, { repository: catalogRepository, service: catalogTableService }); - catalogSchemaRoutes(app, { repository: catalogRepository, worker: catalogSyncWorker }); + catalogSchemaRoutes(app, { + repository: catalogRepository, + worker: catalogSyncWorker, + operations: catalogOperationCoordinator, + }); settingsRoutes(app, { cfg: config, listModels, getSettings }); piManagementRoutes(app, { service: piManagement }); diff --git a/backend/src/catalog/memory-repository.ts b/backend/src/catalog/memory-repository.ts index c4d85d8c..e30fa228 100644 --- a/backend/src/catalog/memory-repository.ts +++ b/backend/src/catalog/memory-repository.ts @@ -3,6 +3,8 @@ import { CatalogConflictError, CatalogConnectorError, type CatalogColumn, + type CatalogDatabaseMetadataDeleteTarget, + type CatalogMetadataDeleteCounts, type CatalogRelationship, type CatalogSchemaDiff, type CatalogSyncCounts, @@ -11,6 +13,7 @@ import { type CatalogSyncRunUpdate, type CatalogSyncScope, type CatalogTable, + type CatalogTableMetadataDeleteTarget, type CatalogRepository, type DatabaseConfigurationInput, type DatabaseTestResult, @@ -199,6 +202,71 @@ export class MemoryCatalogRepository implements CatalogRepository { .map((relationship) => structuredClone(relationship)); } + async deleteDatabaseMetadata( + databaseIds: readonly string[], + target: CatalogDatabaseMetadataDeleteTarget, + ): Promise { + const selectedDatabaseIds = [...new Set(databaseIds)]; + if (selectedDatabaseIds.length === 0 || selectedDatabaseIds.some((id) => !this.records.has(id))) { + return undefined; + } + const selected = new Set(selectedDatabaseIds); + const tables = [...this.tables.values()].filter((table) => selected.has(table.databaseId)); + const tableIds = new Set(tables.map((table) => table.id)); + const columns = [...this.columns.values()].filter((column) => tableIds.has(column.tableId)); + const relationships = [...this.relationships.values()] + .filter((relationship) => selected.has(relationship.databaseId)); + + if (target === "tables") { + for (const table of tables) this.deleteTable(table.id); + this.markCatalogIncomplete(selectedDatabaseIds); + return { tables: tables.length, columns: columns.length, relationships: relationships.length }; + } + for (const relationship of relationships) this.relationships.delete(relationship.id); + for (const databaseId of selectedDatabaseIds) this.refreshForeignKeyFlags(databaseId); + this.markCatalogIncomplete(selectedDatabaseIds); + return { tables: 0, columns: 0, relationships: relationships.length }; + } + + async deleteTableMetadata( + databaseId: string, + tableIds: readonly string[], + target: CatalogTableMetadataDeleteTarget, + ): Promise { + const selectedTableIds = [...new Set(tableIds)]; + if (!this.records.has(databaseId) || selectedTableIds.length === 0) return undefined; + const tables = selectedTableIds.map((id) => this.tables.get(id)); + if (tables.some((table) => !table || table.databaseId !== databaseId)) return undefined; + const selected = new Set(selectedTableIds); + + if (target === "columns") { + const columns = [...this.columns.values()].filter((column) => selected.has(column.tableId)); + const deletedColumnIds = new Set(columns.map((column) => column.id)); + for (const column of columns) this.columns.delete(column.id); + for (const [relationshipId, relationship] of this.relationships) { + if (relationship.databaseId !== databaseId) continue; + this.relationships.set(relationshipId, { + ...relationship, + columns: relationship.columns.filter((pair) => ( + !deletedColumnIds.has(pair.sourceColumnId) && !deletedColumnIds.has(pair.targetColumnId) + )), + }); + } + this.refreshForeignKeyFlags(databaseId); + this.markCatalogIncomplete([databaseId]); + return { tables: 0, columns: columns.length, relationships: 0 }; + } + + const relationships = [...this.relationships.values()].filter((relationship) => ( + relationship.databaseId === databaseId + && (selected.has(relationship.sourceTableId) || selected.has(relationship.targetTableId)) + )); + for (const relationship of relationships) this.relationships.delete(relationship.id); + this.refreshForeignKeyFlags(databaseId); + this.markCatalogIncomplete([databaseId]); + return { tables: 0, columns: 0, relationships: relationships.length }; + } + async planSchemaSync( databaseId: string, scope: CatalogSyncScope, @@ -675,6 +743,18 @@ export class MemoryCatalogRepository implements CatalogRepository { } } + private markCatalogIncomplete(databaseIds: readonly string[]): void { + for (const databaseId of databaseIds) { + const database = this.records.get(databaseId); + if (!database) continue; + this.records.set(databaseId, { + ...database, + schemaSyncedVersion: undefined, + schemaSyncedAt: undefined, + }); + } + } + private refreshForeignKeyFlags(databaseId: string): void { const counts = new Map(); for (const relationship of this.relationships.values()) { diff --git a/backend/src/catalog/operation-coordinator.ts b/backend/src/catalog/operation-coordinator.ts index 1ec7160a..3884f9b2 100644 --- a/backend/src/catalog/operation-coordinator.ts +++ b/backend/src/catalog/operation-coordinator.ts @@ -1,6 +1,6 @@ import { CatalogOperationInProgressError } from "./types.js"; -/** Serializes connection tests and schema synchronization for each catalog database. */ +/** Serializes connection tests, synchronization, and metadata cleanup for each catalog database. */ export class CatalogOperationCoordinator { private readonly active = new Set(); @@ -25,4 +25,16 @@ export class CatalogOperationCoordinator { release(); } } + + async runMany(databaseIds: readonly string[], operation: () => Promise): Promise { + const releases: Array<() => void> = []; + try { + for (const databaseId of [...new Set(databaseIds)].sort()) { + releases.push(this.reserve(databaseId)); + } + return await operation(); + } finally { + for (const release of releases.reverse()) release(); + } + } } diff --git a/backend/src/catalog/repository.ts b/backend/src/catalog/repository.ts index 4bd551d7..512eb6e4 100644 --- a/backend/src/catalog/repository.ts +++ b/backend/src/catalog/repository.ts @@ -16,6 +16,8 @@ import { CatalogConnectorError, CatalogUnavailableError, type CatalogColumn, + type CatalogDatabaseMetadataDeleteTarget, + type CatalogMetadataDeleteCounts, type CatalogRelationship, type CatalogSchemaDiff, type CatalogSyncCounts, @@ -24,6 +26,7 @@ import { type CatalogSyncRunUpdate, type CatalogSyncScope, type CatalogTable, + type CatalogTableMetadataDeleteTarget, type CatalogRepository, type DatabaseBinding, type DatabaseConfigurationInput, @@ -559,6 +562,104 @@ export class KyselyCatalogRepository implements CatalogRepository { })); } + async deleteDatabaseMetadata( + databaseIds: readonly string[], + target: CatalogDatabaseMetadataDeleteTarget, + ): Promise { + const selectedDatabaseIds = [...new Set(databaseIds)]; + if (selectedDatabaseIds.length === 0) return undefined; + return await this.db.transaction().execute(async (trx) => { + const databases = await trx.selectFrom("workspaceDatabases").select("id") + .where("id", "in", selectedDatabaseIds).orderBy("id").forUpdate().execute(); + if (databases.length !== selectedDatabaseIds.length) return undefined; + + if (target === "relationships") { + const count = await trx.selectFrom("catalogRelationships") + .select(sql`count(*)::int`.as("count")) + .where("databaseId", "in", selectedDatabaseIds).executeTakeFirst(); + await trx.deleteFrom("catalogRelationships") + .where("databaseId", "in", selectedDatabaseIds).execute(); + await trx.updateTable("workspaceDatabases").set({ + schemaSyncedVersion: null, + schemaSyncedAt: null, + }).where("id", "in", selectedDatabaseIds).execute(); + return { tables: 0, columns: 0, relationships: Number(count?.count ?? 0) }; + } + + const tableCount = await trx.selectFrom("catalogTables") + .select(sql`count(*)::int`.as("count")) + .where("databaseId", "in", selectedDatabaseIds).executeTakeFirst(); + const columnCount = await trx.selectFrom("catalogColumns") + .innerJoin("catalogTables", "catalogTables.id", "catalogColumns.tableId") + .select(sql`count(*)::int`.as("count")) + .where("catalogTables.databaseId", "in", selectedDatabaseIds).executeTakeFirst(); + const relationshipCount = await trx.selectFrom("catalogRelationships") + .select(sql`count(*)::int`.as("count")) + .where("databaseId", "in", selectedDatabaseIds).executeTakeFirst(); + await trx.deleteFrom("catalogTables") + .where("databaseId", "in", selectedDatabaseIds).execute(); + await trx.updateTable("workspaceDatabases").set({ + schemaSyncedVersion: null, + schemaSyncedAt: null, + }).where("id", "in", selectedDatabaseIds).execute(); + return { + tables: Number(tableCount?.count ?? 0), + columns: Number(columnCount?.count ?? 0), + relationships: Number(relationshipCount?.count ?? 0), + }; + }); + } + + async deleteTableMetadata( + databaseId: string, + tableIds: readonly string[], + target: CatalogTableMetadataDeleteTarget, + ): Promise { + const selectedTableIds = [...new Set(tableIds)]; + if (selectedTableIds.length === 0) return undefined; + return await this.db.transaction().execute(async (trx) => { + const database = await trx.selectFrom("workspaceDatabases").select("id") + .where("id", "=", databaseId).forUpdate().executeTakeFirst(); + if (!database) return undefined; + const tables = await trx.selectFrom("catalogTables").select("id") + .where("databaseId", "=", databaseId) + .where("id", "in", selectedTableIds).execute(); + if (tables.length !== selectedTableIds.length) return undefined; + + if (target === "columns") { + const count = await trx.selectFrom("catalogColumns") + .select(sql`count(*)::int`.as("count")) + .where("tableId", "in", selectedTableIds).executeTakeFirst(); + await trx.deleteFrom("catalogColumns") + .where("tableId", "in", selectedTableIds).execute(); + await trx.updateTable("workspaceDatabases").set({ + schemaSyncedVersion: null, + schemaSyncedAt: null, + }).where("id", "=", databaseId).execute(); + return { tables: 0, columns: Number(count?.count ?? 0), relationships: 0 }; + } + + const count = await trx.selectFrom("catalogRelationships") + .select(sql`count(*)::int`.as("count")) + .where("databaseId", "=", databaseId) + .where((eb) => eb.or([ + eb("sourceTableId", "in", selectedTableIds), + eb("targetTableId", "in", selectedTableIds), + ])).executeTakeFirst(); + await trx.deleteFrom("catalogRelationships") + .where("databaseId", "=", databaseId) + .where((eb) => eb.or([ + eb("sourceTableId", "in", selectedTableIds), + eb("targetTableId", "in", selectedTableIds), + ])).execute(); + await trx.updateTable("workspaceDatabases").set({ + schemaSyncedVersion: null, + schemaSyncedAt: null, + }).where("id", "=", databaseId).execute(); + return { tables: 0, columns: 0, relationships: Number(count?.count ?? 0) }; + }); + } + async planSchemaSync( databaseId: string, scope: CatalogSyncScope, @@ -1002,6 +1103,8 @@ export class UnavailableCatalogRepository implements CatalogRepository { async getColumn(): Promise { return this.fail(); } async updateColumnMetadata(): Promise { return this.fail(); } async listRelationships(): Promise { return this.fail(); } + async deleteDatabaseMetadata(): Promise { return this.fail(); } + async deleteTableMetadata(): Promise { return this.fail(); } async planSchemaSync(): Promise { return this.fail(); } async applySchemaSync(): Promise { return this.fail(); } async createSyncRun(): Promise { return this.fail(); } diff --git a/backend/src/catalog/types.ts b/backend/src/catalog/types.ts index 965dbbdf..64047e59 100644 --- a/backend/src/catalog/types.ts +++ b/backend/src/catalog/types.ts @@ -133,6 +133,15 @@ export interface CatalogRelationship { updatedAt: string; } +export type CatalogDatabaseMetadataDeleteTarget = "tables" | "relationships"; +export type CatalogTableMetadataDeleteTarget = "columns" | "relationships"; + +export interface CatalogMetadataDeleteCounts { + tables: number; + columns: number; + relationships: number; +} + export interface ObservedRelationshipColumn { position: number; sourceColumnName: string; @@ -284,6 +293,15 @@ export interface CatalogRepository { generatedDescription: string | null, ): Promise; listRelationships(databaseId: string): Promise; + deleteDatabaseMetadata( + databaseIds: readonly string[], + target: CatalogDatabaseMetadataDeleteTarget, + ): Promise; + deleteTableMetadata( + databaseId: string, + tableIds: readonly string[], + target: CatalogTableMetadataDeleteTarget, + ): Promise; planSchemaSync( databaseId: string, scope: CatalogSyncScope, diff --git a/backend/src/routes/catalog-schema.ts b/backend/src/routes/catalog-schema.ts index 46948bad..ae4e07b0 100644 --- a/backend/src/routes/catalog-schema.ts +++ b/backend/src/routes/catalog-schema.ts @@ -3,6 +3,7 @@ import { setTimeout as delay } from "node:timers/promises"; import { z } from "zod"; import { isPrincipalContext, requirePermission } from "../auth/authorization.js"; import type { CatalogSyncWorker } from "../catalog/sync-worker.js"; +import type { CatalogOperationCoordinator } from "../catalog/operation-coordinator.js"; import { CatalogConflictError, CatalogConnectorError, @@ -23,6 +24,14 @@ const createRunSchema = z.object({ scope: z.enum(["tables", "columns", "relationships", "all"]), tableIds: z.array(idSchema).max(10_000).default([]), }).strict(); +const databaseCleanupSchema = z.object({ + databaseIds: z.array(idSchema).min(1).max(1_000), + target: z.enum(["tables", "relationships"]), +}).strict(); +const tableCleanupSchema = z.object({ + tableIds: z.array(idSchema).min(1).max(10_000), + target: z.enum(["columns", "relationships"]), +}).strict(); const confirmationSchema = z.object({ confirmationToken: z.string().uuid() }).strict(); const eventQuerySchema = z.object({ after: z.coerce.number().int().nonnegative().default(0) }); @@ -62,7 +71,11 @@ function publicRun(run: CatalogSyncRun) { export function catalogSchemaRoutes( app: FastifyInstance, - deps: { repository: CatalogRepository; worker: CatalogSyncWorker }, + deps: { + repository: CatalogRepository; + worker: CatalogSyncWorker; + operations?: CatalogOperationCoordinator; + }, ): void { app.get("/catalog/databases/:databaseId/tables/:tableId/columns", async (request, reply) => { if (!manage(request, reply)) return reply; @@ -114,6 +127,45 @@ export function catalogSchemaRoutes( } catch (error) { return safeError(reply, error); } }); + app.post("/catalog/databases/metadata-cleanup", async (request, reply) => { + if (!manage(request, reply)) return reply; + try { + const input = databaseCleanupSchema.parse(request.body); + const databaseIds = [...new Set(input.databaseIds)]; + const operation = async () => await deps.repository.deleteDatabaseMetadata(databaseIds, input.target); + const deleted = deps.operations + ? await deps.operations.runMany(databaseIds, operation) + : await operation(); + if (!deleted) { + return reply.code(404).send({ + code: "database_not_found", + message: "One or more database configurations were not found.", + }); + } + return deleted; + } catch (error) { return safeError(reply, error); } + }); + + app.post("/catalog/databases/:databaseId/tables/metadata-cleanup", async (request, reply) => { + if (!manage(request, reply)) return reply; + try { + const databaseId = idSchema.parse((request.params as { databaseId?: unknown }).databaseId); + const input = tableCleanupSchema.parse(request.body); + const tableIds = [...new Set(input.tableIds)]; + const operation = async () => await deps.repository.deleteTableMetadata(databaseId, tableIds, input.target); + const deleted = deps.operations + ? await deps.operations.run(databaseId, operation) + : await operation(); + if (!deleted) { + return reply.code(404).send({ + code: "table_not_found", + message: "One or more catalog tables were not found.", + }); + } + return deleted; + } catch (error) { return safeError(reply, error); } + }); + app.post("/catalog/databases/:databaseId/sync-runs", async (request, reply) => { if (!manage(request, reply)) return reply; try { diff --git a/backend/test/catalog-operation-coordinator.test.ts b/backend/test/catalog-operation-coordinator.test.ts new file mode 100644 index 00000000..e77ebfe2 --- /dev/null +++ b/backend/test/catalog-operation-coordinator.test.ts @@ -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"); +}); diff --git a/backend/test/catalog-repository.integration.test.ts b/backend/test/catalog-repository.integration.test.ts index a76644bf..debf475b 100644 --- a/backend/test/catalog-repository.integration.test.ts +++ b/backend/test/catalog-repository.integration.test.ts @@ -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({ + 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); diff --git a/backend/test/catalog-schema-routes.test.ts b/backend/test/catalog-schema-routes.test.ts index d112be0e..08648a32 100644 --- a/backend/test/catalog-schema-routes.test.ts +++ b/backend/test/catalog-schema-routes.test.ts @@ -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 { 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 { + 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); +}); diff --git a/docs/adr/0005-hard-delete-catalog-tables-during-synchronization.md b/docs/adr/0005-hard-delete-catalog-tables-during-synchronization.md index 5918b3bd..3cbc0ad7 100644 --- a/docs/adr/0005-hard-delete-catalog-tables-during-synchronization.md +++ b/docs/adr/0005-hard-delete-catalog-tables-during-synchronization.md @@ -1,3 +1,7 @@ +--- +status: superseded by ADR-0008 +--- + # Hard-delete catalog tables during synchronization An explicit Table Synchronization makes the Catalog Table membership exactly match a successful diff --git a/docs/adr/0008-allow-manual-catalog-metadata-cleanup.md b/docs/adr/0008-allow-manual-catalog-metadata-cleanup.md new file mode 100644 index 00000000..438fa509 --- /dev/null +++ b/docs/adr/0008-allow-manual-catalog-metadata-cleanup.md @@ -0,0 +1,16 @@ +--- +status: accepted +--- + +# Allow explicit manual catalog metadata cleanup + +An administrator may explicitly clear structural projections from the Metadata Catalog without +changing the Workspace Database, its binding, or its secrets. A database selection can remove all +Catalog Tables, cascading their Catalog Columns and Catalog Relationships, or remove only its +Catalog Relationships; a table selection can remove its Catalog Columns or every incoming and +outgoing Catalog Relationship. Each selection is atomic, and a later Schema Synchronization can +rebuild the removed projection from the external schema. + +This supersedes ADR-0005's broader invariant that physical membership cannot be edited manually. +Synchronization remains authoritative when it runs, while manual cleanup intentionally permits an +incomplete catalog between cleanup and the next successful synchronization. diff --git a/docs/plans/2026-08-26-metadata-catalog-from-thothai.md b/docs/plans/2026-08-26-metadata-catalog-from-thothai.md index 461d4ada..2aafe5b3 100644 --- a/docs/plans/2026-08-26-metadata-catalog-from-thothai.md +++ b/docs/plans/2026-08-26-metadata-catalog-from-thothai.md @@ -149,9 +149,10 @@ canonico; i percorsi e i comportamenti descrivono il sorgente disponibile il 202 `generated_description` nullable per lo step AI futuro, version e timestamp. La UI mostra come tre campi indipendenti senza fallback visivo: source comment read-only, generated description modificabile e description modificabile. I valori null restano celle e controlli vuoti. -47. Le Catalog Table non possono essere aggiunte, rinominate o cancellate manualmente. `Sync tables` - legge dal database esterno le tabelle PostgreSQL ordinarie e partizionate dello schema scelto; - viste e materialized view sono escluse. +47. Le Catalog Table non possono essere aggiunte o rinominate manualmente. Un amministratore può + però ripulire esplicitamente le proiezioni nel Metadata Catalog senza modificare il database + esterno; `Sync tables` legge le tabelle PostgreSQL ordinarie e partizionate dello schema scelto, + mentre viste e materialized view sono escluse. 48. La sincronizzazione è esplicita. La scansione avviene fuori dalla transazione del catalogo; il diff viene applicato atomicamente soltanto se la version del Workspace Database è ancora quella sottoposta a scansione. Una scansione fallita non modifica il catalogo. @@ -556,7 +557,8 @@ Gli endpoint dovranno vivere sotto un namespace catalogo e non riutilizzare le r Workspace Database, Catalog Table, Catalog Column e Catalog Relationship sono implementati con React/Vite e il design system ThothII. La navigazione è gerarchica e locale al database (`Overview | Tables`), senza menu o filtri globali -per tipo di entità. La grid delle tabelle non offre Add/Delete; il dettaglio full-width mantiene +per tipo di entità. La grid delle tabelle non offre Add o cancellazione della singola configurazione; +le selezioni espongono invece la pulizia esplicita dei metadati. Il dettaglio full-width mantiene immutabili i fatti fisici e consente di modificare separatamente Description e Generated Description. Colonne e relazioni seguono la stessa gerarchia: Columns appartiene al dettaglio della tabella, Relationships al database. I valori descrittivi null sono mostrati come celle e @@ -569,8 +571,11 @@ accumulata attraverso i filtri e viene azzerata dopo successo, nuova sincronizza dalla pagina; un'azione è all-or-nothing se un elemento non è idoneo. I menu a livello database espongono gli scope fisici come azioni distinte: `Synchronize tables`, `Synchronize all columns`, `Synchronize relationships` e `Synchronize all`. La grid Tables espone invece `Synchronize -columns` per le tabelle selezionate. Test connection resta un'azione distinta; griglie senza azioni -non mostrano controlli di selezione inerti. +columns` per le tabelle selezionate. Le selezioni database aggiungono `Delete all tables` e +`Delete all relationships`; le selezioni tabelle aggiungono `Delete all columns` e `Delete all +relationships`. Queste operazioni sono atomiche, richiedono conferma e non modificano database +esterno, binding, configurazione o segreti. Test connection resta un'azione distinta; griglie senza +azioni non mostrano controlli di selezione inerti. ### Step 6: introspezione diff --git a/frontend/src/api/catalog-databases.ts b/frontend/src/api/catalog-databases.ts index 04a56df7..15290cd8 100644 --- a/frontend/src/api/catalog-databases.ts +++ b/frontend/src/api/catalog-databases.ts @@ -123,6 +123,15 @@ export interface CatalogRelationship { updatedAt: string; } +export type CatalogDatabaseMetadataDeleteTarget = "tables" | "relationships"; +export type CatalogTableMetadataDeleteTarget = "columns" | "relationships"; + +export interface CatalogMetadataDeleteCounts { + tables: number; + columns: number; + relationships: number; +} + export type CatalogSyncScope = "tables" | "columns" | "relationships" | "all"; export type CatalogSyncState = "queued" | "running" | "awaiting_confirmation" | "applying" | "succeeded" | "failed" | "cancelled" | "interrupted"; @@ -228,6 +237,23 @@ export const updateCatalogColumnMetadata = ( export const listCatalogRelationships = (databaseId: string) => apiFetch(`/catalog/databases/${encodeURIComponent(databaseId)}/relationships`); +export const deleteCatalogDatabaseMetadata = ( + databaseIds: string[], + target: CatalogDatabaseMetadataDeleteTarget, +) => apiFetch("/catalog/databases/metadata-cleanup", { + method: "POST", + body: JSON.stringify({ databaseIds, target }), +}); + +export const deleteCatalogTableMetadata = ( + databaseId: string, + tableIds: string[], + target: CatalogTableMetadataDeleteTarget, +) => apiFetch( + `/catalog/databases/${encodeURIComponent(databaseId)}/tables/metadata-cleanup`, + { method: "POST", body: JSON.stringify({ tableIds, target }) }, +); + export const startCatalogSync = ( databaseId: string, version: number, diff --git a/frontend/src/shell/DatabaseManagementPage.test.tsx b/frontend/src/shell/DatabaseManagementPage.test.tsx index 9aba9f91..5e101d8c 100644 --- a/frontend/src/shell/DatabaseManagementPage.test.tsx +++ b/frontend/src/shell/DatabaseManagementPage.test.tsx @@ -181,6 +181,59 @@ test.each(synchronizationScopes)( }, ); +test("selected database Actions confirms and deletes catalog tables for the full selection", async () => { + const user = userEvent.setup(); + let cleanupBody: unknown; + const second = makeDatabase({ + id: "44444444-4444-4444-8444-444444444444", + workspaceId: "radiology", + workspaceName: "Radiology", + databaseName: "radiology", + }); + server.use(http.post("/api/catalog/databases/metadata-cleanup", async ({ request }) => { + cleanupBody = await request.json(); + return HttpResponse.json({ tables: 8, columns: 42, relationships: 5 }); + })); + renderPage({ rows: [makeDatabase(), second] }); + + const firstRow = await screen.findByRole("row", { name: /Policlinico San Donato/ }); + const secondRow = await screen.findByRole("row", { name: /Radiology/ }); + await user.click(within(firstRow).getByRole("checkbox")); + await user.click(within(secondRow).getByRole("checkbox")); + await user.click(screen.getByRole("button", { name: "Actions" })); + + expect(await screen.findByRole("menuitem", { name: "Delete all tables" })).toBeEnabled(); + expect(screen.getByRole("menuitem", { name: "Delete all relationships" })).toBeEnabled(); + await user.click(screen.getByRole("menuitem", { name: "Delete all tables" })); + + expect(screen.getByText("Delete all catalog tables for 2 databases?")).toBeVisible(); + expect(screen.getByText(/database configurations and source databases are unchanged/i)).toBeVisible(); + expect(cleanupBody).toBeUndefined(); + + await user.click(within(secondRow).getByRole("checkbox")); + expect(screen.queryByText(/Delete all catalog tables for/)).not.toBeInTheDocument(); + expect(cleanupBody).toBeUndefined(); + await user.click(within(secondRow).getByRole("checkbox")); + await user.click(screen.getByRole("button", { name: "Actions" })); + await user.click(await screen.findByRole("menuitem", { name: "Delete all tables" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Delete catalog tables" })).toHaveFocus()); + await user.keyboard("{Escape}"); + await waitFor(() => expect(screen.getByRole("button", { name: "Actions" })).toHaveFocus()); + await user.click(screen.getByRole("button", { name: "Actions" })); + await user.click(await screen.findByRole("menuitem", { name: "Delete all tables" })); + await user.click(screen.getByRole("button", { name: "Delete catalog tables" })); + + await waitFor(() => expect(cleanupBody).toEqual({ + databaseIds: [ + "11111111-1111-4111-8111-111111111111", + "44444444-4444-4444-8444-444444444444", + ], + target: "tables", + })); + await waitFor(() => expect(screen.queryByText("2 selected")).not.toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole("textbox", { name: "Search databases" })).toHaveFocus()); +}); + test("presents completed synchronization steps as success and skips unneeded confirmation", async () => { const user = userEvent.setup(); const run = { @@ -580,7 +633,7 @@ test("filters catalog tables as the operator types", async () => { }); }); -test("selected table exposes a direct Synchronize columns action and sends its id", async () => { +test("selected table Actions exposes cleanup commands and sends synchronization ids", async () => { const user = userEvent.setup(); let startBody: unknown; const run = makeSyncRun("columns", [patientsTable.id]); @@ -601,10 +654,11 @@ test("selected table exposes a direct Synchronize columns action and sends its i const tableRow = await screen.findByRole("row", { name: /patients/ }); await user.click(within(tableRow).getByRole("checkbox")); - const synchronizeColumns = screen.getByRole("button", { name: "Synchronize columns" }); - expect(synchronizeColumns).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Actions" })); + const synchronizeColumns = await screen.findByRole("menuitem", { name: "Synchronize columns" }); expect(synchronizeColumns).toBeEnabled(); - expect(screen.queryByRole("button", { name: "Actions" })).not.toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Delete all columns" })).toBeEnabled(); + expect(screen.getByRole("menuitem", { name: "Delete all relationships" })).toBeEnabled(); await user.click(synchronizeColumns); await waitFor(() => expect(startBody).toEqual({ @@ -615,6 +669,54 @@ test("selected table exposes a direct Synchronize columns action and sends its i await waitFor(() => expect(screen.queryByText("1 selected")).not.toBeInTheDocument()); }); +test("selected table Actions confirms and deletes incoming and outgoing relationships", async () => { + const user = userEvent.setup(); + let cleanupBody: unknown; + server.use( + http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])), + http.post("/api/catalog/databases/:databaseId/tables/metadata-cleanup", async ({ request }) => { + cleanupBody = await request.json(); + return HttpResponse.json({ tables: 0, columns: 0, relationships: 2 }); + }), + ); + const { client } = renderPage({ + rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })], + }); + const unselectedColumnQuery = [ + "catalog-columns", + patientsTable.databaseId, + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + ] as const; + client.setQueryData(unselectedColumnQuery, []); + + await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" })); + await user.click(screen.getByRole("tab", { name: "Tables" })); + const tableRow = await screen.findByRole("row", { name: /patients/ }); + await user.click(within(tableRow).getByRole("checkbox")); + await user.click(screen.getByRole("button", { name: "Actions" })); + await user.click(await screen.findByRole("menuitem", { name: "Delete all relationships" })); + + expect(screen.getByText("Delete all relationships for 1 table?")).toBeVisible(); + expect(screen.getByText(/incoming and outgoing relationships/i)).toBeVisible(); + expect(cleanupBody).toBeUndefined(); + + await user.click(within(tableRow).getByRole("checkbox")); + expect(screen.queryByText("Delete all relationships for 1 table?")).not.toBeInTheDocument(); + expect(cleanupBody).toBeUndefined(); + await user.click(within(tableRow).getByRole("checkbox")); + await user.click(screen.getByRole("button", { name: "Actions" })); + await user.click(await screen.findByRole("menuitem", { name: "Delete all relationships" })); + await user.click(screen.getByRole("button", { name: "Delete relationships" })); + + await waitFor(() => expect(cleanupBody).toEqual({ + tableIds: [patientsTable.id], + target: "relationships", + })); + await waitFor(() => expect(screen.queryByText("1 selected")).not.toBeInTheDocument()); + expect(client.getQueryState(unselectedColumnQuery)?.isInvalidated).toBe(true); + await waitFor(() => expect(screen.getByRole("textbox", { name: "Search tables" })).toHaveFocus()); +}); + test("navigates purely from a database to its tables and edits review metadata", async () => { const user = userEvent.setup(); let patchBody: unknown; diff --git a/frontend/src/shell/DatabaseManagementPage.tsx b/frontend/src/shell/DatabaseManagementPage.tsx index 857a1569..16d045b5 100644 --- a/frontend/src/shell/DatabaseManagementPage.tsx +++ b/frontend/src/shell/DatabaseManagementPage.tsx @@ -13,12 +13,14 @@ import { ApiError, apiErrorMessage } from "../api/client"; import { createCatalogDatabase, deleteCatalogDatabase, + deleteCatalogDatabaseMetadata, listCatalogDatabases, replaceCatalogDatabaseSecrets, startCatalogSync, testCatalogDatabase, updateCatalogDatabase, type CatalogDatabase, + type CatalogDatabaseMetadataDeleteTarget, type CatalogSecretName, type CatalogSyncScope, type CatalogSyncRun, @@ -588,6 +590,28 @@ export function DatabaseManagementPage({ } }, [queryClient, rememberSyncRun]); + const deleteSelectedMetadata = useCallback(async ( + selected: CatalogDatabase[], + target: CatalogDatabaseMetadataDeleteTarget, + ) => { + try { + const databaseIds = selected.map((row) => row.id!); + const counts = await deleteCatalogDatabaseMetadata(databaseIds, target); + await Promise.all(databaseIds.flatMap((databaseId) => [ + queryClient.invalidateQueries({ queryKey: ["catalog-tables", databaseId] }), + queryClient.invalidateQueries({ queryKey: ["catalog-columns", databaseId] }), + queryClient.invalidateQueries({ queryKey: ["catalog-relationships", databaseId] }), + ])); + await queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY }); + toast.success(target === "tables" + ? `Deleted ${counts.tables} catalog tables, ${counts.columns} columns, and ${counts.relationships} relationships` + : `Deleted ${counts.relationships} catalog relationships`); + } catch (error) { + toast.error(apiErrorMessage(error)); + throw error; + } + }, [queryClient]); + const syncDatabase = useCallback(async (scope: CatalogSyncScope) => { if (!activeRow?.id || !activeRow.configured) return; try { @@ -677,6 +701,7 @@ export function DatabaseManagementPage({ onOpenSync={openSync} onTestSelected={testSelected} onSyncSelected={syncSelected} + onDeleteMetadataSelected={deleteSelectedMetadata} /> )} diff --git a/frontend/src/shell/database-management/DatabaseGrid.tsx b/frontend/src/shell/database-management/DatabaseGrid.tsx index cd06313b..50aa5750 100644 --- a/frontend/src/shell/database-management/DatabaseGrid.tsx +++ b/frontend/src/shell/database-management/DatabaseGrid.tsx @@ -11,7 +11,11 @@ import "ag-grid-community/styles/ag-grid.css"; import "ag-grid-community/styles/ag-theme-alpine.css"; import { ChevronDown, Eye, Pencil, RefreshCw, Trash2, X } from "lucide-react"; import { Button } from "../../components/ui/button"; -import type { CatalogDatabase, CatalogSyncScope } from "../../api/catalog-databases"; +import type { + CatalogDatabase, + CatalogDatabaseMetadataDeleteTarget, + CatalogSyncScope, +} from "../../api/catalog-databases"; import { statusLabel } from "./model"; import { databaseSyncItemClass, databaseSyncScopes } from "./DatabaseSyncMenu"; @@ -31,6 +35,10 @@ interface DatabaseGridProps { onOpenSync: (row: CatalogDatabase) => void; onTestSelected: (rows: CatalogDatabase[]) => Promise; onSyncSelected: (rows: CatalogDatabase[], scope: CatalogSyncScope) => Promise; + onDeleteMetadataSelected: ( + rows: CatalogDatabase[], + target: CatalogDatabaseMetadataDeleteTarget, + ) => Promise; } interface DatabaseGridContext { @@ -146,11 +154,14 @@ export function DatabaseGrid({ onOpenSync, onTestSelected, onSyncSelected, + onDeleteMetadataSelected, }: DatabaseGridProps) { const compact = useCompactViewport(); const gridRef = useRef>(null); + const actionsTriggerRef = useRef(null); const [selectedRows, setSelectedRows] = useState([]); - const [action, setAction] = useState<"test" | "sync" | null>(null); + const [action, setAction] = useState<"test" | "sync" | "delete" | null>(null); + const [pendingDelete, setPendingDelete] = useState(null); const context = useMemo( () => ({ canManage, onView, onEdit, onDelete, onOpenSync }), [canManage, onView, onEdit, onDelete, onOpenSync], @@ -236,12 +247,25 @@ export function DatabaseGrid({ }).length; const canTestSelection = canManage && selectedRows.length > 0 && selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun); const canSyncSelection = canManage && selectedRows.length > 0 && selectedRows.every((row) => row.configured && row.id && row.connectionStatus === "reachable" && row.testedVersion === row.version && !row.activeSyncRun); - const perform = async (kind: "test" | "sync", operation: () => Promise) => { + const canDeleteMetadataSelection = canManage && selectedRows.length > 0 + && selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun); + useEffect(() => { + if (!pendingDelete) return; + const timer = window.setTimeout(() => document.getElementById("database-cleanup-confirm-button")?.focus(), 0); + return () => window.clearTimeout(timer); + }, [pendingDelete]); + const closeDeleteConfirmation = () => { + setPendingDelete(null); + window.setTimeout(() => actionsTriggerRef.current?.focus(), 0); + }; + const perform = async (kind: "test" | "sync" | "delete", operation: () => Promise) => { setAction(kind); try { await operation(); gridRef.current?.api.deselectAll(); setSelectedRows([]); + setPendingDelete(null); + if (kind === "delete") window.setTimeout(() => searchInputRef.current?.focus(), 0); } finally { setAction(null); } }; @@ -249,32 +273,81 @@ export function DatabaseGrid({
{selectedRows.length > 0 ? ( - <> - {selectedRows.length} selected - {hiddenSelected ? {hiddenSelected} hidden by filter : null} - - Actions - - - - void perform("test", () => onTestSelected(selectedRows))}>Test connections - - {databaseSyncScopes.map(({ scope, label }) => ( + pendingDelete ? ( +
{ + if (event.key === "Escape" && action === null) closeDeleteConfirmation(); + }} + > +
+

+ {pendingDelete === "tables" + ? `Delete all catalog tables for ${selectedRows.length} database${selectedRows.length === 1 ? "" : "s"}?` + : `Delete all relationships for ${selectedRows.length} database${selectedRows.length === 1 ? "" : "s"}?`} +

+

+ {pendingDelete === "tables" + ? "Columns and relationships will also be removed. Database configurations and source databases are unchanged." + : "Catalog tables, columns, database configurations, and source databases are unchanged."} +

+
+ + +
+ ) : ( + <> + {selectedRows.length} selected + {hiddenSelected ? {hiddenSelected} hidden by filter : null} + + Actions + + + + void perform("test", () => onTestSelected(selectedRows))}>Test connections + + {databaseSyncScopes.map(({ scope, label }) => ( + void perform("sync", () => onSyncSelected(selectedRows, scope))} + > + {label} + + ))} + void perform("sync", () => onSyncSelected(selectedRows, scope))} + className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" + disabled={!canDeleteMetadataSelection} + onClick={() => setPendingDelete("tables")} > - {label} + Delete all tables - ))} - - - - - - + setPendingDelete("relationships")} + > + Delete all relationships + +
+
+
+
+ + + ) ) : <>Workspace databases data.workspaceId} rowSelection={{ mode: "multiRow", selectAll: "filtered", enableClickSelection: false }} selectionColumnDef={{ width: 44, maxWidth: 44, pinned: "left" }} - onSelectionChanged={({ api }) => setSelectedRows(api.getSelectedRows())} + onSelectionChanged={({ api }) => { + if (pendingDelete && action === null) setPendingDelete(null); + setSelectedRows(api.getSelectedRows()); + }} rowHeight={44} headerHeight={38} animateRows={false} diff --git a/frontend/src/shell/database-management/DatabaseTables.tsx b/frontend/src/shell/database-management/DatabaseTables.tsx index 833f6f49..0d724eb1 100644 --- a/frontend/src/shell/database-management/DatabaseTables.tsx +++ b/frontend/src/shell/database-management/DatabaseTables.tsx @@ -1,18 +1,21 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { Menu } from "@base-ui/react/menu"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AgGridReact } from "ag-grid-react"; import type { ColDef, ICellRendererParams } from "ag-grid-community"; -import { ArrowLeft, Columns3, Pencil, RefreshCw, Save, X } from "lucide-react"; +import { ArrowLeft, ChevronDown, Columns3, Pencil, RefreshCw, Save, Trash2, X } from "lucide-react"; import { toast } from "sonner"; import { Button } from "../../components/ui/button"; import { ApiError, apiErrorMessage } from "../../api/client"; import { + deleteCatalogTableMetadata, listCatalogTables, startCatalogSync, updateCatalogTableMetadata, type CatalogDatabase, type CatalogSyncRun, type CatalogTable, + type CatalogTableMetadataDeleteTarget, } from "../../api/catalog-databases"; import type { DatabaseNavigationState } from "./model"; import { DatabaseColumns } from "./DatabaseColumns"; @@ -74,11 +77,14 @@ export function DatabaseTables({ const [editorVersion, setEditorVersion] = useState(null); const [stale, setStale] = useState(false); const [staleBannerOpen, setStaleBannerOpen] = useState(true); - const [busy, setBusy] = useState<"sync" | "save" | null>(null); + const [busy, setBusy] = useState<"sync" | "save" | "delete" | null>(null); + const [pendingDelete, setPendingDelete] = useState(null); const [columnNavigation, setColumnNavigation] = useState({ dirty: false, busy: false }); const gridRef = useRef>(null); const originRef = useRef(null); const headingRef = useRef(null); + const actionsTriggerRef = useRef(null); + const searchInputRef = useRef(null); const activeTable = activeTableId ? data.find((table) => table.id === activeTableId) : undefined; const editorFingerprint = JSON.stringify([description, generatedDescription]); const editorDirty = Boolean(activeTableId && editorFingerprint !== baseline); @@ -93,6 +99,15 @@ export function DatabaseTables({ setStale(true); setStaleBannerOpen(true); }, [activeTable, activeTableId, busy, editorVersion]); + useEffect(() => { + if (!pendingDelete) return; + const timer = window.setTimeout(() => document.getElementById("table-cleanup-confirm-button")?.focus(), 0); + return () => window.clearTimeout(timer); + }, [pendingDelete]); + const closeDeleteConfirmation = () => { + setPendingDelete(null); + window.setTimeout(() => actionsTriggerRef.current?.focus(), 0); + }; const openTable = (table: CatalogTable, section: "overview" | "columns", origin: HTMLButtonElement) => { originRef.current = origin; @@ -168,6 +183,26 @@ export function DatabaseTables({ toast.success(scope === "all" ? "Full schema synchronization started" : scope === "columns" ? "Column synchronization started" : "Table synchronization started"); } catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(null); } }; + const deleteMetadata = async (target: CatalogTableMetadataDeleteTarget) => { + setBusy("delete"); + try { + const counts = await deleteCatalogTableMetadata(databaseId, selectedIds, target); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["catalog-columns", databaseId] }), + queryClient.invalidateQueries({ queryKey: ["catalog-relationships", databaseId] }), + queryClient.invalidateQueries({ queryKey: ["catalog-databases"] }), + ]); + gridRef.current?.api.deselectAll(); + setSelectedIds([]); + setPendingDelete(null); + window.setTimeout(() => searchInputRef.current?.focus(), 0); + toast.success(target === "columns" + ? `Deleted ${counts.columns} catalog columns` + : `Deleted ${counts.relationships} catalog relationships`); + } catch (error) { + toast.error(apiErrorMessage(error)); + } finally { setBusy(null); } + }; const columns = useMemo[]>(() => [ { field: "name", headerName: "Name", minWidth: 250, flex: 1, cellClass: "font-mono text-xs" }, @@ -258,24 +293,80 @@ export function DatabaseTables({
{selectedIds.length > 0 ? ( - <> - {selectedIds.length} selected - {visibleSelected !== selectedIds.length ? {selectedIds.length - visibleSelected} hidden by filter : null} - - - +
+

+ {pendingDelete === "columns" + ? `Delete all catalog columns for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?` + : `Delete all relationships for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`} +

+

+ {pendingDelete === "columns" + ? "Only columns belonging to the selected catalog tables will be removed." + : "All incoming and outgoing relationships for the selected catalog tables will be removed."} +

+
+ + +
+ ) : ( + <> + {selectedIds.length} selected + {visibleSelected !== selectedIds.length ? {selectedIds.length - visibleSelected} hidden by filter : null} + + Actions + + + + void synchronize("columns", selectedIds)} + > + Synchronize columns + + + setPendingDelete("columns")} + > + Delete all columns + + setPendingDelete("relationships")} + > + Delete all relationships + + + + + + + + ) ) : ( <> Catalog tables - setSearch(event.target.value)} /> + setSearch(event.target.value)} /> table.id} rowSelection={{ mode: "multiRow", selectAll: "filtered", enableClickSelection: false }} - onSelectionChanged={({ api }) => setSelectedIds(api.getSelectedRows().map((table) => table.id))} + onSelectionChanged={({ api }) => { + if (pendingDelete && busy === null) setPendingDelete(null); + setSelectedIds(api.getSelectedRows().map((table) => table.id)); + }} onModelUpdated={({ api }) => setDisplayedCount(api.getDisplayedRowCount())} rowHeight={44} headerHeight={38}