import type { WorkspaceRegistry } from "../workspaces/registry.js"; import type { WorkspaceDescriptor } from "../workspaces/schema.js"; import { discoverWorkspaceSecretRequirements } from "../workspaces/secret-requirements.js"; import type { WorkspaceSecretStore } from "../workspaces/secret-store.js"; import { createConcreteDiagnosticAdapters } from "../workspaces/diagnostics.js"; import { CatalogOperationCoordinator } from "./operation-coordinator.js"; import { ConcreteCatalogPostgresAccess, type CatalogPostgresAccess, } from "./postgres-access.js"; import type { CatalogRepository, DatabaseBinding, DatabaseConfigurationInput, DatabaseTestResult, WorkspaceDatabase, } from "./types.js"; import { CATALOG_SECRET_IDS, type CatalogSecretName } from "./secrets.js"; export { CATALOG_SECRET_IDS, type CatalogSecretName } from "./secrets.js"; export interface CatalogListItem extends Omit { id?: string; workspaceName: string; workspaceDescription?: string; workspaceAvailable: boolean; workspaceRevision: { commit: string; blob: string } | null; workspaceEvidence: { sourceType: "filesystem" | "http" | "s3" | null; state: | "not_declared" | "materialized_current_revision" | "configuration_required" | "configured_unverified" | "workspace_unavailable"; }; runtimeBinding: { transport: DatabaseBinding["transport"]; configurationState: "ready" | "configuration_required"; sessionTransportSupported: boolean; } | null; configured: boolean; secrets: Record; } function secretState(store: WorkspaceSecretStore, workspaceId: string): Record { return Object.fromEntries(Object.entries(CATALOG_SECRET_IDS).map(([name, id]) => ( [name, store.has(workspaceId, id)] ))) as Record; } function databaseRuntimeState( store: WorkspaceSecretStore, database: WorkspaceDatabase, ): NonNullable { const { binding, workspaceId } = database; const configured = (id: string) => store.has(workspaceId, id); const connectionComplete = binding.transport === "postgres_direct" ? Boolean(binding.host && binding.port && binding.username && configured(CATALOG_SECRET_IDS.password)) : binding.transport === "rest_api" ? Boolean(binding.baseUrl && (binding.restAuth === "none" || configured(CATALOG_SECRET_IDS.apiKey))) : Boolean( binding.username && binding.sshHost && binding.sshPort && binding.sshUsername && binding.sshTargetHost && binding.sshTargetPort && configured(CATALOG_SECRET_IDS.password) && configured(CATALOG_SECRET_IDS.sshPrivateKey) && configured(CATALOG_SECRET_IDS.sshKnownHosts), ); return { transport: binding.transport, configurationState: connectionComplete ? "ready" : "configuration_required", sessionTransportSupported: binding.transport !== "ssh_tunnel", }; } function workspaceEvidenceState( store: WorkspaceSecretStore, workspace: WorkspaceDescriptor, ): CatalogListItem["workspaceEvidence"] { const source = workspace.evidence?.source; if (!source) return { sourceType: null, state: "not_declared" }; if (source.type === "filesystem") { return { sourceType: source.type, state: "materialized_current_revision" }; } const configurationRequired = discoverWorkspaceSecretRequirements(workspace, process.env) .some(({ connector, id, required }) => ( connector === "evidence" && required && !store.has(workspace.workspace.id, id) )); return { sourceType: source.type, state: configurationRequired ? "configuration_required" : "configured_unverified", }; } export class CatalogService { private readonly adapters = createConcreteDiagnosticAdapters(); constructor( private readonly repository: CatalogRepository, private readonly registry: WorkspaceRegistry, private readonly secretStore: WorkspaceSecretStore, private readonly secretRoots: readonly string[], private readonly diagnosticTimeoutMs: number, private readonly postgres: CatalogPostgresAccess = new ConcreteCatalogPostgresAccess(secretStore, { connectTimeoutMs: diagnosticTimeoutMs, }), private readonly operations: CatalogOperationCoordinator = new CatalogOperationCoordinator(), ) {} async list(): Promise { const [workspaces, configured] = await Promise.all([ this.registry.listCatalog(), this.repository.list(), ]); const byWorkspace = new Map(configured.map((database) => [database.workspaceId, database])); const active = await Promise.all(workspaces.map(async (entry) => { const database = byWorkspace.get(entry.id); const { workspace } = await this.registry.readPinned(entry.id, entry.revision.commit); const base = database ?? { workspaceId: entry.id, engine: "postgres" as const, databaseName: "", schema: "", version: 0, createdAt: "", updatedAt: "", binding: { transport: "postgres_direct" as const }, connectionStatus: "untested" as const, metadataContentRevision: 0, preprocessingStatus: "failed" as const, }; return { ...base, workspaceName: entry.name, workspaceDescription: entry.description, workspaceAvailable: true, workspaceRevision: { commit: entry.revision.commit, blob: entry.revision.blob, }, workspaceEvidence: workspaceEvidenceState(this.secretStore, workspace), runtimeBinding: database ? databaseRuntimeState(this.secretStore, database) : null, configured: database !== undefined, secrets: secretState(this.secretStore, entry.id), }; })); const known = new Set(workspaces.map((entry) => entry.id)); const orphaned: CatalogListItem[] = configured .filter((database) => !known.has(database.workspaceId)) .map((database) => ({ ...database, workspaceName: database.workspaceId, workspaceDescription: "Workspace is no longer present in the repository catalog.", workspaceAvailable: false, workspaceRevision: null, workspaceEvidence: { sourceType: null, state: "workspace_unavailable" }, runtimeBinding: null, configured: true, secrets: secretState(this.secretStore, database.workspaceId), })); return [...active, ...orphaned]; } async ensureWorkspace(workspaceId: string): Promise { const { workspace } = await this.registry.read(workspaceId); return workspace; } async normalizeInput(input: DatabaseConfigurationInput): Promise { await this.ensureWorkspace(input.workspaceId); if (input.binding.transport !== "rest_api") return input; return { ...input, binding: { ...input.binding, restPath: input.binding.restPath ?? "/health", }, }; } configuredSecrets(workspaceId: string): Record { return secretState(this.secretStore, workspaceId); } replaceSecrets(workspaceId: string, values: Partial>): void { const encoded: Record = {}; for (const [name, value] of Object.entries(values) as Array<[CatalogSecretName, string | undefined]>) { if (value !== undefined && value.length > 0) encoded[CATALOG_SECRET_IDS[name]] = value; } if (Object.keys(encoded).length > 0) this.secretStore.putMany(workspaceId, encoded); } forgetSecrets(workspaceId: string): void { for (const id of Object.values(CATALOG_SECRET_IDS)) this.secretStore.forget(workspaceId, id); } async test(database: WorkspaceDatabase): Promise { return await this.operations.run(database.id, async () => { const testedAt = new Date().toISOString(); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.diagnosticTimeoutMs); const required = database.binding.transport === "rest_api" ? database.binding.restAuth === "none" ? [] : [CATALOG_SECRET_IDS.apiKey] : []; const materialized = this.secretStore.materialize(database.workspaceId, required); let result: DatabaseTestResult; try { if (database.binding.transport !== "rest_api") { const client = await this.postgres.connect(database, controller.signal); try { const result = await client.query( `SELECT current_database() AS database, CASE WHEN pg_catalog.has_schema_privilege( current_user, (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = $1), 'USAGE' ) THEN $1 ELSE NULL END AS schema`, [database.schema], ); const row = result.rows[0]; if (row?.database !== database.databaseName || row.schema !== database.schema) { throw new Error("Database identity mismatch"); } } finally { await client.end(); } } else { const credentialId = CATALOG_SECRET_IDS.apiKey; await this.adapters.probeConnector({ role: "dwh", transport: database.binding.transport, baseUrl: database.binding.baseUrl, credentialFile: materialized.files.get(credentialId), resource: { database: database.databaseName, schema: database.schema }, timeoutMs: this.diagnosticTimeoutMs, signal: controller.signal, diagnostic: { method: "GET" as const, path: database.binding.restPath ?? "/health", auth: database.binding.restAuth ?? "bearer", }, }); } result = { connectionStatus: "reachable", testedVersion: database.version, lastTestedAt: testedAt, }; } catch { result = { connectionStatus: "failed", testedVersion: database.version, lastTestedAt: testedAt, errorCode: "connector_unavailable", errorMessage: "The database connector could not be reached or authenticated.", }; } finally { clearTimeout(timer); controller.abort(); materialized.release(); } return await this.repository.recordTest(database.id, database.version, result); }); } }