203 lines
11 KiB
TypeScript
203 lines
11 KiB
TypeScript
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<CatalogDatabase>({
|
|
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);
|
|
|
|
test.skipIf(!dockerAvailable)("PostgreSQL repository performs scoped metadata cleanup with catalog cascades", async () => {
|
|
const container = await new PostgreSqlContainer("postgres:17.6-bookworm").start();
|
|
const db = new Kysely<CatalogDatabase>({
|
|
dialect: new PostgresDialect({ pool: new Pool({ connectionString: container.getConnectionUri() }) }),
|
|
plugins: [new CamelCasePlugin()],
|
|
});
|
|
try {
|
|
await upDatabases(db);
|
|
await upTables(db);
|
|
await upSchemaSync(db);
|
|
const repository = new KyselyCatalogRepository(db);
|
|
const database = await repository.create({
|
|
workspaceId: "cleanup-test",
|
|
engine: "postgres",
|
|
databaseName: "warehouse",
|
|
schema: "public",
|
|
binding: { transport: "postgres_direct", host: "db.internal", port: 5432, username: "reader" },
|
|
});
|
|
const snapshot: ObservedSchemaSnapshot = {
|
|
schemaVersion: 1,
|
|
capabilities: { tables: "available", columns: "available", relationships: "available" },
|
|
tables: [
|
|
{ name: "patients", sourceComment: null },
|
|
{ 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: [{
|
|
constraintName: "visits_patient_id_fkey",
|
|
sourceTableName: "visits",
|
|
targetTableName: "patients",
|
|
updateRule: "NO ACTION",
|
|
deleteRule: "CASCADE",
|
|
deferrable: false,
|
|
initiallyDeferred: false,
|
|
columns: [{ position: 1, sourceColumnName: "patient_id", targetColumnName: "id" }],
|
|
}],
|
|
};
|
|
await repository.applySchemaSync(database.id, database.version, "all", [], snapshot);
|
|
const patients = (await repository.listTables(database.id)).find((table) => table.name === "patients")!;
|
|
|
|
expect(await repository.deleteTableMetadata(database.id, [patients.id], "relationships"))
|
|
.toEqual({ tables: 0, columns: 0, relationships: 1 });
|
|
expect(await repository.listRelationships(database.id)).toEqual([]);
|
|
expect(await repository.listColumns(database.id, patients.id)).toHaveLength(2);
|
|
expect((await repository.get(database.id))?.schemaSyncedVersion).toBeUndefined();
|
|
|
|
await repository.applySchemaSync(database.id, database.version, "relationships", [], snapshot);
|
|
expect(await repository.deleteTableMetadata(database.id, [patients.id], "columns"))
|
|
.toEqual({ tables: 0, columns: 2, relationships: 0 });
|
|
expect(await repository.listColumns(database.id, patients.id)).toEqual([]);
|
|
expect(await repository.listRelationships(database.id)).toMatchObject([
|
|
{ constraintName: "visits_patient_id_fkey", columns: [] },
|
|
]);
|
|
|
|
await repository.applySchemaSync(database.id, database.version, "columns", [patients.id], snapshot);
|
|
await repository.applySchemaSync(database.id, database.version, "relationships", [], snapshot);
|
|
expect(await repository.deleteDatabaseMetadata([
|
|
database.id,
|
|
"99999999-9999-4999-8999-999999999999",
|
|
], "tables")).toBeUndefined();
|
|
expect(await repository.listTables(database.id)).toHaveLength(2);
|
|
expect(await repository.deleteDatabaseMetadata([database.id], "tables"))
|
|
.toEqual({ tables: 2, columns: 4, relationships: 1 });
|
|
expect(await repository.get(database.id)).toBeDefined();
|
|
expect(await repository.listTables(database.id)).toEqual([]);
|
|
expect(await repository.listRelationships(database.id)).toEqual([]);
|
|
} finally {
|
|
await db.destroy();
|
|
await container.stop();
|
|
}
|
|
}, 60_000);
|