feat: consolidate database management work

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

Keep active sensitive-generation status in a tooltip and indicator, and update the layout E2E to follow the history action in its new database-scoped location.
This commit is contained in:
Codex
2026-09-01 14:46:55 +02:00
parent f586152636
commit 076c9742c5
73 changed files with 6966 additions and 610 deletions
+176 -4
View File
@@ -23,7 +23,10 @@ import {
type CatalogDatabaseMetadataDeleteTarget,
type CatalogMetadataDeleteCounts,
type CatalogMetrics,
type CatalogRelationship,
type CatalogLogicalRelationship,
type CatalogLogicalRelationshipCandidate,
type CatalogLogicalRelationshipContext,
type CatalogPhysicalRelationship,
type CatalogSchemaDiff,
type CatalogSyncCounts,
type CatalogSyncEvent,
@@ -145,6 +148,17 @@ interface CatalogRelationshipColumnTable {
targetColumnId: string;
}
interface CatalogLogicalRelationshipTable {
id: string;
databaseId: string;
sourceColumnId: string;
targetColumnId: string;
generated: Generated<boolean>;
deletedAt: Timestamp | null;
createdAt: Timestamp;
updatedAt: Timestamp;
}
interface DescriptionGenerationRunTable {
id: string;
databaseId: string;
@@ -241,6 +255,7 @@ export interface CatalogDatabase {
catalogColumns: CatalogColumnTable;
catalogRelationships: CatalogRelationshipTable;
catalogRelationshipColumns: CatalogRelationshipColumnTable;
catalogLogicalRelationships: CatalogLogicalRelationshipTable;
descriptionGenerationRuns: DescriptionGenerationRunTable;
descriptionGenerationEvents: DescriptionGenerationEventTable;
sensitiveDataSuggestionRuns: SensitiveDataSuggestionRunTable;
@@ -1022,7 +1037,7 @@ export class KyselyCatalogRepository implements CatalogRepository {
return rows.map(serializeSensitiveDataSuggestionEvent);
}
async listRelationships(databaseId: string): Promise<CatalogRelationship[]> {
async listRelationships(databaseId: string): Promise<CatalogPhysicalRelationship[]> {
const rows = await this.db.selectFrom("catalogRelationships as relationship")
.innerJoin("catalogTables as sourceTable", "sourceTable.id", "relationship.sourceTableId")
.innerJoin("catalogTables as targetTable", "targetTable.id", "relationship.targetTableId")
@@ -1049,7 +1064,7 @@ export class KyselyCatalogRepository implements CatalogRepository {
.where("pair.relationshipId", "in", rows.map((row) => row.id))
.orderBy("pair.relationshipId").orderBy("pair.position")
.execute();
const byRelationship = new Map<string, CatalogRelationship["columns"]>();
const byRelationship = new Map<string, CatalogPhysicalRelationship["columns"]>();
for (const pair of pairs) {
const items = byRelationship.get(pair.relationshipId) ?? [];
items.push(pair);
@@ -1061,9 +1076,160 @@ export class KyselyCatalogRepository implements CatalogRepository {
lastSyncedAt: row.lastSyncedAt === null ? null : new Date(row.lastSyncedAt).toISOString(),
createdAt: new Date(row.createdAt).toISOString(),
updatedAt: new Date(row.updatedAt).toISOString(),
origin: "physical" as const,
status: "active" as const,
}));
}
async listLogicalRelationships(databaseId: string): Promise<CatalogLogicalRelationship[]> {
const rows = await this.db.selectFrom("catalogLogicalRelationships as relationship")
.innerJoin("catalogColumns as sourceColumn", "sourceColumn.id", "relationship.sourceColumnId")
.innerJoin("catalogTables as sourceTable", "sourceTable.id", "sourceColumn.tableId")
.innerJoin("catalogColumns as targetColumn", "targetColumn.id", "relationship.targetColumnId")
.innerJoin("catalogTables as targetTable", "targetTable.id", "targetColumn.tableId")
.select([
"relationship.id", "relationship.databaseId", "relationship.generated",
"relationship.deletedAt", "relationship.createdAt", "relationship.updatedAt",
"sourceTable.id as sourceTableId", "sourceTable.name as sourceTableName",
"sourceColumn.id as sourceColumnId", "sourceColumn.name as sourceColumnName",
"targetTable.id as targetTableId", "targetTable.name as targetTableName",
"targetColumn.id as targetColumnId", "targetColumn.name as targetColumnName",
])
.where("relationship.databaseId", "=", databaseId)
.orderBy("sourceTable.name")
.orderBy("sourceColumn.name")
.orderBy("targetTable.name")
.orderBy("targetColumn.name")
.execute();
return rows.map((row) => ({
id: row.id,
databaseId: row.databaseId,
constraintName: null,
sourceTableId: row.sourceTableId,
sourceTableName: row.sourceTableName,
targetTableId: row.targetTableId,
targetTableName: row.targetTableName,
updateRule: null,
deleteRule: null,
deferrable: false,
initiallyDeferred: false,
columns: [{
position: 1,
sourceColumnId: row.sourceColumnId,
sourceColumnName: row.sourceColumnName,
targetColumnId: row.targetColumnId,
targetColumnName: row.targetColumnName,
}],
lastSyncedDatabaseVersion: null,
lastSyncedAt: null,
createdAt: new Date(row.createdAt).toISOString(),
updatedAt: new Date(row.updatedAt).toISOString(),
origin: row.generated ? "generated" : "manual",
status: row.deletedAt === null ? "active" : "excluded",
}));
}
async getLogicalRelationshipContext(
databaseId: string,
): Promise<CatalogLogicalRelationshipContext | undefined> {
if (!(await selectOne(this.db, databaseId))) return undefined;
const columns = await this.db.selectFrom("catalogColumns as column")
.innerJoin("catalogTables as table", "table.id", "column.tableId")
.select([
"column.id as columnId", "column.name as columnName", "column.dataType",
"column.primaryKeyPosition", "table.id as tableId", "table.name as tableName",
])
.where("table.databaseId", "=", databaseId)
.orderBy("table.name")
.orderBy("column.ordinalPosition")
.execute();
const primaryKeyCounts = new Map<string, number>();
for (const column of columns) {
if (column.primaryKeyPosition !== null) {
primaryKeyCounts.set(column.tableId, (primaryKeyCounts.get(column.tableId) ?? 0) + 1);
}
}
const physicalPairs = await this.db.selectFrom("catalogRelationshipColumns as pair")
.innerJoin("catalogRelationships as relationship", "relationship.id", "pair.relationshipId")
.select(["pair.sourceColumnId", "pair.targetColumnId"])
.where("relationship.databaseId", "=", databaseId)
.execute();
return {
endpoints: columns.map((column) => ({
...column,
tablePrimaryKeyColumnCount: primaryKeyCounts.get(column.tableId) ?? 0,
})),
physicalPairs,
logicalRelationships: await this.listLogicalRelationships(databaseId),
};
}
async insertLogicalRelationship(
databaseId: string,
sourceColumnId: string,
targetColumnId: string,
generated: boolean,
): Promise<CatalogLogicalRelationship | undefined> {
const id = randomUUID();
const inserted = await this.db.insertInto("catalogLogicalRelationships").values({
id, databaseId, sourceColumnId, targetColumnId, generated,
}).onConflict((conflict) => conflict
.columns(["databaseId", "sourceColumnId", "targetColumnId"])
.doNothing())
.returning("id")
.executeTakeFirst();
if (!inserted) return undefined;
return (await this.listLogicalRelationships(databaseId)).find((item) => item.id === id);
}
async insertGeneratedLogicalRelationships(
databaseId: string,
candidates: readonly CatalogLogicalRelationshipCandidate[],
): Promise<number> {
if (candidates.length === 0) return 0;
return await this.db.transaction().execute(async (trx) => {
let added = 0;
for (const candidate of candidates) {
const inserted = await trx.insertInto("catalogLogicalRelationships").values({
id: randomUUID(),
databaseId,
sourceColumnId: candidate.sourceColumnId,
targetColumnId: candidate.targetColumnId,
generated: true,
}).onConflict((conflict) => conflict
.columns(["databaseId", "sourceColumnId", "targetColumnId"])
.doNothing())
.returning("id")
.executeTakeFirst();
if (inserted) added += 1;
}
return added;
});
}
async setLogicalRelationshipStatus(
databaseId: string,
relationshipId: string,
status: CatalogLogicalRelationship["status"],
): Promise<CatalogLogicalRelationship | undefined> {
const updated = await this.db.updateTable("catalogLogicalRelationships")
.set({ deletedAt: status === "excluded" ? sql`now()` : null, updatedAt: sql`now()` })
.where("databaseId", "=", databaseId)
.where("id", "=", relationshipId)
.returning("id")
.executeTakeFirst();
if (!updated) return undefined;
return (await this.listLogicalRelationships(databaseId)).find((item) => item.id === relationshipId);
}
async deleteLogicalRelationship(databaseId: string, relationshipId: string): Promise<boolean> {
const result = await this.db.deleteFrom("catalogLogicalRelationships")
.where("databaseId", "=", databaseId)
.where("id", "=", relationshipId)
.executeTakeFirst();
return result.numDeletedRows > 0n;
}
async deleteDatabaseMetadata(
databaseIds: readonly string[],
target: CatalogDatabaseMetadataDeleteTarget,
@@ -1621,7 +1787,13 @@ export class UnavailableCatalogRepository implements CatalogRepository {
async updateSensitiveDataSuggestionRun(): Promise<SensitiveDataSuggestionRun | undefined> { return this.fail(); }
async appendSensitiveDataSuggestionEvent(): Promise<SensitiveDataSuggestionEvent> { return this.fail(); }
async listSensitiveDataSuggestionEvents(): Promise<SensitiveDataSuggestionEvent[]> { return this.fail(); }
async listRelationships(): Promise<CatalogRelationship[]> { return this.fail(); }
async listRelationships(): Promise<CatalogPhysicalRelationship[]> { return this.fail(); }
async listLogicalRelationships(): Promise<CatalogLogicalRelationship[]> { return this.fail(); }
async getLogicalRelationshipContext(): Promise<CatalogLogicalRelationshipContext | undefined> { return this.fail(); }
async insertLogicalRelationship(): Promise<CatalogLogicalRelationship | undefined> { return this.fail(); }
async insertGeneratedLogicalRelationships(): Promise<number> { return this.fail(); }
async setLogicalRelationshipStatus(): Promise<CatalogLogicalRelationship | undefined> { return this.fail(); }
async deleteLogicalRelationship(): Promise<boolean> { 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(); }