feat: protect sensitive catalog samples

This commit is contained in:
Codex
2026-08-30 12:14:23 +02:00
parent 6278ee9d81
commit 0736983bc5
28 changed files with 1162 additions and 128 deletions
@@ -286,10 +286,13 @@ test("discloses bounded transient source samples in database-wide and selected g
name: "Metadata generation source data disclosure",
});
expect(within(disclosure).getByText(
"Description generation may send up to five real source rows and up to five representative non-null example values to the selected model provider.",
"Description generation may send up to five source rows and up to five representative non-null example values to the selected model provider. Values from columns marked sensitive are replaced with plausible synthetic values before the request.",
)).toBeVisible();
expect(within(disclosure).getByText(
"Samples are transient and are not stored in run logs or catalog metadata. Automated Sensitive Data Policy filtering and anonymization are not currently provided; they are planned for future work.",
"Values from unmarked columns may be sent unchanged. Samples are transient and are not stored in run logs or catalog metadata.",
)).toBeVisible();
expect(within(disclosure).getByText(
"Sensitive-field suggestions use structural metadata only and remain unsaved until you choose Save sensitive fields.",
)).toBeVisible();
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
@@ -333,7 +336,7 @@ test.each(synchronizationScopes)(
});
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox"));
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(await screen.findByRole("menuitem", { name: synchronizationScopes[0].label })).toBeEnabled();
@@ -365,7 +368,7 @@ test("starts Generate Missing for one configured database without confirmation",
renderPage({ rows: [makeDatabase()] });
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox"));
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate Missing" }));
@@ -396,7 +399,7 @@ test("confirms Generate All replacement, supports cancel, and sends the database
renderPage({ rows: [makeDatabase()] });
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox"));
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate All" }));
@@ -434,7 +437,7 @@ test("keeps the database selected and safely explains when no descriptions are e
renderPage({ rows: [makeDatabase()] });
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox"));
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate Missing" }));
@@ -493,7 +496,7 @@ test.each([
]) client.setQueryData(queryKey, []);
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox"));
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", {
name: scope === "all" ? "Generate All" : "Generate Missing",
@@ -568,7 +571,7 @@ test.each([
for (const database of rows.slice(0, selectedRows)) {
const row = await screen.findByRole("row", { name: new RegExp(database.workspaceName) });
await user.click(within(row).getByRole("checkbox"));
await user.click(within(row).getByRole("checkbox", { name: /toggle row selection/i }));
}
await user.click(screen.getByRole("button", { name: "Actions" }));
@@ -599,13 +602,13 @@ test("disables database-wide generation while a description generation is active
renderPage({ rows: [makeDatabase()] });
let databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox"));
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate Missing" }));
expect(await screen.findByRole("complementary", { name: "Description generation" })).toBeVisible();
databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox"));
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(await screen.findByRole("menuitem", { name: "Generate All" }))
.toHaveAttribute("aria-disabled", "true");
@@ -630,8 +633,8 @@ test("selected database Actions confirms and deletes catalog tables for the full
const firstRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
const secondRow = await screen.findByRole("row", { name: /Radiology/ });
await user.click(within(firstRow).getByRole("checkbox"));
await user.click(within(secondRow).getByRole("checkbox"));
await user.click(within(firstRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(within(secondRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(await screen.findByRole("menuitem", { name: "Delete all tables" })).toBeEnabled();
@@ -642,10 +645,10 @@ test("selected database Actions confirms and deletes catalog tables for the full
expect(screen.getByText(/database configurations and source databases are unchanged/i)).toBeVisible();
expect(cleanupBody).toBeUndefined();
await user.click(within(secondRow).getByRole("checkbox"));
await user.click(within(secondRow).getByRole("checkbox", { name: /toggle row selection/i }));
expect(screen.queryByText(/Delete all catalog tables for/)).not.toBeInTheDocument();
expect(cleanupBody).toBeUndefined();
await user.click(within(secondRow).getByRole("checkbox"));
await user.click(within(secondRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Delete all tables" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Delete catalog tables" })).toHaveFocus());
@@ -681,7 +684,7 @@ test("presents completed synchronization steps as success and skips unneeded con
});
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox"));
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Synchronize all columns" }));
@@ -1047,6 +1050,7 @@ const patientIdColumn: CatalogColumn = {
sourceComment: "Patient identifier",
description: null,
generatedDescription: null,
sensitive: false,
lastSyncedDatabaseVersion: 3,
lastSyncedAt: "2026-08-27T10:00:00Z",
version: 1,
@@ -1106,7 +1110,7 @@ test("selected table Actions exposes cleanup commands and sends synchronization
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
await user.click(screen.getByRole("tab", { name: "Tables" }));
const tableRow = await screen.findByRole("row", { name: /patients/ });
await user.click(within(tableRow).getByRole("checkbox"));
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
const synchronizeColumns = await screen.findByRole("menuitem", { name: "Synchronize columns" });
@@ -1413,8 +1417,8 @@ test("moves selected generated table descriptions and reports copied and skipped
await user.click(screen.getByRole("tab", { name: "Tables" }));
const patientsRow = await screen.findByRole("row", { name: /patients/ });
const visitsRow = await screen.findByRole("row", { name: /visits/ });
await user.click(within(patientsRow).getByRole("checkbox"));
await user.click(within(visitsRow).getByRole("checkbox"));
await user.click(within(patientsRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(within(visitsRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", {
name: "Move generated description to Description",
@@ -1451,6 +1455,7 @@ test("selects columns, moves generated descriptions, and refreshes only the affe
sourceComment: "Patient identifier",
description: "Old identifier",
generatedDescription: "Generated identifier",
sensitive: false,
lastSyncedDatabaseVersion: 3,
lastSyncedAt: "2026-08-27T10:00:00Z",
version: 1,
@@ -1491,7 +1496,7 @@ test("selects columns, moves generated descriptions, and refreshes only the affe
const tableQuery = ["catalog-tables", patientsTable.databaseId] as const;
const selectableRow = async (name: RegExp) => {
const rows = await screen.findAllByRole("row", { name });
const row = rows.find((candidate) => within(candidate).queryByRole("checkbox"));
const row = rows.find((candidate) => within(candidate).queryByRole("checkbox", { name: /toggle row selection/i }));
expect(row).toBeDefined();
return row!;
};
@@ -1501,8 +1506,8 @@ test("selects columns, moves generated descriptions, and refreshes only the affe
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
const idRow = await selectableRow(/Patient identifier/);
const nameRow = await selectableRow(/Keep curated patient name/);
await user.click(within(idRow).getByRole("checkbox"));
await user.click(within(nameRow).getByRole("checkbox"));
await user.click(within(idRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(within(nameRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", {
name: "Move generated description to Description",
@@ -1549,9 +1554,9 @@ test("starts one selected column with the configured default model", async () =>
await user.click(screen.getByRole("tab", { name: "Tables" }));
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
const columnRow = (await screen.findAllByRole("row", { name: /Patient identifier/ }))
.find((row) => within(row).queryByRole("checkbox"));
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
expect(columnRow).toBeDefined();
await user.click(within(columnRow!).getByRole("checkbox"));
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
@@ -1566,6 +1571,101 @@ test("starts one selected column with the configured default model", async () =>
expect(await screen.findByText("Description generation started for 1 column")).toBeVisible();
});
test("reviews AI-sensitive-field suggestions as an editable draft and saves only changed columns", async () => {
const user = userEvent.setup();
const idColumn = { ...patientIdColumn, sensitive: false };
const nameColumn = {
...patientIdColumn,
id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
name: "name",
ordinalPosition: 2,
primaryKeyPosition: null,
isPrimaryKey: false,
description: "Patient name",
generatedDescription: "Name of the patient",
sensitive: false,
};
let columns = [idColumn, nameColumn];
let suggestionBody: unknown;
const patches: Array<{ columnId: string; body: unknown }> = [];
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
})),
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
http.get(
"/api/catalog/databases/:databaseId/tables/:tableId/columns",
() => HttpResponse.json(columns),
),
http.post(
"/api/catalog/databases/:databaseId/sensitive-data-suggestions",
async ({ request }) => {
suggestionBody = await request.json();
return HttpResponse.json({
suggestions: [
{ columnId: idColumn.id, sensitive: true },
{ columnId: nameColumn.id, sensitive: true },
{ columnId: "ffffffff-ffff-4fff-8fff-ffffffffffff", sensitive: true },
],
});
},
),
http.patch(
"/api/catalog/databases/:databaseId/tables/:tableId/columns/:columnId",
async ({ params, request }) => {
const body = await request.json() as {
version: number;
description: string | null;
generatedDescription: string | null;
sensitive: boolean;
};
patches.push({ columnId: String(params.columnId), body });
const current = columns.find((column) => column.id === params.columnId)!;
const updated = { ...current, ...body, version: current.version + 1 };
columns = columns.map((column) => column.id === updated.id ? updated : column);
return HttpResponse.json(updated);
},
),
);
renderPage({ rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })] });
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
await user.click(screen.getByRole("tab", { name: "Tables" }));
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
const idSensitive = await screen.findByRole("checkbox", { name: "Sensitive data for id" });
const nameSensitive = await screen.findByRole("checkbox", { name: "Sensitive data for name" });
expect(idSensitive).not.toBeChecked();
expect(nameSensitive).not.toBeChecked();
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
await waitFor(() => expect(suggestionBody).toEqual({ modelId: "local-qwen" }));
await waitFor(() => {
expect(screen.getByRole("checkbox", { name: "Sensitive data for id" })).toBeChecked();
expect(screen.getByRole("checkbox", { name: "Sensitive data for name" })).toBeChecked();
});
expect(patches).toHaveLength(0);
await user.click(screen.getByRole("checkbox", { name: "Sensitive data for name" }));
expect(screen.getByRole("checkbox", { name: "Sensitive data for name" })).not.toBeChecked();
await user.click(screen.getByRole("button", { name: "Save sensitive fields" }));
await waitFor(() => expect(patches).toEqual([{
columnId: idColumn.id,
body: {
version: idColumn.version,
description: idColumn.description,
generatedDescription: idColumn.generatedDescription,
sensitive: true,
},
}]));
expect(await screen.findByText("Sensitive fields saved")).toBeVisible();
expect(screen.getByRole("checkbox", { name: "Sensitive data for id" })).toBeChecked();
expect(screen.getByRole("checkbox", { name: "Sensitive data for name" })).not.toBeChecked();
});
test("polls a description run and renders its events in sequence order", async () => {
const user = userEvent.setup();
const queuedRun = makeDescriptionGenerationRun();
@@ -1617,8 +1717,8 @@ test("polls a description run and renders its events in sequence order", async (
await user.click(screen.getByRole("tab", { name: "Tables" }));
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
const columnRow = (await screen.findAllByRole("row", { name: /Patient identifier/ }))
.find((row) => within(row).queryByRole("checkbox"));
await user.click(within(columnRow!).getByRole("checkbox"));
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
@@ -1699,8 +1799,8 @@ test("refreshes Catalog Tables and Catalog Columns after column generation compl
await user.click(screen.getByRole("tab", { name: "Tables" }));
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
const columnRow = (await screen.findAllByRole("row", { name: /Patient identifier/ }))
.find((row) => within(row).queryByRole("checkbox"));
await user.click(within(columnRow!).getByRole("checkbox"));
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
@@ -1708,7 +1808,7 @@ test("refreshes Catalog Tables and Catalog Columns after column generation compl
await waitFor(() => expect(columnReads).toBe(2));
await waitFor(() => expect(tableReads).toBe(2));
expect(within((await screen.findAllByRole("row", { name: /Stable patient identifier/ }))
.find((row) => within(row).queryByRole("checkbox"))!).getByText("Stable patient identifier")).toBeVisible();
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }))!).getByText("Stable patient identifier")).toBeVisible();
expect(client.getQueryState(unrelatedColumnsQuery)?.isInvalidated).toBe(true);
});
@@ -1732,8 +1832,8 @@ test("keeps the selected column and shows a safe message when generation cannot
await user.click(screen.getByRole("tab", { name: "Tables" }));
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
const columnRow = (await screen.findAllByRole("row", { name: /Patient identifier/ }))
.find((row) => within(row).queryByRole("checkbox"));
await user.click(within(columnRow!).getByRole("checkbox"));
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
@@ -1799,8 +1899,8 @@ test("shows a basic failed run without exposing private model or provider fields
await user.click(screen.getByRole("tab", { name: "Tables" }));
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
const columnRow = (await screen.findAllByRole("row", { name: /Patient identifier/ }))
.find((row) => within(row).queryByRole("checkbox"));
await user.click(within(columnRow!).getByRole("checkbox"));
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
@@ -1851,10 +1951,10 @@ test("offers generation and consolidation for multiple selected columns", async
await user.click(screen.getByRole("tab", { name: "Tables" }));
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
const idRow = (await screen.findAllByRole("row", { name: /Patient identifier/ }))
.find((row) => within(row).queryByRole("checkbox"));
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
const nameRow = (await screen.findAllByRole("row", { name: /Patient name/ }))
.find((row) => within(row).queryByRole("checkbox"));
await user.click(within(idRow!).getByRole("checkbox"));
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
await user.click(within(idRow!).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(await screen.findByRole("menuitem", { name: "Generate description" })).toBeEnabled();
expect(screen.getByRole("menuitem", {
@@ -1862,7 +1962,7 @@ test("offers generation and consolidation for multiple selected columns", async
})).toBeEnabled();
await user.keyboard("{Escape}");
await user.click(within(nameRow!).getByRole("checkbox"));
await user.click(within(nameRow!).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
const generate = await screen.findByRole("menuitem", { name: "Generate descriptions" });
expect(generate).toBeEnabled();
@@ -1901,7 +2001,7 @@ test.each([
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
await user.click(screen.getByRole("tab", { name: "Tables" }));
const tableRow = await screen.findByRole("row", { name: /patients/ });
await user.click(within(tableRow).getByRole("checkbox"));
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(await screen.findByRole("menuitem", { name: "Generate description" }))
.toHaveAttribute("aria-disabled", "true");
@@ -1909,8 +2009,8 @@ test.each([
await user.click(screen.getByRole("button", { name: "Clear" }));
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
const columnRow = (await screen.findAllByRole("row", { name: /Patient identifier/ }))
.find((row) => within(row).queryByRole("checkbox"));
await user.click(within(columnRow!).getByRole("checkbox"));
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(await screen.findByRole("menuitem", { name: "Generate description" }))
@@ -1943,8 +2043,8 @@ test("disables another generation start while the selected-column run is active"
await user.click(screen.getByRole("tab", { name: "Tables" }));
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
const columnRow = (await screen.findAllByRole("row", { name: /Patient identifier/ }))
.find((row) => within(row).queryByRole("checkbox"));
await user.click(within(columnRow!).getByRole("checkbox"));
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
expect(await screen.findByRole("complementary", { name: "Description generation" })).toBeVisible();
@@ -1956,7 +2056,7 @@ test("disables another generation start while the selected-column run is active"
await user.click(screen.getByRole("button", { name: "Back to tables" }));
const tableRow = await screen.findByRole("row", { name: /patients/ });
await user.click(within(tableRow).getByRole("checkbox"));
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(await screen.findByRole("menuitem", { name: "Generate description" }))
.toHaveAttribute("aria-disabled", "true");
@@ -1982,7 +2082,7 @@ test("disables selected description consolidation without database.manage", asyn
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
await user.click(screen.getByRole("tab", { name: "Tables" }));
const tableRow = await screen.findByRole("row", { name: /patients/ });
await user.click(within(tableRow).getByRole("checkbox"));
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(await screen.findByRole("menuitem", {
@@ -2011,7 +2111,7 @@ test("shows an operation conflict without clearing selected descriptions or refr
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
await user.click(screen.getByRole("tab", { name: "Tables" }));
const tableRow = await screen.findByRole("row", { name: /patients/ });
await user.click(within(tableRow).getByRole("checkbox"));
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", {
name: "Move generated description to Description",
@@ -2045,7 +2145,7 @@ test("selected table Actions confirms and deletes incoming and outgoing relation
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
await user.click(screen.getByRole("tab", { name: "Tables" }));
const tableRow = await screen.findByRole("row", { name: /patients/ });
await user.click(within(tableRow).getByRole("checkbox"));
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Delete all relationships" }));
@@ -2053,10 +2153,10 @@ test("selected table Actions confirms and deletes incoming and outgoing relation
expect(screen.getByText(/incoming and outgoing relationships/i)).toBeVisible();
expect(cleanupBody).toBeUndefined();
await user.click(within(tableRow).getByRole("checkbox"));
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
expect(screen.queryByText("Delete all relationships for 1 table?")).not.toBeInTheDocument();
expect(cleanupBody).toBeUndefined();
await user.click(within(tableRow).getByRole("checkbox"));
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Delete all relationships" }));
await user.click(screen.getByRole("button", { name: "Delete relationships" }));
@@ -2133,6 +2233,7 @@ test("navigates from a table to columns and from the database to physical relati
sourceComment: "Patient identifier",
description: null,
generatedDescription: null,
sensitive: false,
lastSyncedDatabaseVersion: 3,
lastSyncedAt: "2026-08-27T10:00:00Z",
version: 1,
@@ -3,7 +3,7 @@ import { Menu } from "@base-ui/react/menu";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AgGridReact } from "ag-grid-react";
import type { ColDef, ICellRendererParams } from "ag-grid-community";
import { ChevronDown, KeyRound, Link2, Pencil, RefreshCw, Save, X } from "lucide-react";
import { ChevronDown, KeyRound, Link2, Pencil, RefreshCw, Save, Sparkles, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { ApiError, apiErrorMessage } from "../../api/client";
@@ -11,6 +11,7 @@ import {
consolidateCatalogDescriptions,
listCatalogColumns,
startDescriptionGenerationRun,
suggestSensitiveFields,
updateCatalogColumnMetadata,
type CatalogColumn,
type CatalogTable,
@@ -31,7 +32,9 @@ interface Props {
interface GridContext {
canManage: boolean;
busy: boolean;
onEdit: (column: CatalogColumn, origin: HTMLButtonElement) => void;
onSensitiveChange: (column: CatalogColumn, sensitive: boolean) => void;
}
function KeyCell({ data }: ICellRendererParams<CatalogColumn>) {
@@ -55,6 +58,23 @@ function ActionCell({ data, context }: ICellRendererParams<CatalogColumn, unknow
);
}
function SensitiveCell({ data, context }: ICellRendererParams<CatalogColumn, unknown, GridContext>) {
if (!data || !context) return null;
return (
<div className="flex h-full items-center justify-center" onClick={(event) => event.stopPropagation()}>
<input
type="checkbox"
className="size-4 accent-primary outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
checked={data.sensitive}
disabled={!context.canManage || context.busy}
aria-label={`Sensitive data for ${data.name}`}
onChange={(event) => context.onSensitiveChange(data, event.target.checked)}
onClick={(event) => event.stopPropagation()}
/>
</div>
);
}
export function DatabaseColumns({
databaseId,
table,
@@ -77,6 +97,8 @@ export function DatabaseColumns({
const [editingId, setEditingId] = useState<string | null>(null);
const [description, setDescription] = useState("");
const [generatedDescription, setGeneratedDescription] = useState("");
const [sensitiveDrafts, setSensitiveDrafts] = useState<Record<string, boolean>>({});
const [sensitiveAction, setSensitiveAction] = useState<"suggest" | "save" | null>(null);
const [baseline, setBaseline] = useState("");
const [version, setVersion] = useState<number | null>(null);
const [stale, setStale] = useState(false);
@@ -86,7 +108,17 @@ export function DatabaseColumns({
const originRef = useRef<HTMLButtonElement | null>(null);
const active = editingId ? data.find((column) => column.id === editingId) : undefined;
const fingerprint = JSON.stringify([description, generatedDescription]);
const dirty = Boolean(editingId && fingerprint !== baseline);
const editorDirty = Boolean(editingId && fingerprint !== baseline);
const changedSensitiveColumns = data.filter((column) => (
Object.hasOwn(sensitiveDrafts, column.id)
&& sensitiveDrafts[column.id] !== column.sensitive
));
const dirty = editorDirty || changedSensitiveColumns.length > 0;
const displayedColumns = useMemo(() => data.map((column) => (
Object.hasOwn(sensitiveDrafts, column.id)
? { ...column, sensitive: sensitiveDrafts[column.id]! }
: column
)), [data, sensitiveDrafts]);
useEffect(() => { onNavigationStateChange({ dirty, busy }); }, [busy, dirty, onNavigationStateChange]);
useEffect(() => {
@@ -107,7 +139,7 @@ export function DatabaseColumns({
};
const closeEditor = () => {
if (busy) return;
if (dirty && !window.confirm("Discard unsaved column metadata?")) return;
if (editorDirty && !window.confirm("Discard unsaved column metadata?")) return;
setEditingId(null);
window.setTimeout(() => originRef.current?.focus(), 0);
};
@@ -178,8 +210,74 @@ export function DatabaseColumns({
} finally { setBusy(false); }
};
const changeSensitive = (column: CatalogColumn, sensitive: boolean) => {
const persisted = data.find((candidate) => candidate.id === column.id);
if (!persisted) return;
setSensitiveDrafts((current) => {
const next = { ...current };
if (sensitive === persisted.sensitive) delete next[column.id];
else next[column.id] = sensitive;
return next;
});
};
const suggestSensitive = async () => {
if (!selectedMetadataModel) return;
setBusy(true);
setSensitiveAction("suggest");
try {
const result = await suggestSensitiveFields(databaseId, selectedMetadataModel);
const currentById = new Map(data.map((column) => [column.id, column]));
const next: Record<string, boolean> = {};
for (const suggestion of result.suggestions) {
const column = currentById.get(suggestion.columnId);
if (column && suggestion.sensitive !== column.sensitive) {
next[column.id] = suggestion.sensitive;
}
}
setSensitiveDrafts(next);
toast.success("Sensitive field suggestions ready for review");
} catch (error) {
toast.error(apiErrorMessage(error));
} finally {
setSensitiveAction(null);
setBusy(false);
}
};
const saveSensitive = async () => {
if (changedSensitiveColumns.length === 0) return;
setBusy(true);
setSensitiveAction("save");
try {
const updated = await Promise.all(changedSensitiveColumns.map((column) => (
updateCatalogColumnMetadata(
databaseId,
table.id,
column.id,
column.version,
column.description,
column.generatedDescription,
sensitiveDrafts[column.id],
)
)));
const updatedById = new Map(updated.map((column) => [column.id, column]));
queryClient.setQueryData<CatalogColumn[]>(queryKey, (current = []) => current.map(
(column) => updatedById.get(column.id) ?? column,
));
setSensitiveDrafts({});
toast.success("Sensitive fields saved");
} catch (error) {
toast.error(apiErrorMessage(error));
} finally {
setSensitiveAction(null);
setBusy(false);
}
};
const columns = useMemo<ColDef<CatalogColumn>[]>(() => [
{ field: "ordinalPosition", headerName: "#", width: 64, maxWidth: 64, filter: "agNumberColumnFilter" },
{ field: "sensitive", headerName: "Sensitive", minWidth: 110, width: 110, sortable: false, filter: false, resizable: false, cellRenderer: SensitiveCell },
{ field: "name", headerName: "Name", minWidth: 190, flex: 1, cellClass: "font-mono text-xs" },
{ field: "dataType", headerName: "Type", minWidth: 150, flex: 0.8, cellClass: "font-mono text-xs" },
{ headerName: "Keys", minWidth: 125, width: 125, sortable: false, filter: false, cellRenderer: KeyCell },
@@ -189,7 +287,12 @@ export function DatabaseColumns({
{ field: "description", headerName: "Description", minWidth: 230, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ colId: "actions", headerName: "", width: 64, maxWidth: 64, pinned: "right", sortable: false, filter: false, resizable: false, cellRenderer: ActionCell },
], []);
const context = useMemo<GridContext>(() => ({ canManage, onEdit: edit }), [canManage, data]);
const context = useMemo<GridContext>(() => ({
canManage,
busy,
onEdit: edit,
onSensitiveChange: changeSensitive,
}), [busy, canManage, data]);
if (editingId && active) {
return (
@@ -213,7 +316,7 @@ export function DatabaseColumns({
</div>
<div className="mt-5 flex justify-end gap-2 border-t border-border pt-4">
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>Cancel</Button>
<Button type="button" disabled={!canManage || !dirty || busy || stale} onClick={() => void save()}><Save />{busy ? "Saving…" : "Save metadata"}</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy || stale} onClick={() => void save()}><Save />{busy ? "Saving…" : "Save metadata"}</Button>
</div>
</div>
);
@@ -255,6 +358,19 @@ export function DatabaseColumns({
<span className="thot-label whitespace-nowrap">Catalog columns</span>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search columns" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
<Button
type="button"
variant="outline"
disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy}
onClick={() => void suggestSensitive()}
>
<Sparkles />{sensitiveAction === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}
</Button>
{changedSensitiveColumns.length > 0 ? (
<Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}>
<Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}
</Button>
) : null}
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<Button type="button" disabled={!canManage || busy} onClick={onSync}><RefreshCw />Sync columns</Button>
</>
@@ -264,7 +380,7 @@ export function DatabaseColumns({
<div className="thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full">
<AgGridReact<CatalogColumn>
ref={gridRef}
rowData={data}
rowData={displayedColumns}
columnDefs={columns}
context={context}
loading={isLoading}
@@ -61,13 +61,17 @@ export function MetadataGenerationModelSelector({
className="space-y-1 text-xs font-normal leading-4 text-muted-foreground"
>
<p>
Description generation may send up to five real source rows and up to five representative
non-null example values to the selected model provider.
Description generation may send up to five source rows and up to five representative
non-null example values to the selected model provider. Values from columns marked
sensitive are replaced with plausible synthetic values before the request.
</p>
<p>
Samples are transient and are not stored in run logs or catalog metadata. Automated
Sensitive Data Policy filtering and anonymization are not currently provided; they are
planned for future work.
Values from unmarked columns may be sent unchanged. Samples are transient and are not
stored in run logs or catalog metadata.
</p>
<p>
Sensitive-field suggestions use structural metadata only and remain unsaved until you
choose Save sensitive fields.
</p>
</div>
</div>