Files
ThothII/backend/src/catalog/service.ts
T
Codex cffa60772e
Publish documentation / publish (push) Successful in 2m12s
feat: complete catalog-driven preprocessing
2026-09-06 17:49:35 +02:00

266 lines
10 KiB
TypeScript

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<WorkspaceDatabase, "id"> {
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<CatalogSecretName, boolean>;
}
function secretState(store: WorkspaceSecretStore, workspaceId: string): Record<CatalogSecretName, boolean> {
return Object.fromEntries(Object.entries(CATALOG_SECRET_IDS).map(([name, id]) => (
[name, store.has(workspaceId, id)]
))) as Record<CatalogSecretName, boolean>;
}
function databaseRuntimeState(
store: WorkspaceSecretStore,
database: WorkspaceDatabase,
): NonNullable<CatalogListItem["runtimeBinding"]> {
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<CatalogListItem[]> {
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<WorkspaceDescriptor> {
const { workspace } = await this.registry.read(workspaceId);
return workspace;
}
async normalizeInput(input: DatabaseConfigurationInput): Promise<DatabaseConfigurationInput> {
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<CatalogSecretName, boolean> {
return secretState(this.secretStore, workspaceId);
}
replaceSecrets(workspaceId: string, values: Partial<Record<CatalogSecretName, string>>): void {
const encoded: Record<string, string> = {};
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<WorkspaceDatabase | undefined> {
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);
});
}
}