feat: add metadata catalog cleanup commands
This commit is contained in:
+11
-5
@@ -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
@@ -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
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(); }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,32 +273,81 @@ 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 ? (
|
||||||
<span className="text-sm font-semibold">{selectedRows.length} selected</span>
|
<div
|
||||||
{hiddenSelected ? <span className="text-xs text-muted-foreground">{hiddenSelected} hidden by filter</span> : null}
|
role="group"
|
||||||
<Menu.Root>
|
aria-labelledby="database-cleanup-confirmation"
|
||||||
<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>
|
className="flex min-w-0 flex-1 flex-wrap items-center gap-2"
|
||||||
<Menu.Portal>
|
onKeyDown={(event) => {
|
||||||
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
|
if (event.key === "Escape" && action === null) closeDeleteConfirmation();
|
||||||
<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={!canTestSelection} onClick={() => void perform("test", () => onTestSelected(selectedRows))}>Test connections</Menu.Item>
|
>
|
||||||
<Menu.Separator className="my-1 h-px bg-border" />
|
<div className="mr-auto min-w-56">
|
||||||
{databaseSyncScopes.map(({ scope, label }) => (
|
<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>
|
||||||
|
{hiddenSelected ? <span className="text-xs text-muted-foreground">{hiddenSelected} hidden by filter</span> : null}
|
||||||
|
<Menu.Root>
|
||||||
|
<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.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={!canTestSelection} onClick={() => void perform("test", () => onTestSelected(selectedRows))}>Test connections</Menu.Item>
|
||||||
|
<Menu.Separator className="my-1 h-px bg-border" />
|
||||||
|
{databaseSyncScopes.map(({ scope, label }) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={scope}
|
||||||
|
className={databaseSyncItemClass}
|
||||||
|
disabled={!canSyncSelection}
|
||||||
|
onClick={() => void perform("sync", () => onSyncSelected(selectedRows, scope))}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
<Menu.Separator className="my-1 h-px bg-border" />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
key={scope}
|
className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45"
|
||||||
className={databaseSyncItemClass}
|
disabled={!canDeleteMetadataSelection}
|
||||||
disabled={!canSyncSelection}
|
onClick={() => setPendingDelete("tables")}
|
||||||
onClick={() => void perform("sync", () => onSyncSelected(selectedRows, scope))}
|
|
||||||
>
|
>
|
||||||
{label}
|
Delete all tables
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
))}
|
<Menu.Item
|
||||||
</Menu.Popup>
|
className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45"
|
||||||
</Menu.Positioner>
|
disabled={!canDeleteMetadataSelection}
|
||||||
</Menu.Portal>
|
onClick={() => setPendingDelete("relationships")}
|
||||||
</Menu.Root>
|
>
|
||||||
<Button type="button" variant="ghost" disabled={action !== null} onClick={() => { gridRef.current?.api.deselectAll(); setSelectedRows([]); }}><X />Clear</Button>
|
Delete all relationships
|
||||||
</>
|
</Menu.Item>
|
||||||
|
</Menu.Popup>
|
||||||
|
</Menu.Positioner>
|
||||||
|
</Menu.Portal>
|
||||||
|
</Menu.Root>
|
||||||
|
<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 ? (
|
||||||
<span className="text-sm font-semibold">{selectedIds.length} selected</span>
|
<div
|
||||||
{visibleSelected !== selectedIds.length ? <span className="text-xs text-muted-foreground">{selectedIds.length - visibleSelected} hidden by filter</span> : null}
|
role="group"
|
||||||
<Button
|
aria-labelledby="table-cleanup-confirmation"
|
||||||
type="button"
|
className="flex min-w-0 flex-1 flex-wrap items-center gap-2"
|
||||||
variant="outline"
|
onKeyDown={(event) => {
|
||||||
disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)}
|
if (event.key === "Escape" && busy === null) closeDeleteConfirmation();
|
||||||
title={!bindingReady ? "Test the current database binding before synchronizing columns" : undefined}
|
}}
|
||||||
onClick={() => void synchronize("columns", selectedIds)}
|
|
||||||
>
|
>
|
||||||
<RefreshCw className={busy === "sync" ? "animate-spin" : ""} />Synchronize columns
|
<div className="mr-auto min-w-56">
|
||||||
</Button>
|
<p id="table-cleanup-confirmation" className="text-sm font-semibold">
|
||||||
<Button type="button" variant="ghost" onClick={() => { gridRef.current?.api.deselectAll(); setSelectedIds([]); }}><X />Clear</Button>
|
{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>
|
||||||
|
{visibleSelected !== selectedIds.length ? <span className="text-xs text-muted-foreground">{selectedIds.length - visibleSelected} hidden by filter</span> : null}
|
||||||
|
<Menu.Root>
|
||||||
|
<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>
|
||||||
|
<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)}
|
||||||
|
onClick={() => void synchronize("columns", selectedIds)}
|
||||||
|
>
|
||||||
|
Synchronize columns
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<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}
|
||||||
|
|||||||
Reference in New Issue
Block a user