import { spawnSync } from "node:child_process"; import { PostgreSqlContainer } from "@testcontainers/postgresql"; import { CamelCasePlugin, Kysely, PostgresDialect, sql } from "kysely"; import { Pool } from "pg"; import { expect, test } from "vitest"; import { KyselyCatalogRepository, type CatalogDatabase } from "../src/catalog/repository.js"; import type { ObservedSchemaSnapshot } from "../src/catalog/types.js"; import { up as upDatabases } from "../src/catalog/migrations/001_workspace_databases.js"; import { up as upTables } from "../src/catalog/migrations/002_catalog_tables.js"; import { up as upSchemaSync } from "../src/catalog/migrations/003_catalog_schema_sync.js"; import { up as upRuntimeSequencePrivileges } from "../src/catalog/migrations/004_catalog_runtime_sequence_privileges.js"; const dockerAvailable = spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; test.skipIf(!dockerAvailable)("PostgreSQL migration enforces one database per workspace and optimistic updates", async () => { const container = await new PostgreSqlContainer("postgres:17.6-bookworm").start(); const db = new Kysely({ dialect: new PostgresDialect({ pool: new Pool({ connectionString: container.getConnectionUri() }) }), plugins: [new CamelCasePlugin()], }); try { await upDatabases(db); await upTables(db); await upSchemaSync(db); await sql`CREATE ROLE thothii_catalog_runtime`.execute(db); await upRuntimeSequencePrivileges(db); const sequencePrivilege = await sql<{ allowed: boolean }>` SELECT has_sequence_privilege( 'thothii_catalog_runtime', 'catalog_sync_events_id_seq', 'USAGE' ) AS allowed `.execute(db); expect(sequencePrivilege.rows[0]?.allowed).toBe(true); const repository = new KyselyCatalogRepository(db); const input = { workspaceId: "psd-clinical", engine: "postgres" as const, databaseName: "warehouse", schema: "datawarehouse", binding: { transport: "rest_api" as const, baseUrl: "https://psd.example/api", restPath: "/health", restAuth: "bearer" as const }, }; const created = await repository.create(input); expect(created).toMatchObject({ version: 1, connectionStatus: "untested", binding: { transport: "rest_api" } }); await expect(repository.create(input)).rejects.toThrow("Workspace database already exists"); expect(await repository.update(created.id, 99, input)).toBeUndefined(); const synchronized = await repository.reconcileTables(created.id, 1, [ { name: "patients", sourceComment: "Clinical patients" }, { name: "visits", sourceComment: null }, ], []); expect(synchronized).toMatchObject({ kind: "applied", createdCount: 2, deletedCount: 0 }); const patients = (await repository.listTables(created.id))[0]; expect(await repository.updateTableDescription( created.id, patients.id, patients.version, "Curated patients", )).toMatchObject({ description: "Curated patients", sourceComment: "Clinical patients", version: 2 }); const visits = (await repository.listTables(created.id)).find((table) => table.name === "visits")!; const fullColumnsSnapshot: ObservedSchemaSnapshot = { schemaVersion: 1, capabilities: { tables: "available", columns: "available", relationships: "available" }, tables: [ { name: "patients", sourceComment: "Clinical patients" }, { name: "visits", sourceComment: null }, ], columns: [ { tableName: "patients", name: "id", ordinalPosition: 1, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: 1, sourceComment: null }, { tableName: "patients", name: "name", ordinalPosition: 2, dataType: "text", isNullable: true, defaultExpression: null, primaryKeyPosition: null, sourceComment: null }, { tableName: "visits", name: "id", ordinalPosition: 1, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: 1, sourceComment: null }, { tableName: "visits", name: "patient_id", ordinalPosition: 2, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: null, sourceComment: null }, ], relationships: [], }; expect(await repository.applySchemaSync(created.id, 1, "columns", [], fullColumnsSnapshot)) .toMatchObject({ created: 4, deleted: 0 }); expect((await repository.listColumns(created.id, patients.id)).map((column) => column.name)) .toEqual(["id", "name"]); expect((await repository.listColumns(created.id, visits.id)).map((column) => column.name)) .toEqual(["id", "patient_id"]); const reducedColumnsSnapshot: ObservedSchemaSnapshot = { ...fullColumnsSnapshot, columns: fullColumnsSnapshot.columns.filter((column) => column.name === "id"), }; expect(await repository.planSchemaSync(created.id, "columns", [], reducedColumnsSnapshot)).toMatchObject({ deletedColumns: [ { tableName: "patients", columnName: "name" }, { tableName: "visits", columnName: "patient_id" }, ], }); expect(await repository.planSchemaSync(created.id, "columns", [patients.id], reducedColumnsSnapshot)).toMatchObject({ deletedColumns: [{ tableName: "patients", columnName: "name" }], }); expect(await repository.applySchemaSync(created.id, 1, "columns", [patients.id], reducedColumnsSnapshot)) .toMatchObject({ deleted: 1 }); expect((await repository.listColumns(created.id, patients.id)).map((column) => column.name)) .toEqual(["id"]); expect((await repository.listColumns(created.id, visits.id)).map((column) => column.name)) .toEqual(["id", "patient_id"]); expect(await repository.reconcileTables(created.id, 1, [ { name: "patients", sourceComment: "Updated physical comment" }, ], [])).toEqual({ kind: "confirmation_required", deletedNames: ["visits"] }); expect(await repository.reconcileTables(created.id, 1, [ { name: "patients", sourceComment: "Updated physical comment" }, ], ["visits"])).toMatchObject({ kind: "applied", updatedCount: 1, deletedCount: 1 }); const syncRun = await repository.createSyncRun(created.id, "columns", [patients.id], 1); expect(syncRun).toMatchObject({ databaseId: created.id, scope: "columns", tableIds: [patients.id], state: "queued", }); expect(await repository.listSyncRuns(created.id)).toEqual([ expect.objectContaining({ id: syncRun.id, tableIds: [patients.id] }), ]); expect(await repository.update(created.id, 1, { ...input, schema: "public" })).toMatchObject({ version: 2, schema: "public" }); expect(await repository.delete(created.id, 2)).toBe(true); expect(await repository.list()).toEqual([]); expect(await repository.listTables(created.id)).toEqual([]); } finally { await db.destroy(); await container.stop(); } }, 60_000);