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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user