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
+11 -5
View File
@@ -274,8 +274,8 @@ associato a un Workspace Database.
La sua identità e il suo nome appartengono al database esterno, non al Metadata Catalog. 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. **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 La sua appartenenza e identità fisica derivano dall'introspezione: non può essere creata o
Catalog Metadata possono essere curati amministrativamente. rinominata manualmente, ma può essere rimossa tramite Catalog Metadata Cleanup.
_Avoid_: SqlTable, managed table _Avoid_: SqlTable, managed table
**Physical Column** — Una colonna osservata in una Physical Table, inclusi nome, posizione, **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. **Catalog Column** — La rappresentazione persistita di una Physical Column nel Metadata Catalog.
I fatti osservati sono governati dalla sincronizzazione; Description e Generated Description 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 _Avoid_: SqlColumn, managed column
**Physical Relationship** — Un vincolo foreign key dichiarato nel database esterno. La sua **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. 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 **Catalog Relationship** — La rappresentazione persistita di una Physical Relationship nel
Metadata Catalog. È governata esclusivamente dall'introspezione e non è creata o modificata Metadata Catalog. Non è creata o modificata manualmente, ma può essere rimossa tramite Catalog
manualmente. Metadata Cleanup.
_Avoid_: denormalized FK, relationship string _Avoid_: denormalized FK, relationship string
**Logical Relationship** — Una relazione semantica curata o inferita che non corrisponde **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 **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. 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 **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 della Database Binding. Uno scope rimane consultabile ma è stale finché non viene sincronizzato
con la binding corrente. con la binding corrente.
+10 -4
View File
@@ -74,13 +74,19 @@ connector bindings.
Configured databases use pure hierarchical navigation through `Overview`, `Tables`, and Configured databases use pure hierarchical navigation through `Overview`, `Tables`, and
`Relationships`; a selected table has `Overview` and `Columns`. Physical membership, source `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 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; projections of the external schema. They cannot be created, renamed, or structurally edited by
generated descriptions start null and AI generation/consolidation is deferred. 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, 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 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 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 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 apply, retained history, and a live SSE log with polling fallback. Null metadata renders blank
rather than as a placeholder. 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 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 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/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, Semantic aliases, value descriptions, synonyms, concepts, AI metadata generation/consolidation,
and logical relationships remain deferred to their dedicated slices. and logical relationships remain deferred to their dedicated slices.
+5 -1
View File
@@ -385,7 +385,11 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
}); });
catalogDatabaseRoutes(app, { repository: catalogRepository, service: catalogService, operations: catalogOperationCoordinator }); catalogDatabaseRoutes(app, { repository: catalogRepository, service: catalogService, operations: catalogOperationCoordinator });
catalogTableRoutes(app, { repository: catalogRepository, service: catalogTableService }); 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 }); settingsRoutes(app, { cfg: config, listModels, getSettings });
piManagementRoutes(app, { service: piManagement }); piManagementRoutes(app, { service: piManagement });
+80
View File
@@ -3,6 +3,8 @@ import {
CatalogConflictError, CatalogConflictError,
CatalogConnectorError, CatalogConnectorError,
type CatalogColumn, type CatalogColumn,
type CatalogDatabaseMetadataDeleteTarget,
type CatalogMetadataDeleteCounts,
type CatalogRelationship, type CatalogRelationship,
type CatalogSchemaDiff, type CatalogSchemaDiff,
type CatalogSyncCounts, type CatalogSyncCounts,
@@ -11,6 +13,7 @@ import {
type CatalogSyncRunUpdate, type CatalogSyncRunUpdate,
type CatalogSyncScope, type CatalogSyncScope,
type CatalogTable, type CatalogTable,
type CatalogTableMetadataDeleteTarget,
type CatalogRepository, type CatalogRepository,
type DatabaseConfigurationInput, type DatabaseConfigurationInput,
type DatabaseTestResult, type DatabaseTestResult,
@@ -199,6 +202,71 @@ export class MemoryCatalogRepository implements CatalogRepository {
.map((relationship) => structuredClone(relationship)); .map((relationship) => structuredClone(relationship));
} }
async deleteDatabaseMetadata(
databaseIds: readonly string[],
target: CatalogDatabaseMetadataDeleteTarget,
): Promise<CatalogMetadataDeleteCounts | undefined> {
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<CatalogMetadataDeleteCounts | undefined> {
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( async planSchemaSync(
databaseId: string, databaseId: string,
scope: CatalogSyncScope, 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 { private refreshForeignKeyFlags(databaseId: string): void {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
for (const relationship of this.relationships.values()) { for (const relationship of this.relationships.values()) {
+13 -1
View File
@@ -1,6 +1,6 @@
import { CatalogOperationInProgressError } from "./types.js"; 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 { export class CatalogOperationCoordinator {
private readonly active = new Set<string>(); private readonly active = new Set<string>();
@@ -25,4 +25,16 @@ export class CatalogOperationCoordinator {
release(); release();
} }
} }
async runMany<T>(databaseIds: readonly string[], operation: () => Promise<T>): Promise<T> {
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();
}
}
} }
+103
View File
@@ -16,6 +16,8 @@ import {
CatalogConnectorError, CatalogConnectorError,
CatalogUnavailableError, CatalogUnavailableError,
type CatalogColumn, type CatalogColumn,
type CatalogDatabaseMetadataDeleteTarget,
type CatalogMetadataDeleteCounts,
type CatalogRelationship, type CatalogRelationship,
type CatalogSchemaDiff, type CatalogSchemaDiff,
type CatalogSyncCounts, type CatalogSyncCounts,
@@ -24,6 +26,7 @@ import {
type CatalogSyncRunUpdate, type CatalogSyncRunUpdate,
type CatalogSyncScope, type CatalogSyncScope,
type CatalogTable, type CatalogTable,
type CatalogTableMetadataDeleteTarget,
type CatalogRepository, type CatalogRepository,
type DatabaseBinding, type DatabaseBinding,
type DatabaseConfigurationInput, type DatabaseConfigurationInput,
@@ -559,6 +562,104 @@ export class KyselyCatalogRepository implements CatalogRepository {
})); }));
} }
async deleteDatabaseMetadata(
databaseIds: readonly string[],
target: CatalogDatabaseMetadataDeleteTarget,
): Promise<CatalogMetadataDeleteCounts | undefined> {
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<number>`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<number>`count(*)::int`.as("count"))
.where("databaseId", "in", selectedDatabaseIds).executeTakeFirst();
const columnCount = await trx.selectFrom("catalogColumns")
.innerJoin("catalogTables", "catalogTables.id", "catalogColumns.tableId")
.select(sql<number>`count(*)::int`.as("count"))
.where("catalogTables.databaseId", "in", selectedDatabaseIds).executeTakeFirst();
const relationshipCount = await trx.selectFrom("catalogRelationships")
.select(sql<number>`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<CatalogMetadataDeleteCounts | undefined> {
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<number>`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<number>`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( async planSchemaSync(
databaseId: string, databaseId: string,
scope: CatalogSyncScope, scope: CatalogSyncScope,
@@ -1002,6 +1103,8 @@ export class UnavailableCatalogRepository implements CatalogRepository {
async getColumn(): Promise<CatalogColumn | undefined> { return this.fail(); } async getColumn(): Promise<CatalogColumn | undefined> { return this.fail(); }
async updateColumnMetadata(): Promise<CatalogColumn | undefined> { return this.fail(); } async updateColumnMetadata(): Promise<CatalogColumn | undefined> { return this.fail(); }
async listRelationships(): Promise<CatalogRelationship[]> { return this.fail(); } async listRelationships(): Promise<CatalogRelationship[]> { return this.fail(); }
async deleteDatabaseMetadata(): Promise<CatalogMetadataDeleteCounts | undefined> { return this.fail(); }
async deleteTableMetadata(): Promise<CatalogMetadataDeleteCounts | undefined> { return this.fail(); }
async planSchemaSync(): Promise<CatalogSchemaDiff> { return this.fail(); } async planSchemaSync(): Promise<CatalogSchemaDiff> { return this.fail(); }
async applySchemaSync(): Promise<CatalogSyncCounts | undefined> { return this.fail(); } async applySchemaSync(): Promise<CatalogSyncCounts | undefined> { return this.fail(); }
async createSyncRun(): Promise<CatalogSyncRun> { return this.fail(); } async createSyncRun(): Promise<CatalogSyncRun> { return this.fail(); }
+18
View File
@@ -133,6 +133,15 @@ export interface CatalogRelationship {
updatedAt: string; updatedAt: string;
} }
export type CatalogDatabaseMetadataDeleteTarget = "tables" | "relationships";
export type CatalogTableMetadataDeleteTarget = "columns" | "relationships";
export interface CatalogMetadataDeleteCounts {
tables: number;
columns: number;
relationships: number;
}
export interface ObservedRelationshipColumn { export interface ObservedRelationshipColumn {
position: number; position: number;
sourceColumnName: string; sourceColumnName: string;
@@ -284,6 +293,15 @@ export interface CatalogRepository {
generatedDescription: string | null, generatedDescription: string | null,
): Promise<CatalogColumn | undefined>; ): Promise<CatalogColumn | undefined>;
listRelationships(databaseId: string): Promise<CatalogRelationship[]>; listRelationships(databaseId: string): Promise<CatalogRelationship[]>;
deleteDatabaseMetadata(
databaseIds: readonly string[],
target: CatalogDatabaseMetadataDeleteTarget,
): Promise<CatalogMetadataDeleteCounts | undefined>;
deleteTableMetadata(
databaseId: string,
tableIds: readonly string[],
target: CatalogTableMetadataDeleteTarget,
): Promise<CatalogMetadataDeleteCounts | undefined>;
planSchemaSync( planSchemaSync(
databaseId: string, databaseId: string,
scope: CatalogSyncScope, scope: CatalogSyncScope,
+53 -1
View File
@@ -3,6 +3,7 @@ import { setTimeout as delay } from "node:timers/promises";
import { z } from "zod"; import { z } from "zod";
import { isPrincipalContext, requirePermission } from "../auth/authorization.js"; import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
import type { CatalogSyncWorker } from "../catalog/sync-worker.js"; import type { CatalogSyncWorker } from "../catalog/sync-worker.js";
import type { CatalogOperationCoordinator } from "../catalog/operation-coordinator.js";
import { import {
CatalogConflictError, CatalogConflictError,
CatalogConnectorError, CatalogConnectorError,
@@ -23,6 +24,14 @@ const createRunSchema = z.object({
scope: z.enum(["tables", "columns", "relationships", "all"]), scope: z.enum(["tables", "columns", "relationships", "all"]),
tableIds: z.array(idSchema).max(10_000).default([]), tableIds: z.array(idSchema).max(10_000).default([]),
}).strict(); }).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 confirmationSchema = z.object({ confirmationToken: z.string().uuid() }).strict();
const eventQuerySchema = z.object({ after: z.coerce.number().int().nonnegative().default(0) }); const eventQuerySchema = z.object({ after: z.coerce.number().int().nonnegative().default(0) });
@@ -62,7 +71,11 @@ function publicRun(run: CatalogSyncRun) {
export function catalogSchemaRoutes( export function catalogSchemaRoutes(
app: FastifyInstance, app: FastifyInstance,
deps: { repository: CatalogRepository; worker: CatalogSyncWorker }, deps: {
repository: CatalogRepository;
worker: CatalogSyncWorker;
operations?: CatalogOperationCoordinator;
},
): void { ): void {
app.get("/catalog/databases/:databaseId/tables/:tableId/columns", async (request, reply) => { app.get("/catalog/databases/:databaseId/tables/:tableId/columns", async (request, reply) => {
if (!manage(request, reply)) return reply; if (!manage(request, reply)) return reply;
@@ -114,6 +127,45 @@ export function catalogSchemaRoutes(
} catch (error) { return safeError(reply, error); } } 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) => { app.post("/catalog/databases/:databaseId/sync-runs", async (request, reply) => {
if (!manage(request, reply)) return reply; if (!manage(request, reply)) return reply;
try { try {
@@ -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(); await container.stop();
} }
}, 60_000); }, 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> { async function waitFor(repository: MemoryCatalogRepository, runId: string, state: CatalogSyncRun["state"]): Promise<CatalogSyncRun> {
for (let attempt = 0; attempt < 100; attempt += 1) { for (let attempt = 0; attempt < 100; attempt += 1) {
const run = await repository.getSyncRun(runId); 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 () => { test("synchronizes a full physical schema and derives primary and foreign key flags", async () => {
const { app, repository, database } = await setup(); const { app, repository, database } = await setup();
const started = await app.inject({ 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((await repository.listTables(database.id)).map((table) => table.name)).toEqual(["patients"]);
expect(scan).toHaveBeenCalledTimes(3); 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);
});
@@ -1,3 +1,7 @@
---
status: superseded by ADR-0008
---
# Hard-delete catalog tables during synchronization # Hard-delete catalog tables during synchronization
An explicit Table Synchronization makes the Catalog Table membership exactly match a successful An explicit Table Synchronization makes the Catalog Table membership exactly match a successful
@@ -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.
@@ -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 `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 tre campi indipendenti senza fallback visivo: source comment read-only, generated description
modificabile e description modificabile. I valori null restano celle e controlli vuoti. 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` 47. Le Catalog Table non possono essere aggiunte o rinominate manualmente. Un amministratore può
legge dal database esterno le tabelle PostgreSQL ordinarie e partizionate dello schema scelto; però ripulire esplicitamente le proiezioni nel Metadata Catalog senza modificare il database
viste e materialized view sono escluse. 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 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 diff viene applicato atomicamente soltanto se la version del Workspace Database è ancora quella
sottoposta a scansione. Una scansione fallita non modifica il catalogo. 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 Workspace Database, Catalog Table, Catalog Column e Catalog Relationship sono implementati con
React/Vite e il design system ThothII. React/Vite e il design system ThothII.
La navigazione è gerarchica e locale al database (`Overview | Tables`), senza menu o filtri globali 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 immutabili i fatti fisici e consente di modificare separatamente Description e Generated
Description. Colonne e relazioni seguono la stessa gerarchia: Columns appartiene al dettaglio 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 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 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`, espongono gli scope fisici come azioni distinte: `Synchronize tables`, `Synchronize all columns`,
`Synchronize relationships` e `Synchronize all`. La grid Tables espone invece `Synchronize `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 columns` per le tabelle selezionate. Le selezioni database aggiungono `Delete all tables` e
non mostrano controlli di selezione inerti. `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 ### Step 6: introspezione
+26
View File
@@ -123,6 +123,15 @@ export interface CatalogRelationship {
updatedAt: string; 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 CatalogSyncScope = "tables" | "columns" | "relationships" | "all";
export type CatalogSyncState = "queued" | "running" | "awaiting_confirmation" | "applying" export type CatalogSyncState = "queued" | "running" | "awaiting_confirmation" | "applying"
| "succeeded" | "failed" | "cancelled" | "interrupted"; | "succeeded" | "failed" | "cancelled" | "interrupted";
@@ -228,6 +237,23 @@ export const updateCatalogColumnMetadata = (
export const listCatalogRelationships = (databaseId: string) => export const listCatalogRelationships = (databaseId: string) =>
apiFetch<CatalogRelationship[]>(`/catalog/databases/${encodeURIComponent(databaseId)}/relationships`); apiFetch<CatalogRelationship[]>(`/catalog/databases/${encodeURIComponent(databaseId)}/relationships`);
export const deleteCatalogDatabaseMetadata = (
databaseIds: string[],
target: CatalogDatabaseMetadataDeleteTarget,
) => apiFetch<CatalogMetadataDeleteCounts>("/catalog/databases/metadata-cleanup", {
method: "POST",
body: JSON.stringify({ databaseIds, target }),
});
export const deleteCatalogTableMetadata = (
databaseId: string,
tableIds: string[],
target: CatalogTableMetadataDeleteTarget,
) => apiFetch<CatalogMetadataDeleteCounts>(
`/catalog/databases/${encodeURIComponent(databaseId)}/tables/metadata-cleanup`,
{ method: "POST", body: JSON.stringify({ tableIds, target }) },
);
export const startCatalogSync = ( export const startCatalogSync = (
databaseId: string, databaseId: string,
version: number, version: number,
@@ -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 () => { test("presents completed synchronization steps as success and skips unneeded confirmation", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const run = { 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(); const user = userEvent.setup();
let startBody: unknown; let startBody: unknown;
const run = makeSyncRun("columns", [patientsTable.id]); 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/ }); const tableRow = await screen.findByRole("row", { name: /patients/ });
await user.click(within(tableRow).getByRole("checkbox")); await user.click(within(tableRow).getByRole("checkbox"));
const synchronizeColumns = screen.getByRole("button", { name: "Synchronize columns" }); await user.click(screen.getByRole("button", { name: "Actions" }));
expect(synchronizeColumns).toBeVisible(); const synchronizeColumns = await screen.findByRole("menuitem", { name: "Synchronize columns" });
expect(synchronizeColumns).toBeEnabled(); 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 user.click(synchronizeColumns);
await waitFor(() => expect(startBody).toEqual({ 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()); 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 () => { test("navigates purely from a database to its tables and edits review metadata", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
let patchBody: unknown; let patchBody: unknown;
@@ -13,12 +13,14 @@ import { ApiError, apiErrorMessage } from "../api/client";
import { import {
createCatalogDatabase, createCatalogDatabase,
deleteCatalogDatabase, deleteCatalogDatabase,
deleteCatalogDatabaseMetadata,
listCatalogDatabases, listCatalogDatabases,
replaceCatalogDatabaseSecrets, replaceCatalogDatabaseSecrets,
startCatalogSync, startCatalogSync,
testCatalogDatabase, testCatalogDatabase,
updateCatalogDatabase, updateCatalogDatabase,
type CatalogDatabase, type CatalogDatabase,
type CatalogDatabaseMetadataDeleteTarget,
type CatalogSecretName, type CatalogSecretName,
type CatalogSyncScope, type CatalogSyncScope,
type CatalogSyncRun, type CatalogSyncRun,
@@ -588,6 +590,28 @@ export function DatabaseManagementPage({
} }
}, [queryClient, rememberSyncRun]); }, [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) => { const syncDatabase = useCallback(async (scope: CatalogSyncScope) => {
if (!activeRow?.id || !activeRow.configured) return; if (!activeRow?.id || !activeRow.configured) return;
try { try {
@@ -677,6 +701,7 @@ export function DatabaseManagementPage({
onOpenSync={openSync} onOpenSync={openSync}
onTestSelected={testSelected} onTestSelected={testSelected}
onSyncSelected={syncSelected} onSyncSelected={syncSelected}
onDeleteMetadataSelected={deleteSelectedMetadata}
/> />
)} )}
</div> </div>
@@ -11,7 +11,11 @@ import "ag-grid-community/styles/ag-grid.css";
import "ag-grid-community/styles/ag-theme-alpine.css"; import "ag-grid-community/styles/ag-theme-alpine.css";
import { ChevronDown, Eye, Pencil, RefreshCw, Trash2, X } from "lucide-react"; import { ChevronDown, Eye, Pencil, RefreshCw, Trash2, X } from "lucide-react";
import { Button } from "../../components/ui/button"; 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 { statusLabel } from "./model";
import { databaseSyncItemClass, databaseSyncScopes } from "./DatabaseSyncMenu"; import { databaseSyncItemClass, databaseSyncScopes } from "./DatabaseSyncMenu";
@@ -31,6 +35,10 @@ interface DatabaseGridProps {
onOpenSync: (row: CatalogDatabase) => void; onOpenSync: (row: CatalogDatabase) => void;
onTestSelected: (rows: CatalogDatabase[]) => Promise<void>; onTestSelected: (rows: CatalogDatabase[]) => Promise<void>;
onSyncSelected: (rows: CatalogDatabase[], scope: CatalogSyncScope) => Promise<void>; onSyncSelected: (rows: CatalogDatabase[], scope: CatalogSyncScope) => Promise<void>;
onDeleteMetadataSelected: (
rows: CatalogDatabase[],
target: CatalogDatabaseMetadataDeleteTarget,
) => Promise<void>;
} }
interface DatabaseGridContext { interface DatabaseGridContext {
@@ -146,11 +154,14 @@ export function DatabaseGrid({
onOpenSync, onOpenSync,
onTestSelected, onTestSelected,
onSyncSelected, onSyncSelected,
onDeleteMetadataSelected,
}: DatabaseGridProps) { }: DatabaseGridProps) {
const compact = useCompactViewport(); const compact = useCompactViewport();
const gridRef = useRef<AgGridReact<CatalogDatabase>>(null); const gridRef = useRef<AgGridReact<CatalogDatabase>>(null);
const actionsTriggerRef = useRef<HTMLButtonElement>(null);
const [selectedRows, setSelectedRows] = useState<CatalogDatabase[]>([]); const [selectedRows, setSelectedRows] = useState<CatalogDatabase[]>([]);
const [action, setAction] = useState<"test" | "sync" | null>(null); const [action, setAction] = useState<"test" | "sync" | "delete" | null>(null);
const [pendingDelete, setPendingDelete] = useState<CatalogDatabaseMetadataDeleteTarget | null>(null);
const context = useMemo<DatabaseGridContext>( const context = useMemo<DatabaseGridContext>(
() => ({ canManage, onView, onEdit, onDelete, onOpenSync }), () => ({ canManage, onView, onEdit, onDelete, onOpenSync }),
[canManage, onView, onEdit, onDelete, onOpenSync], [canManage, onView, onEdit, onDelete, onOpenSync],
@@ -236,12 +247,25 @@ export function DatabaseGrid({
}).length; }).length;
const canTestSelection = canManage && selectedRows.length > 0 && selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun); 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 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<void>) => { 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<void>) => {
setAction(kind); setAction(kind);
try { try {
await operation(); await operation();
gridRef.current?.api.deselectAll(); gridRef.current?.api.deselectAll();
setSelectedRows([]); setSelectedRows([]);
setPendingDelete(null);
if (kind === "delete") window.setTimeout(() => searchInputRef.current?.focus(), 0);
} finally { setAction(null); } } finally { setAction(null); }
}; };
@@ -249,11 +273,44 @@ export function DatabaseGrid({
<section aria-label="Workspace databases" className="mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5"> <section aria-label="Workspace databases" className="mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5">
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2"> <div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
{selectedRows.length > 0 ? ( {selectedRows.length > 0 ? (
pendingDelete ? (
<div
role="group"
aria-labelledby="database-cleanup-confirmation"
className="flex min-w-0 flex-1 flex-wrap items-center gap-2"
onKeyDown={(event) => {
if (event.key === "Escape" && action === null) closeDeleteConfirmation();
}}
>
<div className="mr-auto min-w-56">
<p id="database-cleanup-confirmation" className="text-sm font-semibold">
{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"}?`}
</p>
<p className="text-xs text-muted-foreground">
{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."}
</p>
</div>
<Button type="button" variant="ghost" disabled={action !== null} onClick={closeDeleteConfirmation}>Cancel</Button>
<Button
id="database-cleanup-confirm-button"
type="button"
variant="destructive"
disabled={action !== null}
onClick={() => void perform("delete", () => onDeleteMetadataSelected(selectedRows, pendingDelete))}
>
<Trash2 />{pendingDelete === "tables" ? "Delete catalog tables" : "Delete relationships"}
</Button>
</div>
) : (
<> <>
<span className="text-sm font-semibold">{selectedRows.length} selected</span> <span className="text-sm font-semibold">{selectedRows.length} selected</span>
{hiddenSelected ? <span className="text-xs text-muted-foreground">{hiddenSelected} hidden by filter</span> : null} {hiddenSelected ? <span className="text-xs text-muted-foreground">{hiddenSelected} hidden by filter</span> : null}
<Menu.Root> <Menu.Root>
<Menu.Trigger className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={action !== null}>Actions <ChevronDown className="size-4" /></Menu.Trigger> <Menu.Trigger ref={actionsTriggerRef} className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={action !== null}>Actions <ChevronDown className="size-4" /></Menu.Trigger>
<Menu.Portal> <Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4}> <Menu.Positioner side="bottom" align="start" sideOffset={4}>
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none"> <Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
@@ -269,12 +326,28 @@ export function DatabaseGrid({
{label} {label}
</Menu.Item> </Menu.Item>
))} ))}
<Menu.Separator className="my-1 h-px bg-border" />
<Menu.Item
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")}
>
Delete all tables
</Menu.Item>
<Menu.Item
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("relationships")}
>
Delete all relationships
</Menu.Item>
</Menu.Popup> </Menu.Popup>
</Menu.Positioner> </Menu.Positioner>
</Menu.Portal> </Menu.Portal>
</Menu.Root> </Menu.Root>
<Button type="button" variant="ghost" disabled={action !== null} onClick={() => { gridRef.current?.api.deselectAll(); setSelectedRows([]); }}><X />Clear</Button> <Button type="button" variant="ghost" disabled={action !== null} onClick={() => { gridRef.current?.api.deselectAll(); setSelectedRows([]); }}><X />Clear</Button>
</> </>
)
) : <><span className="thot-label whitespace-nowrap">Workspace databases</span><input ) : <><span className="thot-label whitespace-nowrap">Workspace databases</span><input
ref={searchInputRef} ref={searchInputRef}
className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition focus:border-primary/60 focus:ring-3 focus:ring-ring/15" className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
@@ -308,7 +381,10 @@ export function DatabaseGrid({
getRowId={({ data }) => data.workspaceId} getRowId={({ data }) => data.workspaceId}
rowSelection={{ mode: "multiRow", selectAll: "filtered", enableClickSelection: false }} rowSelection={{ mode: "multiRow", selectAll: "filtered", enableClickSelection: false }}
selectionColumnDef={{ width: 44, maxWidth: 44, pinned: "left" }} 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} rowHeight={44}
headerHeight={38} headerHeight={38}
animateRows={false} animateRows={false}
@@ -1,18 +1,21 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Menu } from "@base-ui/react/menu";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AgGridReact } from "ag-grid-react"; import { AgGridReact } from "ag-grid-react";
import type { ColDef, ICellRendererParams } from "ag-grid-community"; 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 { toast } from "sonner";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { ApiError, apiErrorMessage } from "../../api/client"; import { ApiError, apiErrorMessage } from "../../api/client";
import { import {
deleteCatalogTableMetadata,
listCatalogTables, listCatalogTables,
startCatalogSync, startCatalogSync,
updateCatalogTableMetadata, updateCatalogTableMetadata,
type CatalogDatabase, type CatalogDatabase,
type CatalogSyncRun, type CatalogSyncRun,
type CatalogTable, type CatalogTable,
type CatalogTableMetadataDeleteTarget,
} from "../../api/catalog-databases"; } from "../../api/catalog-databases";
import type { DatabaseNavigationState } from "./model"; import type { DatabaseNavigationState } from "./model";
import { DatabaseColumns } from "./DatabaseColumns"; import { DatabaseColumns } from "./DatabaseColumns";
@@ -74,11 +77,14 @@ export function DatabaseTables({
const [editorVersion, setEditorVersion] = useState<number | null>(null); const [editorVersion, setEditorVersion] = useState<number | null>(null);
const [stale, setStale] = useState(false); const [stale, setStale] = useState(false);
const [staleBannerOpen, setStaleBannerOpen] = useState(true); 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<CatalogTableMetadataDeleteTarget | null>(null);
const [columnNavigation, setColumnNavigation] = useState<DatabaseNavigationState>({ dirty: false, busy: false }); const [columnNavigation, setColumnNavigation] = useState<DatabaseNavigationState>({ dirty: false, busy: false });
const gridRef = useRef<AgGridReact<CatalogTable>>(null); const gridRef = useRef<AgGridReact<CatalogTable>>(null);
const originRef = useRef<HTMLButtonElement | null>(null); const originRef = useRef<HTMLButtonElement | null>(null);
const headingRef = useRef<HTMLHeadingElement>(null); const headingRef = useRef<HTMLHeadingElement>(null);
const actionsTriggerRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const activeTable = activeTableId ? data.find((table) => table.id === activeTableId) : undefined; const activeTable = activeTableId ? data.find((table) => table.id === activeTableId) : undefined;
const editorFingerprint = JSON.stringify([description, generatedDescription]); const editorFingerprint = JSON.stringify([description, generatedDescription]);
const editorDirty = Boolean(activeTableId && editorFingerprint !== baseline); const editorDirty = Boolean(activeTableId && editorFingerprint !== baseline);
@@ -93,6 +99,15 @@ export function DatabaseTables({
setStale(true); setStale(true);
setStaleBannerOpen(true); setStaleBannerOpen(true);
}, [activeTable, activeTableId, busy, editorVersion]); }, [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) => { const openTable = (table: CatalogTable, section: "overview" | "columns", origin: HTMLButtonElement) => {
originRef.current = origin; 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"); 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); } } 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<ColDef<CatalogTable>[]>(() => [ const columns = useMemo<ColDef<CatalogTable>[]>(() => [
{ field: "name", headerName: "Name", minWidth: 250, flex: 1, cellClass: "font-mono text-xs" }, { field: "name", headerName: "Name", minWidth: 250, flex: 1, cellClass: "font-mono text-xs" },
@@ -258,24 +293,80 @@ export function DatabaseTables({
<div className="mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5"> <div className="mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5">
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2"> <div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
{selectedIds.length > 0 ? ( {selectedIds.length > 0 ? (
pendingDelete ? (
<div
role="group"
aria-labelledby="table-cleanup-confirmation"
className="flex min-w-0 flex-1 flex-wrap items-center gap-2"
onKeyDown={(event) => {
if (event.key === "Escape" && busy === null) closeDeleteConfirmation();
}}
>
<div className="mr-auto min-w-56">
<p id="table-cleanup-confirmation" className="text-sm font-semibold">
{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"}?`}
</p>
<p className="text-xs text-muted-foreground">
{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."}
</p>
</div>
<Button type="button" variant="ghost" disabled={busy !== null} onClick={closeDeleteConfirmation}>Cancel</Button>
<Button
id="table-cleanup-confirm-button"
type="button"
variant="destructive"
disabled={busy !== null}
onClick={() => void deleteMetadata(pendingDelete)}
>
<Trash2 />{pendingDelete === "columns" ? "Delete columns" : "Delete relationships"}
</Button>
</div>
) : (
<> <>
<span className="text-sm font-semibold">{selectedIds.length} selected</span> <span className="text-sm font-semibold">{selectedIds.length} selected</span>
{visibleSelected !== selectedIds.length ? <span className="text-xs text-muted-foreground">{selectedIds.length - visibleSelected} hidden by filter</span> : null} {visibleSelected !== selectedIds.length ? <span className="text-xs text-muted-foreground">{selectedIds.length - visibleSelected} hidden by filter</span> : null}
<Button <Menu.Root>
type="button" <Menu.Trigger ref={actionsTriggerRef} className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy !== null}>Actions <ChevronDown className="size-4" /></Menu.Trigger>
variant="outline" <Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)}
title={!bindingReady ? "Test the current database binding before synchronizing columns" : undefined}
onClick={() => void synchronize("columns", selectedIds)} onClick={() => void synchronize("columns", selectedIds)}
> >
<RefreshCw className={busy === "sync" ? "animate-spin" : ""} />Synchronize columns Synchronize columns
</Button> </Menu.Item>
<Menu.Separator className="my-1 h-px bg-border" />
<Menu.Item
className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45"
disabled={!canManage || busy !== null || Boolean(currentRun)}
onClick={() => setPendingDelete("columns")}
>
Delete all columns
</Menu.Item>
<Menu.Item
className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45"
disabled={!canManage || busy !== null || Boolean(currentRun)}
onClick={() => setPendingDelete("relationships")}
>
Delete all relationships
</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Button type="button" variant="ghost" onClick={() => { gridRef.current?.api.deselectAll(); setSelectedIds([]); }}><X />Clear</Button> <Button type="button" variant="ghost" onClick={() => { gridRef.current?.api.deselectAll(); setSelectedIds([]); }}><X />Clear</Button>
</> </>
)
) : ( ) : (
<> <>
<span className="thot-label whitespace-nowrap">Catalog tables</span> <span className="thot-label whitespace-nowrap">Catalog tables</span>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search tables" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} /> <input ref={searchInputRef} className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search tables" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span <span
aria-live="polite" aria-live="polite"
className="whitespace-nowrap text-xs tabular-nums text-muted-foreground" className="whitespace-nowrap text-xs tabular-nums text-muted-foreground"
@@ -300,7 +391,10 @@ export function DatabaseTables({
defaultColDef={{ sortable: true, filter: true, resizable: true }} defaultColDef={{ sortable: true, filter: true, resizable: true }}
getRowId={({ data: table }) => table.id} getRowId={({ data: table }) => table.id}
rowSelection={{ mode: "multiRow", selectAll: "filtered", enableClickSelection: false }} 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())} onModelUpdated={({ api }) => setDisplayedCount(api.getDisplayedRowCount())}
rowHeight={44} rowHeight={44}
headerHeight={38} headerHeight={38}