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:
@@ -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(); }
|
||||
|
||||
Reference in New Issue
Block a user