feat: implement metadata catalog database management

This commit is contained in:
Codex
2026-08-27 22:43:54 +02:00
parent 705af3aeb2
commit 79c4c925b5
86 changed files with 12566 additions and 135 deletions
+265
View File
@@ -0,0 +1,265 @@
import { apiFetch, assertSameOriginRequestUrl, BASE } from "./client";
import { joinBackendPath } from "./runtime-config";
export type DatabaseTransport = "postgres_direct" | "rest_api" | "ssh_tunnel";
export type ConnectionStatus = "untested" | "reachable" | "failed";
export type CatalogSecretName =
| "password"
| "apiKey"
| "sshPrivateKey"
| "sshPrivateKeyPassphrase"
| "sshKnownHosts"
| "tlsCa";
export interface DatabaseBinding {
transport: DatabaseTransport;
host?: string;
port?: number;
username?: string;
baseUrl?: string;
restPath?: string;
restAuth?: "none" | "bearer" | "x-api-key";
tlsServername?: string;
sshHost?: string;
sshPort?: number;
sshUsername?: string;
sshTargetHost?: string;
sshTargetPort?: number;
}
export interface CatalogDatabase {
id?: string;
workspaceId: string;
workspaceName: string;
workspaceDescription?: string;
workspaceAvailable: boolean;
configured: boolean;
engine: "postgres";
databaseName: string;
schema: string;
version: number;
createdAt: string;
updatedAt: string;
binding: DatabaseBinding;
connectionStatus: ConnectionStatus;
testedVersion?: number;
lastTestedAt?: string;
lastErrorCode?: string;
lastErrorMessage?: string;
schemaSyncedVersion?: number;
schemaSyncedAt?: string;
activeSyncRun?: CatalogSyncRun;
secrets: Record<CatalogSecretName, boolean>;
}
export interface DatabaseConfiguration {
workspaceId: string;
engine: "postgres";
databaseName: string;
schema: string;
binding: DatabaseBinding;
}
export interface CatalogTable {
id: string;
databaseId: string;
name: string;
sourceComment: string | null;
description: string | null;
generatedDescription: string | null;
lastSyncedDatabaseVersion?: number | null;
lastSyncedAt?: string | null;
version: number;
createdAt: string;
updatedAt: string;
}
export interface CatalogColumn {
id: string;
tableId: string;
name: string;
ordinalPosition: number;
dataType: string;
isNullable: boolean;
defaultExpression: string | null;
primaryKeyPosition: number | null;
isPrimaryKey: boolean;
isForeignKey: boolean;
foreignKeyCount: number;
sourceComment: string | null;
description: string | null;
generatedDescription: string | null;
lastSyncedDatabaseVersion: number | null;
lastSyncedAt: string | null;
version: number;
createdAt: string;
updatedAt: string;
}
export interface CatalogRelationshipColumn {
position: number;
sourceColumnId: string;
sourceColumnName: string;
targetColumnId: string;
targetColumnName: string;
}
export interface CatalogRelationship {
id: string;
databaseId: string;
constraintName: string;
sourceTableId: string;
sourceTableName: string;
targetTableId: string;
targetTableName: string;
updateRule: string;
deleteRule: string;
deferrable: boolean;
initiallyDeferred: boolean;
columns: CatalogRelationshipColumn[];
lastSyncedDatabaseVersion: number | null;
lastSyncedAt: string | null;
createdAt: string;
updatedAt: string;
}
export type CatalogSyncScope = "tables" | "columns" | "relationships" | "all";
export type CatalogSyncState = "queued" | "running" | "awaiting_confirmation" | "applying"
| "succeeded" | "failed" | "cancelled" | "interrupted";
export type CatalogSyncPhase = "queued" | "connecting" | "scanning_tables" | "scanning_columns"
| "scanning_relationships" | "planning" | "awaiting_confirmation" | "applying" | "completed";
export interface CatalogSchemaDiff {
deletedTables: string[];
deletedColumns: Array<{ tableName: string; columnName: string }>;
deletedRelationships: Array<{ sourceTableName: string; constraintName: string }>;
}
export interface CatalogSyncRun {
id: string;
databaseId: string;
scope: CatalogSyncScope;
tableIds: string[];
state: CatalogSyncState;
phase: CatalogSyncPhase;
requestedDatabaseVersion: number;
plannedDiff: CatalogSchemaDiff | null;
confirmationToken: string | null;
counts: { tables?: number; columns?: number; relationships?: number; created?: number; updated?: number; deleted?: number };
errorCode: string | null;
errorMessage: string | null;
cancelRequested: boolean;
createdAt: string;
startedAt: string | null;
updatedAt: string;
finishedAt: string | null;
heartbeatAt: string | null;
}
export interface CatalogSyncEvent {
id: number;
runId: string;
sequence: number;
level: "info" | "warning" | "error";
eventType: string;
message: string;
data: Record<string, unknown>;
createdAt: string;
}
export const listCatalogDatabases = () => apiFetch<CatalogDatabase[]>("/catalog/databases");
export const createCatalogDatabase = (input: DatabaseConfiguration) =>
apiFetch<CatalogDatabase>("/catalog/databases", { method: "POST", body: JSON.stringify(input) });
export const updateCatalogDatabase = (id: string, version: number, input: DatabaseConfiguration) =>
apiFetch<CatalogDatabase>(`/catalog/databases/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify({ ...input, version }),
});
export const replaceCatalogDatabaseSecrets = (
id: string,
version: number,
values: Partial<Record<CatalogSecretName, string>>,
) => apiFetch<CatalogDatabase>(`/catalog/databases/${encodeURIComponent(id)}/secrets`, {
method: "PUT",
body: JSON.stringify({ version, values }),
});
export const testCatalogDatabase = (id: string, version: number) =>
apiFetch<CatalogDatabase>(`/catalog/databases/${encodeURIComponent(id)}/test`, {
method: "POST",
body: JSON.stringify({ version }),
});
export const deleteCatalogDatabase = (id: string, version: number) =>
apiFetch<void>(`/catalog/databases/${encodeURIComponent(id)}?version=${version}`, { method: "DELETE" });
export const listCatalogTables = (databaseId: string) =>
apiFetch<CatalogTable[]>(`/catalog/databases/${encodeURIComponent(databaseId)}/tables`);
export const updateCatalogTableMetadata = (
databaseId: string,
tableId: string,
version: number,
description: string | null,
generatedDescription: string | null,
) => apiFetch<CatalogTable>(
`/catalog/databases/${encodeURIComponent(databaseId)}/tables/${encodeURIComponent(tableId)}`,
{ method: "PATCH", body: JSON.stringify({ version, description, generatedDescription }) },
);
export const listCatalogColumns = (databaseId: string, tableId: string) =>
apiFetch<CatalogColumn[]>(`/catalog/databases/${encodeURIComponent(databaseId)}/tables/${encodeURIComponent(tableId)}/columns`);
export const updateCatalogColumnMetadata = (
databaseId: string,
tableId: string,
columnId: string,
version: number,
description: string | null,
generatedDescription: string | null,
) => apiFetch<CatalogColumn>(
`/catalog/databases/${encodeURIComponent(databaseId)}/tables/${encodeURIComponent(tableId)}/columns/${encodeURIComponent(columnId)}`,
{ method: "PATCH", body: JSON.stringify({ version, description, generatedDescription }) },
);
export const listCatalogRelationships = (databaseId: string) =>
apiFetch<CatalogRelationship[]>(`/catalog/databases/${encodeURIComponent(databaseId)}/relationships`);
export const startCatalogSync = (
databaseId: string,
version: number,
scope: CatalogSyncScope,
tableIds: string[] = [],
) => apiFetch<CatalogSyncRun>(`/catalog/databases/${encodeURIComponent(databaseId)}/sync-runs`, {
method: "POST",
body: JSON.stringify({ version, scope, tableIds }),
});
export const listCatalogSyncRuns = (databaseId: string) =>
apiFetch<CatalogSyncRun[]>(`/catalog/databases/${encodeURIComponent(databaseId)}/sync-runs`);
export const getCatalogSyncRun = (runId: string) =>
apiFetch<CatalogSyncRun>(`/catalog/sync-runs/${encodeURIComponent(runId)}`);
export const listCatalogSyncEvents = (runId: string, after = 0) =>
apiFetch<CatalogSyncEvent[]>(`/catalog/sync-runs/${encodeURIComponent(runId)}/events-list?after=${after}`);
export const confirmCatalogSync = (runId: string, confirmationToken: string) =>
apiFetch<CatalogSyncRun>(`/catalog/sync-runs/${encodeURIComponent(runId)}/confirm`, {
method: "POST", body: JSON.stringify({ confirmationToken }),
});
export const cancelCatalogSync = (runId: string) =>
apiFetch<CatalogSyncRun>(`/catalog/sync-runs/${encodeURIComponent(runId)}/cancel`, { method: "POST" });
export const retryCatalogSync = (runId: string) =>
apiFetch<CatalogSyncRun>(`/catalog/sync-runs/${encodeURIComponent(runId)}/retry`, { method: "POST" });
export function catalogSyncEventsUrl(runId: string, after = 0): string {
const url = joinBackendPath(BASE, `/catalog/sync-runs/${encodeURIComponent(runId)}/events?after=${after}`);
assertSameOriginRequestUrl(url);
return url;
}
+17
View File
@@ -14,6 +14,10 @@ const safeErrorCodes = new Set([
"git_unavailable", "git_auth_failed", "git_non_fast_forward", "connector_unavailable",
"semantic_index_incompatible", "pi_management_forbidden", "pi_management_unavailable",
"pi_management_invalid_config", "pi_management_write_failed",
"catalog_unavailable", "database_conflict", "database_invalid", "database_not_found",
"database_stale", "database_operation_failed",
"schema_sync_conflict", "schema_introspection_failed", "schema_request_invalid",
"schema_operation_failed", "sync_run_not_found", "table_stale", "column_stale",
]);
type SafeErrorPayload = {
@@ -46,6 +50,19 @@ const localCodeMessages: Record<string, string> = {
pi_management_unavailable: "Pi management is unavailable.",
pi_management_invalid_config: "The Pi configuration is invalid.",
pi_management_write_failed: "The Pi configuration could not be saved.",
catalog_unavailable: "The database catalog is unavailable.",
database_conflict: "This workspace already has a database configuration.",
database_invalid: "The database configuration is invalid.",
database_not_found: "The database configuration was not found.",
database_stale: "The database configuration changed. Reload and try again.",
database_operation_failed: "The database operation failed.",
schema_sync_conflict: "A schema synchronization is already active or no longer current.",
schema_introspection_failed: "The database schema could not be read safely.",
schema_request_invalid: "The schema request is invalid.",
schema_operation_failed: "The schema operation failed.",
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.",
};
const localStatusMessages: Record<number, string> = {