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:
@@ -1,7 +1,15 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { expect, test } from "vitest";
|
||||
import { server } from "../test/msw";
|
||||
import { getCatalogMetrics, type CatalogMetrics } from "./catalog-databases";
|
||||
import {
|
||||
createCatalogRelationship,
|
||||
deleteCatalogRelationship,
|
||||
getCatalogMetrics,
|
||||
rebuildGeneratedRelationships,
|
||||
setCatalogRelationshipStatus,
|
||||
type CatalogMetrics,
|
||||
type CatalogRelationship,
|
||||
} from "./catalog-databases";
|
||||
|
||||
const databaseId = "d4baf0f5-b9c3-4dc5-aad2-2f5676a36e64";
|
||||
|
||||
@@ -58,3 +66,99 @@ test("propagates an unsuccessful catalog metrics response", async () => {
|
||||
|
||||
await expect(getCatalogMetrics()).rejects.toMatchObject({ status: 503 });
|
||||
});
|
||||
|
||||
const logicalRelationship: CatalogRelationship = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
databaseId,
|
||||
constraintName: null,
|
||||
sourceTableId: "22222222-2222-4222-8222-222222222222",
|
||||
sourceTableName: "orders",
|
||||
targetTableId: "33333333-3333-4333-8333-333333333333",
|
||||
targetTableName: "users",
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
deferrable: false,
|
||||
initiallyDeferred: false,
|
||||
columns: [{
|
||||
position: 1,
|
||||
sourceColumnId: "44444444-4444-4444-8444-444444444444",
|
||||
sourceColumnName: "user_id",
|
||||
targetColumnId: "55555555-5555-4555-8555-555555555555",
|
||||
targetColumnName: "id",
|
||||
}],
|
||||
origin: "manual",
|
||||
status: "active",
|
||||
lastSyncedDatabaseVersion: null,
|
||||
lastSyncedAt: null,
|
||||
createdAt: "2026-08-31T12:00:00.000Z",
|
||||
updatedAt: "2026-08-31T12:00:00.000Z",
|
||||
};
|
||||
|
||||
test("creates a manual relationship from the selected source and target columns", async () => {
|
||||
let requestBody: unknown;
|
||||
server.use(http.post("/api/catalog/databases/:databaseId/relationships", async ({ request }) => {
|
||||
requestBody = await request.json();
|
||||
return HttpResponse.json(logicalRelationship, { status: 201 });
|
||||
}));
|
||||
|
||||
await expect(createCatalogRelationship(
|
||||
databaseId,
|
||||
logicalRelationship.columns[0].sourceColumnId,
|
||||
logicalRelationship.columns[0].targetColumnId,
|
||||
)).resolves.toEqual(logicalRelationship);
|
||||
expect(requestBody).toEqual({
|
||||
sourceColumnId: logicalRelationship.columns[0].sourceColumnId,
|
||||
targetColumnId: logicalRelationship.columns[0].targetColumnId,
|
||||
});
|
||||
});
|
||||
|
||||
test("rebuilds generated relationships for one database", async () => {
|
||||
let calls = 0;
|
||||
server.use(http.post(
|
||||
"/api/catalog/databases/:databaseId/relationships/rebuild-generated",
|
||||
() => {
|
||||
calls += 1;
|
||||
return HttpResponse.json({ added: 12, alreadyPresent: 7, excluded: 3, ambiguous: 2 });
|
||||
},
|
||||
));
|
||||
|
||||
await expect(rebuildGeneratedRelationships(databaseId)).resolves.toEqual({
|
||||
added: 12,
|
||||
alreadyPresent: 7,
|
||||
excluded: 3,
|
||||
ambiguous: 2,
|
||||
});
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
test("changes whether a logical relationship is active or excluded", async () => {
|
||||
let requestBody: unknown;
|
||||
server.use(http.patch(
|
||||
"/api/catalog/databases/:databaseId/relationships/:relationshipId",
|
||||
async ({ request }) => {
|
||||
requestBody = await request.json();
|
||||
return HttpResponse.json({ ...logicalRelationship, status: "excluded" });
|
||||
},
|
||||
));
|
||||
|
||||
await expect(setCatalogRelationshipStatus(
|
||||
databaseId,
|
||||
logicalRelationship.id,
|
||||
"excluded",
|
||||
)).resolves.toMatchObject({ status: "excluded" });
|
||||
expect(requestBody).toEqual({ status: "excluded" });
|
||||
});
|
||||
|
||||
test("permanently deletes a logical relationship", async () => {
|
||||
let calls = 0;
|
||||
server.use(http.delete(
|
||||
"/api/catalog/databases/:databaseId/relationships/:relationshipId",
|
||||
() => {
|
||||
calls += 1;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
},
|
||||
));
|
||||
|
||||
await expect(deleteCatalogRelationship(databaseId, logicalRelationship.id)).resolves.toBeUndefined();
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,12 @@ import { joinBackendPath } from "./runtime-config";
|
||||
|
||||
export type DatabaseTransport = "postgres_direct" | "rest_api" | "ssh_tunnel";
|
||||
export type ConnectionStatus = "untested" | "reachable" | "failed";
|
||||
export type WorkspaceEvidenceState =
|
||||
| "not_declared"
|
||||
| "materialized_current_revision"
|
||||
| "configuration_required"
|
||||
| "configured_unverified"
|
||||
| "workspace_unavailable";
|
||||
export type CatalogSecretName =
|
||||
| "password"
|
||||
| "apiKey"
|
||||
@@ -33,6 +39,16 @@ export interface CatalogDatabase {
|
||||
workspaceName: string;
|
||||
workspaceDescription?: string;
|
||||
workspaceAvailable: boolean;
|
||||
workspaceRevision: { commit: string; blob: string } | null;
|
||||
workspaceEvidence: {
|
||||
sourceType: "filesystem" | "http" | "s3" | null;
|
||||
state: WorkspaceEvidenceState;
|
||||
};
|
||||
runtimeBinding: {
|
||||
transport: DatabaseTransport;
|
||||
configurationState: "ready" | "configuration_required";
|
||||
sessionTransportSupported: boolean;
|
||||
} | null;
|
||||
configured: boolean;
|
||||
engine: "postgres";
|
||||
databaseName: string;
|
||||
@@ -164,25 +180,37 @@ export interface CatalogRelationshipColumn {
|
||||
targetColumnName: string;
|
||||
}
|
||||
|
||||
export type CatalogRelationshipOrigin = "physical" | "generated" | "manual";
|
||||
export type CatalogRelationshipStatus = "active" | "excluded";
|
||||
|
||||
export interface CatalogRelationship {
|
||||
id: string;
|
||||
databaseId: string;
|
||||
constraintName: string;
|
||||
constraintName: string | null;
|
||||
sourceTableId: string;
|
||||
sourceTableName: string;
|
||||
targetTableId: string;
|
||||
targetTableName: string;
|
||||
updateRule: string;
|
||||
deleteRule: string;
|
||||
updateRule: string | null;
|
||||
deleteRule: string | null;
|
||||
deferrable: boolean;
|
||||
initiallyDeferred: boolean;
|
||||
columns: CatalogRelationshipColumn[];
|
||||
origin: CatalogRelationshipOrigin;
|
||||
status: CatalogRelationshipStatus;
|
||||
lastSyncedDatabaseVersion: number | null;
|
||||
lastSyncedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CatalogRelationshipRebuildResult {
|
||||
added: number;
|
||||
alreadyPresent: number;
|
||||
excluded: number;
|
||||
ambiguous: number;
|
||||
}
|
||||
|
||||
export type CatalogDatabaseMetadataDeleteTarget = "tables" | "relationships";
|
||||
export type CatalogTableMetadataDeleteTarget = "columns" | "relationships";
|
||||
|
||||
@@ -452,6 +480,36 @@ export const listSensitiveDataSuggestionEvents = (runId: string, after = 0) =>
|
||||
export const listCatalogRelationships = (databaseId: string) =>
|
||||
apiFetch<CatalogRelationship[]>(`/catalog/databases/${encodeURIComponent(databaseId)}/relationships`);
|
||||
|
||||
export const createCatalogRelationship = (
|
||||
databaseId: string,
|
||||
sourceColumnId: string,
|
||||
targetColumnId: string,
|
||||
) => apiFetch<CatalogRelationship>(
|
||||
`/catalog/databases/${encodeURIComponent(databaseId)}/relationships`,
|
||||
{ method: "POST", body: JSON.stringify({ sourceColumnId, targetColumnId }) },
|
||||
);
|
||||
|
||||
export const rebuildGeneratedRelationships = (databaseId: string) =>
|
||||
apiFetch<CatalogRelationshipRebuildResult>(
|
||||
`/catalog/databases/${encodeURIComponent(databaseId)}/relationships/rebuild-generated`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
|
||||
export const setCatalogRelationshipStatus = (
|
||||
databaseId: string,
|
||||
relationshipId: string,
|
||||
status: CatalogRelationshipStatus,
|
||||
) => apiFetch<CatalogRelationship>(
|
||||
`/catalog/databases/${encodeURIComponent(databaseId)}/relationships/${encodeURIComponent(relationshipId)}`,
|
||||
{ method: "PATCH", body: JSON.stringify({ status }) },
|
||||
);
|
||||
|
||||
export const deleteCatalogRelationship = (databaseId: string, relationshipId: string) =>
|
||||
apiFetch<void>(
|
||||
`/catalog/databases/${encodeURIComponent(databaseId)}/relationships/${encodeURIComponent(relationshipId)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
|
||||
export const deleteCatalogDatabaseMetadata = (
|
||||
databaseIds: string[],
|
||||
target: CatalogDatabaseMetadataDeleteTarget,
|
||||
|
||||
@@ -152,6 +152,15 @@ test.each([
|
||||
["sensitive_data_suggestion_history_request_invalid", "Sensitive suggestion history parameters are invalid."],
|
||||
["sensitive_data_suggestion_history_failed", "Sensitive suggestion history could not be loaded."],
|
||||
["sensitive_data_suggestion_run_not_found", "The sensitive suggestion run was not found."],
|
||||
["relationship_not_found", "The relationship no longer exists. Refresh and try again."],
|
||||
["relationship_duplicate", "This relationship already exists."],
|
||||
["relationship_target_not_unique", "The target column must be the only primary-key column of its table."],
|
||||
["relationship_type_incompatible", "Source and target column types are not compatible."],
|
||||
["relationship_read_only", "Physical relationships are read-only."],
|
||||
["relationship_request_invalid", "The relationship request is invalid."],
|
||||
["relationship_operation_failed", "The relationship operation failed."],
|
||||
["relationship_schema_stale", "Synchronize the current database schema before managing logical relationships."],
|
||||
["column_not_found", "The selected catalog column was not found. Refresh and try again."],
|
||||
])("maps the catalog error code %s to safe local copy", async (code, message) => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ code, message: "provider detail must not be trusted" }), {
|
||||
|
||||
@@ -35,6 +35,9 @@ const safeErrorCodes = new Set([
|
||||
"sensitive_data_suggestion_run_not_found",
|
||||
"schema_sync_conflict", "schema_introspection_failed", "schema_request_invalid",
|
||||
"schema_operation_failed", "sync_run_not_found", "table_stale", "column_stale",
|
||||
"relationship_not_found", "relationship_duplicate", "relationship_target_not_unique",
|
||||
"relationship_type_incompatible", "relationship_read_only", "relationship_request_invalid",
|
||||
"relationship_operation_failed", "relationship_schema_stale", "column_not_found",
|
||||
]);
|
||||
|
||||
type SafeErrorPayload = {
|
||||
@@ -104,6 +107,15 @@ const localCodeMessages: Record<string, string> = {
|
||||
sync_run_not_found: "The synchronization run was not found.",
|
||||
table_stale: "Table metadata changed. Reload and try again.",
|
||||
column_stale: "Column metadata changed. Reload and try again.",
|
||||
relationship_not_found: "The relationship no longer exists. Refresh and try again.",
|
||||
relationship_duplicate: "This relationship already exists.",
|
||||
relationship_target_not_unique: "The target column must be the only primary-key column of its table.",
|
||||
relationship_type_incompatible: "Source and target column types are not compatible.",
|
||||
relationship_read_only: "Physical relationships are read-only.",
|
||||
relationship_request_invalid: "The relationship request is invalid.",
|
||||
relationship_operation_failed: "The relationship operation failed.",
|
||||
relationship_schema_stale: "Synchronize the current database schema before managing logical relationships.",
|
||||
column_not_found: "The selected catalog column was not found. Refresh and try again.",
|
||||
};
|
||||
|
||||
const localStatusMessages: Record<number, string> = {
|
||||
|
||||
Reference in New Issue
Block a user