feat: consolidate database management work

Add catalog-owned logical relationships and runtime snapshots, extend the database-management UI and validation coverage, and document the updated operational workflow.

Keep active sensitive-generation status in a tooltip and indicator, and update the layout E2E to follow the history action in its new database-scoped location.
This commit is contained in:
Codex
2026-09-01 14:46:55 +02:00
parent f586152636
commit 076c9742c5
73 changed files with 6966 additions and 610 deletions
+63 -4
View File
@@ -13,7 +13,10 @@ import type { WorkspaceRegistry, WorkspaceRevision } from "../src/workspaces/reg
import type { WorkspaceDescriptor } from "../src/workspaces/schema.js";
const roots: string[] = [];
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
afterEach(() => {
vi.unstubAllEnvs();
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
const workspace: WorkspaceDescriptor = {
workspace: { schema_version: 3, id: "psd-clinical", name: "Policlinico San Donato", language: "it" },
@@ -36,6 +39,7 @@ function setup(
catalogOperationCoordinator?: CatalogOperationCoordinator;
catalogPostgresAccess?: CatalogPostgresAccess;
} = {},
workspaceDescriptor: WorkspaceDescriptor = workspace,
) {
const secretRoot = mkdtempSync(join(tmpdir(), "catalog-secret-"));
const runtimeRoot = mkdtempSync(join(tmpdir(), "catalog-secret-runtime-"));
@@ -45,7 +49,8 @@ function setup(
const registry = {
list: vi.fn(async () => [revision]),
listCatalog: vi.fn(async () => [{ id: "psd-clinical", name: "Policlinico San Donato", configurationState: "ready", revision }]),
read: vi.fn(async () => ({ workspace, revision })),
read: vi.fn(async () => ({ workspace: workspaceDescriptor, revision })),
readPinned: vi.fn(async () => ({ workspace: workspaceDescriptor, workspaceConfigPath: revision.snapshotPath })),
} as unknown as WorkspaceRegistry;
const app = buildApp(loadConfig({
THT_HARNESS_DIR: "/missing",
@@ -96,10 +101,31 @@ const fleetSnapshot: ObservedSchemaSnapshot = {
};
test("lists every YAML workspace and creates its one database configuration", async () => {
const { app } = setup();
const { app, secretStore } = setup();
const initial = await app.inject({ method: "GET", url: "/catalog/databases" });
expect(initial.statusCode).toBe(200);
expect(initial.json()).toMatchObject([{ workspaceId: "psd-clinical", configured: false, databaseName: "warehouse" }]);
expect(initial.json()).toMatchObject([{
workspaceId: "psd-clinical",
configured: false,
databaseName: "warehouse",
workspaceRevision: { commit: revision.commit, blob: revision.blob },
workspaceEvidence: { sourceType: null, state: "not_declared" },
runtimeBinding: {
transport: "postgres_direct",
configurationState: "configuration_required",
sessionTransportSupported: true,
},
}]);
vi.stubEnv("THT_WS_PSD_CLINICAL_DWH_HOST", "runtime-db.internal");
vi.stubEnv("THT_WS_PSD_CLINICAL_DWH_PORT", "5432");
vi.stubEnv("THT_WS_PSD_CLINICAL_DWH_USER", "runtime-reader");
vi.stubEnv("THT_WS_PSD_CLINICAL_DWH_TRANSPORT", "postgres_direct");
secretStore.putMany("psd-clinical", { "dwh.password": "runtime-password" });
const runtimeReady = await app.inject({ method: "GET", url: "/catalog/databases" });
expect(runtimeReady.json()).toMatchObject([{
runtimeBinding: { configurationState: "ready", sessionTransportSupported: true },
}]);
const created = await app.inject({ method: "POST", url: "/catalog/databases", payload: direct });
expect(created.statusCode).toBe(201);
@@ -110,6 +136,39 @@ test("lists every YAML workspace and creates its one database configuration", as
expect(listed.json()).toMatchObject([{ configured: true, binding: { transport: "postgres_direct", host: "db.internal" } }]);
});
test("projects remote Evidence credential state without conflating catalog secrets", async () => {
const evidenceWorkspace: WorkspaceDescriptor = {
...workspace,
evidence: {
schema_version: 2,
source: {
type: "http",
uris: ["https://evidence.example.test/guide.md"],
authentication: "signed_urls_file",
connect_timeout_ms: 5_000,
read_timeout_ms: 30_000,
max_bytes: 10 * 1024 * 1024,
max_redirects: 5,
allow_private_hosts: false,
max_cache_bytes: 64 * 1024 * 1024,
},
policy: { max_chunk_chars: 4_000, retain_published_generations: 3 },
},
};
const { app, secretStore } = setup({}, {}, evidenceWorkspace);
const missing = await app.inject({ method: "GET", url: "/catalog/databases" });
expect(missing.json()).toMatchObject([{
workspaceEvidence: { sourceType: "http", state: "configuration_required" },
}]);
secretStore.putMany("psd-clinical", { "evidence.signed_urls": "https://signed.example.test/evidence" });
const configured = await app.inject({ method: "GET", url: "/catalog/databases" });
expect(configured.json()).toMatchObject([{
workspaceEvidence: { sourceType: "http", state: "configured_unverified" },
}]);
});
test("lists orphaned records and takes the REST diagnostic path from workspace YAML", async () => {
const { app, repository } = setup();
await repository.create({
@@ -0,0 +1,207 @@
import { expect, test } from "vitest";
import {
CatalogLogicalRelationshipService,
LogicalRelationshipDuplicateError,
LogicalRelationshipSchemaStaleError,
LogicalRelationshipTargetNotUniqueError,
LogicalRelationshipTypeIncompatibleError,
} from "../src/catalog/logical-relationship-service.js";
import { MemoryCatalogRepository } from "../src/catalog/memory-repository.js";
import type { ObservedSchemaSnapshot } from "../src/catalog/types.js";
const column = (
tableName: string,
name: string,
ordinalPosition: number,
dataType: string,
primaryKeyPosition: number | null,
) => ({
tableName, name, ordinalPosition, dataType, primaryKeyPosition,
isNullable: false, defaultExpression: null, sourceComment: null,
});
function schema(
tableNames: string[],
columns: ObservedSchemaSnapshot["columns"],
relationships: ObservedSchemaSnapshot["relationships"] = [],
): ObservedSchemaSnapshot {
return {
schemaVersion: 1,
capabilities: { tables: "available", columns: "available", relationships: "available" },
tables: tableNames.map((name) => ({ name, sourceComment: null })),
columns,
relationships,
};
}
async function setup(snapshot: ObservedSchemaSnapshot) {
const repository = new MemoryCatalogRepository();
const database = await repository.create({
workspaceId: "relationships", engine: "postgres", databaseName: "warehouse", schema: "public",
binding: { transport: "postgres_direct", host: "db.internal", port: 5432, username: "reader" },
});
await repository.applySchemaSync(database.id, database.version, "all", [], snapshot);
const service = new CatalogLogicalRelationshipService(repository);
const context = (await repository.getLogicalRelationshipContext(database.id))!;
const endpoint = (tableName: string, columnName: string) => context.endpoints.find((item) => (
item.tableName === tableName && item.columnName === columnName
))!;
return { repository, database, service, endpoint };
}
test("keeps excluded relationships across rebuild and recreates hard-deleted relationships", async () => {
const { database, service, endpoint } = await setup(schema(
["users", "orders"],
[column("users", "id", 1, "bigint", 1), column("orders", "id", 1, "bigint", 1),
column("orders", "user_id", 2, "bigint", null)],
));
const source = endpoint("orders", "user_id");
const target = endpoint("users", "id");
const manual = await service.addManual(database.id, source.columnId, target.columnId);
expect(manual).toMatchObject({ origin: "manual", status: "active" });
expect(await service.setStatus(database.id, manual.id, "excluded"))
.toMatchObject({ status: "excluded" });
await expect(service.rebuildGenerated(database.id)).resolves.toEqual({
added: 0, alreadyPresent: 0, excluded: 1, ambiguous: 0,
});
expect((await service.list(database.id)).filter((item) => item.origin !== "physical"))
.toMatchObject([{ id: manual.id, origin: "manual", status: "excluded" }]);
await service.deletePermanently(database.id, manual.id);
await expect(service.rebuildGenerated(database.id)).resolves.toEqual({
added: 1, alreadyPresent: 0, excluded: 0, ambiguous: 0,
});
expect((await service.list(database.id)).filter((item) => item.origin !== "physical"))
.toMatchObject([{ origin: "generated", status: "active" }]);
});
test("normalizes snake, kebab, and camel names without fuzzy matching", async () => {
const { database, service } = await setup(schema(
["users", "events"],
[column("users", "id", 1, "bigint", 1), column("events", "id", 1, "bigint", 1),
column("events", "user_id", 2, "bigint", null),
column("events", "user-id", 3, "bigint", null),
column("events", "userId", 4, "bigint", null),
column("events", "userid", 5, "bigint", null),
column("events", "unrelated", 6, "bigint", null)],
));
await expect(service.rebuildGenerated(database.id)).resolves.toEqual({
added: 4, alreadyPresent: 0, excluded: 0, ambiguous: 0,
});
});
test("skips generic bare primary-key names while retaining table-qualified matches", async () => {
const { database, service } = await setup(schema(
["users", "accounts", "events"],
[column("users", "id", 1, "bigint", 1), column("accounts", "id", 1, "bigint", 1),
column("events", "event_key", 1, "bigint", 1), column("events", "id", 2, "bigint", null),
column("events", "user_id", 3, "bigint", null)],
));
await expect(service.rebuildGenerated(database.id)).resolves.toEqual({
added: 1, alreadyPresent: 0, excluded: 0, ambiguous: 0,
});
});
test("infers foreign keys from composite-primary-key sources", async () => {
const { database, service } = await setup(schema(
["users", "groups", "memberships"],
[column("users", "id", 1, "bigint", 1), column("groups", "id", 1, "bigint", 1),
column("memberships", "user_id", 1, "bigint", 1),
column("memberships", "group_id", 2, "bigint", 2)],
));
await expect(service.rebuildGenerated(database.id)).resolves.toEqual({
added: 2, alreadyPresent: 0, excluded: 0, ambiguous: 0,
});
});
test("maps time-key columns to the single primary key of dim_time", async () => {
const { database, service } = await setup(schema(
["dim_time", "admissions"],
[column("dim_time", "day_key", 1, "integer", 1),
column("admissions", "id", 1, "bigint", 1),
column("admissions", "admission_time_key", 2, "integer", null),
column("admissions", "discharge_time_key", 3, "integer", null)],
));
await expect(service.rebuildGenerated(database.id)).resolves.toEqual({
added: 2, alreadyPresent: 0, excluded: 0, ambiguous: 0,
});
});
test("skips ambiguous targets and physical foreign-key pairs", async () => {
const ambiguous = await setup(schema(
["user", "users", "events"],
[column("user", "id", 1, "bigint", 1), column("users", "id", 1, "bigint", 1),
column("events", "id", 1, "bigint", 1), column("events", "user_id", 2, "bigint", null)],
));
await expect(ambiguous.service.rebuildGenerated(ambiguous.database.id)).resolves.toEqual({
added: 0, alreadyPresent: 0, excluded: 0, ambiguous: 1,
});
const physical = await setup(schema(
["users", "orders"],
[column("users", "id", 1, "bigint", 1), column("orders", "id", 1, "bigint", 1),
column("orders", "user_id", 2, "bigint", null)],
[{
constraintName: "orders_user_id_fkey", sourceTableName: "orders", targetTableName: "users",
updateRule: "NO ACTION", deleteRule: "NO ACTION", deferrable: false, initiallyDeferred: false,
columns: [{ position: 1, sourceColumnName: "user_id", targetColumnName: "id" }],
}],
));
await expect(physical.service.rebuildGenerated(physical.database.id)).resolves.toEqual({
added: 0, alreadyPresent: 1, excluded: 0, ambiguous: 0,
});
await expect(physical.service.addManual(
physical.database.id,
physical.endpoint("orders", "user_id").columnId,
physical.endpoint("users", "id").columnId,
)).rejects.toBeInstanceOf(LogicalRelationshipDuplicateError);
});
test("validates target uniqueness and canonical type compatibility for manual relationships", async () => {
const { database, service, endpoint } = await setup(schema(
["users", "composite", "events"],
[column("users", "id", 1, "bigint", 1),
column("composite", "left_id", 1, "bigint", 1), column("composite", "right_id", 2, "bigint", 2),
column("events", "id", 1, "bigint", 1), column("events", "user_id", 2, "integer", null),
column("events", "composite_id", 3, "bigint", null)],
));
await expect(service.addManual(
database.id, endpoint("events", "composite_id").columnId, endpoint("composite", "left_id").columnId,
)).rejects.toBeInstanceOf(LogicalRelationshipTargetNotUniqueError);
await expect(service.addManual(
database.id, endpoint("events", "user_id").columnId, endpoint("users", "id").columnId,
)).rejects.toBeInstanceOf(LogicalRelationshipTypeIncompatibleError);
});
test("rebuild is additive when an existing generated relationship stops matching", async () => {
const initial = schema(
["users", "orders"],
[column("users", "id", 1, "bigint", 1), column("orders", "id", 1, "bigint", 1),
column("orders", "user_id", 2, "bigint", null)],
);
const { repository, database, service } = await setup(initial);
await service.rebuildGenerated(database.id);
const changed = structuredClone(initial);
changed.columns.find((item) => item.tableName === "orders" && item.name === "user_id")!.dataType = "text";
await repository.applySchemaSync(database.id, database.version, "columns", [], changed);
await expect(service.rebuildGenerated(database.id)).resolves.toEqual({
added: 0, alreadyPresent: 0, excluded: 0, ambiguous: 0,
});
expect((await service.list(database.id)).filter((item) => item.origin === "generated")).toHaveLength(1);
});
test("refuses inference until the current database version has a full schema sync", async () => {
const repository = new MemoryCatalogRepository();
const database = await repository.create({
workspaceId: "unsynced-relationships",
engine: "postgres",
databaseName: "warehouse",
schema: "public",
binding: { transport: "postgres_direct", host: "db.internal", port: 5432, username: "reader" },
});
const service = new CatalogLogicalRelationshipService(repository);
await expect(service.rebuildGenerated(database.id))
.rejects.toBeInstanceOf(LogicalRelationshipSchemaStaleError);
});
@@ -12,6 +12,7 @@ import { up as upRuntimeSequencePrivileges } from "../src/catalog/migrations/004
import { up as upDescriptionGeneration } from "../src/catalog/migrations/005_description_generation_runs.js";
import { up as upSensitiveDataFlag } from "../src/catalog/migrations/006_sensitive_data_flag.js";
import { up as upSensitiveSuggestionRuns } from "../src/catalog/migrations/007_sensitive_data_suggestion_runs.js";
import { up as upLogicalRelationships } from "../src/catalog/migrations/008_catalog_logical_relationships.js";
const dockerAvailable = spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0;
@@ -26,6 +27,7 @@ test.skipIf(!dockerAvailable)("PostgreSQL migration enforces one database per wo
await upTables(db);
await upSchemaSync(db);
await upSensitiveDataFlag(db);
await upLogicalRelationships(db);
await sql`CREATE ROLE thothii_catalog_runtime`.execute(db);
await upRuntimeSequencePrivileges(db);
const sequencePrivilege = await sql<{ allowed: boolean }>`
@@ -202,6 +204,7 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository performs scoped metadata cl
await upTables(db);
await upSchemaSync(db);
await upSensitiveDataFlag(db);
await upLogicalRelationships(db);
const repository = new KyselyCatalogRepository(db);
const database = await repository.create({
workspaceId: "cleanup-test",
@@ -280,6 +283,7 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository atomically consolidates sel
await upTables(db);
await upSchemaSync(db);
await upSensitiveDataFlag(db);
await upLogicalRelationships(db);
const repository = new KyselyCatalogRepository(db);
const database = await repository.create({
workspaceId: "consolidation-test",
@@ -379,6 +383,7 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository persists description and se
await upTables(db);
await upSchemaSync(db);
await upSensitiveDataFlag(db);
await upLogicalRelationships(db);
await upDescriptionGeneration(db);
await upSensitiveSuggestionRuns(db);
const repository = new KyselyCatalogRepository(db);
@@ -610,3 +615,63 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository persists description and se
await container.stop();
}
}, 60_000);
test.skipIf(!dockerAvailable)("PostgreSQL repository persists logical relationship lifecycle and tombstones", 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 upSensitiveDataFlag(db);
await upLogicalRelationships(db);
const repository = new KyselyCatalogRepository(db);
const database = await repository.create({
workspaceId: "logical-relationships",
engine: "postgres",
databaseName: "warehouse",
schema: "public",
binding: { transport: "postgres_direct", host: "db.internal", port: 5432, username: "reader" },
});
await repository.applySchemaSync(database.id, database.version, "all", [], {
schemaVersion: 1,
capabilities: { tables: "available", columns: "available", relationships: "available" },
tables: [{ name: "users", sourceComment: null }, { name: "orders", sourceComment: null }],
columns: [
{ tableName: "users", name: "id", ordinalPosition: 1, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: 1, sourceComment: null },
{ tableName: "orders", name: "id", ordinalPosition: 1, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: 1, sourceComment: null },
{ tableName: "orders", name: "user_id", ordinalPosition: 2, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: null, sourceComment: null },
],
relationships: [],
});
const context = (await repository.getLogicalRelationshipContext(database.id))!;
const source = context.endpoints.find((item) => item.tableName === "orders" && item.columnName === "user_id")!;
const target = context.endpoints.find((item) => item.tableName === "users" && item.columnName === "id")!;
const created = await repository.insertLogicalRelationship(database.id, source.columnId, target.columnId, false);
expect(created).toMatchObject({ origin: "manual", status: "active" });
await expect(repository.insertLogicalRelationship(database.id, source.columnId, target.columnId, true))
.resolves.toBeUndefined();
expect(await repository.setLogicalRelationshipStatus(database.id, created!.id, "excluded"))
.toMatchObject({ status: "excluded" });
await expect(repository.insertGeneratedLogicalRelationships(database.id, [{
sourceColumnId: source.columnId, targetColumnId: target.columnId,
}])).resolves.toBe(0);
await expect(repository.deleteLogicalRelationship(database.id, created!.id)).resolves.toBe(true);
await expect(repository.insertGeneratedLogicalRelationships(database.id, [{
sourceColumnId: source.columnId, targetColumnId: target.columnId,
}])).resolves.toBe(1);
expect(await repository.listLogicalRelationships(database.id))
.toMatchObject([{ origin: "generated", status: "active" }]);
await db.deleteFrom("catalogColumns").where("id", "=", source.columnId).execute();
expect(await repository.listLogicalRelationships(database.id)).toEqual([]);
} finally {
await db.destroy();
await container.stop();
}
}, 60_000);
+123
View File
@@ -122,6 +122,7 @@ async function setup(env: Record<string, string> = {}) {
list: vi.fn(async () => [revision]),
listCatalog: vi.fn(async () => [{ id: "psd-clinical", name: "Policlinico San Donato", configurationState: "ready", revision }]),
read: vi.fn(async () => ({ workspace, revision })),
readPinned: vi.fn(async () => ({ workspace, workspaceConfigPath: revision.snapshotPath })),
} as unknown as WorkspaceRegistry;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/missing", NODE_ENV: "test", ...env }), {
thtRunner: {} as never,
@@ -605,3 +606,125 @@ test("rejects a missing database without partially cleaning valid selections", a
expect(response.statusCode).toBe(404);
expect(await repository.listTables(database.id)).toHaveLength(2);
});
test("serves one relationship map and supports the manual relationship lifecycle", async () => {
const { app, repository, database } = await setup();
await seedCatalog(repository, database);
const tables = await repository.listTables(database.id);
const patients = tables.find((table) => table.name === "patients")!;
const visits = tables.find((table) => table.name === "visits")!;
const patientId = (await repository.listColumns(database.id, patients.id)).find((item) => item.name === "id")!;
const visitId = (await repository.listColumns(database.id, visits.id)).find((item) => item.name === "id")!;
const created = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/relationships`,
payload: { sourceColumnId: visitId.id, targetColumnId: patientId.id },
});
expect(created.statusCode).toBe(201);
expect(created.json()).toMatchObject({ origin: "manual", status: "active", constraintName: null });
const listed = (await app.inject({
method: "GET", url: `/catalog/databases/${database.id}/relationships`,
})).json();
expect(listed.map((item: { origin: string }) => item.origin).sort()).toEqual(["manual", "physical"]);
const relationshipId = created.json().id;
const excluded = await app.inject({
method: "PATCH",
url: `/catalog/databases/${database.id}/relationships/${relationshipId}`,
payload: { status: "excluded" },
});
expect(excluded.statusCode).toBe(200);
expect(excluded.json()).toMatchObject({ origin: "manual", status: "excluded" });
const restored = await app.inject({
method: "PATCH",
url: `/catalog/databases/${database.id}/relationships/${relationshipId}`,
payload: { status: "active" },
});
expect(restored.json()).toMatchObject({ status: "active" });
expect((await app.inject({
method: "DELETE", url: `/catalog/databases/${database.id}/relationships/${relationshipId}`,
})).statusCode).toBe(204);
});
test("rejects generated inference while the Catalog schema is not current", async () => {
const { app, database } = await setup();
const response = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/relationships/rebuild-generated`,
});
expect(response.statusCode).toBe(409);
expect(response.json()).toEqual({
code: "relationship_schema_stale",
message: "Synchronize the current database schema before managing logical relationships.",
});
});
test("rebuilds generated relationships and returns the exact summary", async () => {
const { app, repository, database } = await setup();
const observed = snapshot();
observed.relationships = [];
await seedCatalog(repository, database, observed);
const first = await app.inject({
method: "POST", url: `/catalog/databases/${database.id}/relationships/rebuild-generated`,
});
expect(first.statusCode).toBe(200);
expect(first.json()).toEqual({ added: 1, alreadyPresent: 0, excluded: 0, ambiguous: 0 });
const generated = (await repository.listLogicalRelationships(database.id))[0]!;
await app.inject({
method: "PATCH",
url: `/catalog/databases/${database.id}/relationships/${generated.id}`,
payload: { status: "excluded" },
});
const second = await app.inject({
method: "POST", url: `/catalog/databases/${database.id}/relationships/rebuild-generated`,
});
expect(second.json()).toEqual({ added: 0, alreadyPresent: 0, excluded: 1, ambiguous: 0 });
});
test("validates relationship requests and keeps physical relationships read-only", async () => {
const { app, repository, database } = await setup();
await seedCatalog(repository, database);
const physical = (await repository.listRelationships(database.id))[0]!;
const invalid = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/relationships`,
payload: { sourceColumnId: physical.columns[0].sourceColumnId, targetColumnId: physical.columns[0].targetColumnId, generated: true },
});
expect(invalid.statusCode).toBe(400);
expect(invalid.json()).toMatchObject({ code: "relationship_request_invalid" });
const readOnly = await app.inject({
method: "PATCH",
url: `/catalog/databases/${database.id}/relationships/${physical.id}`,
payload: { status: "excluded" },
});
expect(readOnly.statusCode).toBe(409);
expect(readOnly.json()).toMatchObject({ code: "relationship_read_only" });
});
test("requires database.manage for relationship map mutations", async () => {
const { app, repository, database } = await setup({ AUTH_MODE: "upstream" });
const observed = snapshot();
observed.relationships = [];
await seedCatalog(repository, database, observed);
const response = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/relationships/rebuild-generated`,
headers: {
"x-thoth-principal-issuer": "portal",
"x-thoth-principal-subject": "catalog-reader",
"x-thoth-is-admin": "0",
},
});
expect(response.statusCode).toBe(403);
expect(response.json()).toEqual({ code: "auth_forbidden", error: "This operation is not permitted" });
});
@@ -54,6 +54,7 @@ async function setup() {
list: vi.fn(async () => [revision]),
listCatalog: vi.fn(async () => [{ id: "psd-clinical", name: "Policlinico San Donato", configurationState: "ready", revision }]),
read: vi.fn(async () => ({ workspace, revision })),
readPinned: vi.fn(async () => ({ workspace, workspaceConfigPath: revision.snapshotPath })),
} as unknown as WorkspaceRegistry;
const secretStore = new WorkspaceSecretStore({ root: secretRoot, runtimeRoot, installationId: "test" });
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/missing", NODE_ENV: "test" }), {
@@ -0,0 +1,196 @@
import { describe, expect, test, vi } from "vitest";
import { EffectiveRelationshipSnapshotProvider } from "../src/catalog/effective-relationship-snapshot.js";
import type { CatalogRelationship, WorkspaceDatabase } from "../src/catalog/types.js";
const timestamp = "2026-08-31T10:00:00.000Z";
function database(): WorkspaceDatabase {
return {
id: "database-1",
workspaceId: "psd",
engine: "postgres",
databaseName: "warehouse",
schema: "public",
version: 1,
createdAt: timestamp,
updatedAt: timestamp,
binding: { transport: "postgres_direct" },
connectionStatus: "reachable",
schemaSyncedVersion: 1,
schemaSyncedAt: timestamp,
};
}
const operations = {
async run<T>(_databaseId: string, operation: () => Promise<T>): Promise<T> {
return await operation();
},
};
function relationship(options: {
id: string;
origin: "physical" | "generated" | "manual";
status?: "active" | "excluded";
sourceTable?: string;
sourceColumns?: string[];
targetTable?: string;
targetColumns?: string[];
}): CatalogRelationship {
const sourceColumns = options.sourceColumns ?? ["user_id"];
const targetColumns = options.targetColumns ?? ["id"];
const columns = sourceColumns.map((sourceColumnName, position) => ({
position: position + 1,
sourceColumnId: `source-${position}`,
sourceColumnName,
targetColumnId: `target-${position}`,
targetColumnName: targetColumns[position],
}));
const common = {
id: options.id,
databaseId: "database-1",
sourceTableId: "source-table",
sourceTableName: options.sourceTable ?? "orders",
targetTableId: "target-table",
targetTableName: options.targetTable ?? "users",
columns,
createdAt: timestamp,
updatedAt: timestamp,
};
if (options.origin === "physical") {
return {
...common,
constraintName: `fk_${options.id}`,
updateRule: "NO ACTION",
deleteRule: "NO ACTION",
deferrable: false,
initiallyDeferred: false,
lastSyncedDatabaseVersion: 1,
lastSyncedAt: timestamp,
origin: "physical",
status: "active",
};
}
return {
...common,
constraintName: null,
updateRule: null,
deleteRule: null,
deferrable: false,
initiallyDeferred: false,
lastSyncedDatabaseVersion: null,
lastSyncedAt: null,
origin: options.origin,
status: options.status ?? "active",
columns: [columns[0]!],
};
}
describe("EffectiveRelationshipSnapshotProvider", () => {
test("returns no projection for a workspace without a Catalog database", async () => {
const list = vi.fn();
const provider = new EffectiveRelationshipSnapshotProvider(
{
get: vi.fn().mockResolvedValue(undefined),
getByWorkspace: vi.fn().mockResolvedValue(undefined),
},
{ list },
operations,
);
await expect(provider.render("legacy")).resolves.toBeUndefined();
expect(list).not.toHaveBeenCalled();
});
test("renders a deterministic active map with physical precedence", async () => {
const duplicateGenerated = relationship({ id: "generated", origin: "generated" });
const physical = relationship({ id: "physical", origin: "physical" });
const compositePhysical = relationship({
id: "physical-composite",
origin: "physical",
sourceTable: "order_lines",
sourceColumns: ["order_id", "tenant_id"],
targetTable: "orders",
targetColumns: ["id", "tenant_id"],
});
const manual = relationship({
id: "manual",
origin: "manual",
sourceTable: "invoices",
sourceColumns: ["customer_id"],
targetTable: "customers",
targetColumns: ["id"],
});
const excluded = relationship({
id: "excluded",
origin: "generated",
status: "excluded",
sourceTable: "invoices",
});
const list = vi.fn().mockResolvedValue([
manual,
duplicateGenerated,
excluded,
physical,
compositePhysical,
]);
const provider = new EffectiveRelationshipSnapshotProvider(
{
get: vi.fn().mockResolvedValue(database()),
getByWorkspace: vi.fn().mockResolvedValue(database()),
},
{ list },
operations,
);
const rendered = await provider.render("psd");
expect(rendered?.endsWith("\n")).toBe(true);
expect(JSON.parse(rendered!)).toEqual({
schemaVersion: 1,
workspaceId: "psd",
relationships: [
{
sourceTable: "invoices",
sourceColumns: ["customer_id"],
targetTable: "customers",
targetColumns: ["id"],
origin: "manual",
},
{
sourceTable: "order_lines",
sourceColumns: ["order_id", "tenant_id"],
targetTable: "orders",
targetColumns: ["id", "tenant_id"],
origin: "physical",
},
{
sourceTable: "orders",
sourceColumns: ["user_id"],
targetTable: "users",
targetColumns: ["id"],
origin: "physical",
},
],
});
expect(list).toHaveBeenCalledWith("database-1");
});
test("fails closed when the Catalog schema is absent or stale", async () => {
const stale = database();
delete stale.schemaSyncedVersion;
delete stale.schemaSyncedAt;
const list = vi.fn();
const provider = new EffectiveRelationshipSnapshotProvider(
{
get: vi.fn().mockResolvedValue(stale),
getByWorkspace: vi.fn().mockResolvedValue(stale),
},
{ list },
operations,
);
await expect(provider.render("psd")).rejects.toThrow(
"effective relationship snapshot requires a current full schema synchronization",
);
expect(list).not.toHaveBeenCalled();
});
});
+57
View File
@@ -517,6 +517,63 @@ test("creates a session from the active immutable workspace revision", async ()
}));
});
test("hands one effective relationship snapshot to both retrieval and Pi", async () => {
const effective = JSON.stringify({
schemaVersion: 1,
workspaceId: "default",
relationships: [],
});
const render = vi.fn(async () => effective);
const acquireWorkspaceRuntime = vi.fn((_workspace: string, relationships?: string) => ({
path: "/runtime/with-relationships.yaml",
workspaceId: "default",
workspaceRevision: "e".repeat(40),
release: vi.fn(),
}));
const searchPack = vi.fn(async () => {});
const createFor = vi.fn(() => ({ bridge: { onClientEvent: () => {} } }));
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
sessionNew: async () => ({ id: "effective-map" }),
acquireWorkspaceRuntime,
searchPack,
} as any,
effectiveRelationshipSnapshotProvider: { render } as any,
readiness: { ensure: async () => ({ ok: true }) } as any,
mgr: {
get: () => undefined,
createFor,
configure: async () => {},
start: () => {},
} as any,
getSettings: () => ({ workspace: "default", thinking: "low" }) as any,
});
const response = await app.inject({
method: "POST",
url: "/sessions",
payload: { question: "Which users placed orders?", workspaceId: "default" },
});
expect(response.statusCode).toBe(200);
expect(render).toHaveBeenCalledWith("default");
expect(acquireWorkspaceRuntime).toHaveBeenCalledWith(
expect.stringContaining("/default.yaml"),
effective,
);
expect(createFor).toHaveBeenCalledWith(
"effective-map",
expect.objectContaining({
runtimeConfig: expect.objectContaining({ path: "/runtime/with-relationships.yaml" }),
}),
);
expect(searchPack).toHaveBeenCalledWith(
"Which users placed orders?",
"effective-map",
"/runtime/with-relationships.yaml",
);
});
test("rejects an SSH-only workspace before persisting or starting a session", async () => {
const sessionNew = vi.fn(async () => ({ id: "must-not-exist" }));
const ensure = vi.fn(async () => ({ ok: true }));
@@ -197,6 +197,25 @@ test("ThtRunner uses a vault secret only for the lifetime of its runtime lease",
expect(existsSync(rendered.database.password_file)).toBe(false);
});
test("ThtRunner binds and cleans the effective relationship snapshot with its runtime lease", async () => {
const f = await fixture();
const runner = runnerFor(f);
const relationships = JSON.stringify({
schemaVersion: 1,
workspaceId: "psd-clinical",
relationships: [],
});
const lease = runner.acquireWorkspaceRuntime(f.revision.snapshotPath, relationships);
const rendered = parse(readFileSync(lease.path, "utf8")) as {
paths: { effective_relationships: string };
};
expect(readFileSync(rendered.paths.effective_relationships, "utf8")).toBe(relationships);
lease.release();
expect(existsSync(rendered.paths.effective_relationships)).toBe(false);
});
test("separate runtime leases hand off byte-identical revision Evidence configs accepted by tht", async () => {
const f = await fixture();
const runner = runnerFor(f);