feat: implement metadata catalog database management
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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> = {
|
||||
|
||||
Reference in New Issue
Block a user