diff --git a/src/application/desktop/DesktopApplicationState.test.ts b/src/application/desktop/DesktopApplicationState.test.ts index 33c4474d58..c652dcb2ec 100644 --- a/src/application/desktop/DesktopApplicationState.test.ts +++ b/src/application/desktop/DesktopApplicationState.test.ts @@ -8,7 +8,11 @@ import type { DesktopCheckpoint, } from "../../domain/desktop/desktopCapture.js"; import { desktopApplicationStateSchema } from "../../domain/desktop/desktopState.js"; -import { inspectDesktopApplicationState } from "./DesktopApplicationState.js"; +import { + inspectDesktopApplicationState, + type DesktopStateReaders, +} from "./DesktopApplicationState.js"; +import { draftDesktopApplicationTask } from "./DesktopTaskDraft.js"; import { DATABASE, HOME, @@ -215,6 +219,82 @@ describe("inspectDesktopApplicationState", () => { }); }); +describe("nullable SQLite row identity through desktop capture consumers", () => { + it("carries the changed row into the task draft without losing its check", async () => { + const source = capture(); + const folder = await writeCaptureFolder({ + ...source, + state: { + ...source.state, + checkpoints: source.state.checkpoints.map((entry) => ({ + ...entry, + changes: entry.changes.filter((change) => change.path === DATABASE), + retained: entry.retained.filter((file) => file.path === DATABASE), + })), + }, + }); + const sqlite: DesktopStateReaders["sqlite"] = async (database) => ({ + ok: true, + view: { + tables: { + items: { + columns: ["id", "body"], + primary_key: ["id"], + rows: (await readFile(database, "latin1")).endsWith("v1") + ? [ + [null, "alpha"], + [null, "beta"], + ] + : [ + [null, "gamma"], + [null, "beta"], + ], + truncated: false, + }, + }, + tables_truncated: false, + }, + }); + const using = { ...readers, sqlite }; + const state = await inspectDesktopApplicationState(folder, using); + if (!("normalized_result" in state)) throw state; + expect( + desktopApplicationStateSchema.parse(state.normalized_result).paths, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + home_path: ".local/share/notes/notes.db", + changes: [ + expect.objectContaining({ + kind: "rows", + added_count: 1, + removed_count: 1, + }), + ], + }), + ]), + ); + const task = await draftDesktopApplicationTask(folder, using); + if (!("normalized_result" in task)) throw task; + expect(task.normalized_result).toMatchObject({ + task: { + checks: [ + { + kind: "row_present", + table: "items", + where: { id: null, body: "gamma" }, + }, + { + kind: "row_absent", + table: "items", + where: { id: null, body: "alpha" }, + }, + ], + }, + }); + }); +}); + describe("inspectDesktopApplicationState database sidecars", () => { it("refuses a database whose write-ahead log was left out at the baseline", async () => { const base = capture(); diff --git a/src/domain/desktop/appTaskChecks.ts b/src/domain/desktop/appTaskChecks.ts index bed05c4dc1..c423923a0d 100644 --- a/src/domain/desktop/appTaskChecks.ts +++ b/src/domain/desktop/appTaskChecks.ts @@ -287,6 +287,26 @@ const tableOf = ( }; }; +/** Count every row satisfying a task predicate, including overlapping predicates. */ +export const countTaskRows = ( + columns: readonly JsonValue[], + rows: readonly (readonly JsonValue[])[], + where: Readonly>, +): number => { + const conditions = Object.entries(where).map( + ([column, value]) => [columns.indexOf(column), value] as const, + ); + let count = 0; + for (const row of rows) + if ( + conditions.every( + ([index, value]) => index >= 0 && sameJson(row[index], value), + ) + ) + count += 1; + return count; +}; + const rowVerdict = ( check: Check<"row_present" | "row_absent" | "row_count">, entry: TaskFileView | undefined, @@ -297,13 +317,11 @@ const rowVerdict = ( if ("passed" in table) return table; const { columns, rows } = table; const name = `${check.file} table ${check.table}`; - const matches = (row: JsonValue) => - Array.isArray(row) && - Object.entries(check.where ?? {}).every(([column, value]) => { - const index = columns.indexOf(column); - return index >= 0 && sameJson(row[index], value); - }); - const n = rows.filter(matches).length; + const n = countTaskRows( + columns, + rows.filter(Array.isArray), + check.where ?? {}, + ); if (check.kind === "row_present") return n > 0 ? pass(`${name} has a row with ${shown(check.where)}`) diff --git a/src/domain/desktop/appTaskDraft.test.ts b/src/domain/desktop/appTaskDraft.test.ts index c44d5b4918..56d461129c 100644 --- a/src/domain/desktop/appTaskDraft.test.ts +++ b/src/domain/desktop/appTaskDraft.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { JsonValue } from "../jsonValue.js"; import { draftTaskFiles } from "./appTaskDraft.js"; +import { evaluateTaskChecks } from "./appTaskChecks.js"; import type { DesktopStatePath } from "./desktopState.js"; type Format = DesktopStatePath["format"]; @@ -310,6 +311,208 @@ describe("draftTaskFiles for XML that uses namespaces", () => { }); }); +describe("draftTaskFiles with nullable SQLite keys", () => { + it("suggests checks for distinct NULL-key rows and ignores a reorder", () => { + const file = ".local/share/app/items.db"; + const table = (rows: (string | null)[][]) => ({ + tables: { + items: { + columns: ["id", "body"], + primary_key: ["id"], + rows, + truncated: false, + }, + }, + tables_truncated: false, + }); + expect( + draft({ + [file]: path( + "application_state", + "sqlite", + table([ + [null, "alpha"], + [null, "beta"], + ]), + table([ + [null, "beta"], + [null, "alpha"], + ]), + ), + }), + ).toEqual([]); + const [database] = draft({ + [file]: path( + "application_state", + "sqlite", + table([ + [null, "alpha"], + [null, "beta"], + ]), + table([ + [null, "gamma"], + [null, "beta"], + ]), + ), + }); + expect(database?.suggested).toEqual([ + { + kind: "row_present", + file, + table: "items", + where: { id: null, body: "gamma" }, + }, + { + kind: "row_absent", + file, + table: "items", + where: { id: null, body: "alpha" }, + }, + ]); + }); +}); + +describe("draftTaskFiles with unkeyed row conditions", () => { + const file = "Documents/items.db"; + const table = (rows: JsonValue[][]) => ({ + tables: { + items: { + columns: ["id", "body", "at"], + primary_key: ["id"], + rows, + truncated: false, + }, + }, + tables_truncated: false, + }); + + it("does not contradict itself when only a NULL-key row timestamp changes", () => { + const [database] = draft({ + [file]: path( + "document", + "sqlite", + table([[null, "alpha", "2026-11-04 09:00:00"]]), + table([[null, "alpha", "2026-11-04 10:00:00"]]), + ), + }); + expect(database?.suggested).toEqual([]); + }); + + it("counts repeated row conditions even when the total row count stays equal", () => { + const before = table([ + [null, "alpha", null], + [null, "alpha", null], + [null, "beta", null], + ]); + const after = table([ + [null, "alpha", null], + [null, "beta", null], + [null, "beta", null], + ]); + const [database] = draft({ + [file]: path("document", "sqlite", before, after), + }); + expect(database?.suggested).toEqual([ + { + kind: "row_count", + file, + table: "items", + where: { id: null, body: "alpha", at: null }, + equals: 1, + }, + { + kind: "row_count", + file, + table: "items", + where: { id: null, body: "beta", at: null }, + equals: 2, + }, + ]); + const checks = (database?.suggested ?? []).map((check, index) => ({ + ...check, + id: String(index), + })); + expect( + evaluateTaskChecks( + checks, + new Map([[file, { format: "sqlite", view: after }]]), + ).map((outcome) => outcome.passed), + ).toEqual([true, true]); + expect( + evaluateTaskChecks( + checks, + new Map([[file, { format: "sqlite", view: before }]]), + ).map((outcome) => outcome.passed), + ).toEqual([false, false]); + }); + + it.each([ + { + name: "counts all rows matching an overlapping predicate", + before: [ + [null, "alpha", "2026-11-04 09:00:00"], + [null, "alpha", null], + ], + after: [ + [null, "alpha", "2026-11-04 09:00:00"], + [null, "alpha", null], + [null, "alpha", "2026-11-04 10:00:00"], + ], + expected: [ + { kind: "row_count", where: { id: null, body: "alpha" }, equals: 3 }, + { kind: "row_count", equals: 3 }, + ], + }, + { + name: "does not suggest absence when a broader predicate still matches", + before: [ + [null, "alpha", "2026-11-04 09:00:00"], + [null, "alpha", null], + ], + after: [[null, "alpha", null]], + expected: [ + { kind: "row_count", where: { id: null, body: "alpha" }, equals: 1 }, + { kind: "row_count", equals: 1 }, + ], + }, + { + name: "keeps the surviving broad predicate when a timestamp becomes NULL", + before: [[null, "alpha", "2026-11-04 09:00:00"]], + after: [[null, "alpha", null]], + expected: [ + { kind: "row_present", where: { id: null, body: "alpha", at: null } }, + ], + }, + ])("$name", ({ before, after, expected }) => { + for (const key of [["id"], []]) { + const baseline = table(before); + const final = table(after); + baseline.tables.items.primary_key = key; + final.tables.items.primary_key = key; + const [database] = draft({ + [file]: path("document", "sqlite", baseline, final), + }); + expect(database?.suggested).toEqual( + expected.map((check) => ({ ...check, file, table: "items" })), + ); + const checks = (database?.suggested ?? []).map((check, index) => ({ + ...check, + id: String(index), + })); + for (const [view, passed] of [ + [final, true], + [baseline, false], + ] as const) + expect( + evaluateTaskChecks( + checks, + new Map([[file, { format: "sqlite", view }]]), + ).map((outcome) => outcome.passed), + ).toEqual(checks.map(() => passed)); + } + }); +}); + describe("draftTaskFiles with partial evidence", () => { const notes = (...rows: JsonValue[][]) => ({ tables: { diff --git a/src/domain/desktop/appTaskDraft.ts b/src/domain/desktop/appTaskDraft.ts index d0c3b1c9b9..9c5ec865fa 100644 --- a/src/domain/desktop/appTaskDraft.ts +++ b/src/domain/desktop/appTaskDraft.ts @@ -5,7 +5,12 @@ import { type DesktopStatePath, type StateEnds, } from "./desktopState.js"; -import { diffStateViews, diffZipMembers } from "./stateChanges.js"; +import { countTaskRows } from "./appTaskChecks.js"; +import { + diffStateViews, + diffZipMembers, + sqliteRowIdentity, +} from "./stateChanges.js"; import { locatePointer, pairMembers, resolvePointer } from "./viewPointer.js"; import { entryWithoutView, zipArrayLeavesOut, zipEntryAt } from "./zipView.js"; @@ -330,6 +335,58 @@ const rowWhere = ( }), ); +/** Match uniquely keyed rows separately from multiset row conditions. */ +const keyedRows = (table: TableView) => + new Map( + table.rows.flatMap((row) => { + const identity = sqliteRowIdentity(table.columns, table.key, row); + return identity.keyed ? [[identity.value, row] as const] : []; + }), + ); + +/** The predicates a task can express after volatile values are omitted. */ +const rowConditions = (table: TableView) => { + const conditions = new Map>(); + for (const row of table.rows) { + if (sqliteRowIdentity(table.columns, table.key, row).keyed) continue; + const where = rowWhere(table, row, () => true); + if (Object.keys(where).length === 0) continue; + conditions.set(JSON.stringify(where), where); + } + return conditions; +}; + +const unkeyedRowSuggestions = ( + file: string, + table: string, + baseline: TableView, + final: TableView, +): Suggestion[] => { + const conditions = new Map([ + ...rowConditions(final), + ...rowConditions(baseline), + ]); + const suggestions: Suggestion[] = []; + for (const where of conditions.values()) { + const previous = countTaskRows(baseline.columns, baseline.rows, where); + const count = countTaskRows(final.columns, final.rows, where); + if (previous === count) continue; + if (count === 0) + suggestions.push({ kind: "row_absent", file, table, where }); + else if (previous === 0) + suggestions.push({ kind: "row_present", file, table, where }); + else + suggestions.push({ + kind: "row_count", + file, + table, + where, + equals: count, + }); + } + return suggestions; +}; + const rowSuggestions = ( file: string, baseline: Version, @@ -347,28 +404,15 @@ const rowSuggestions = ( }; for (const [name, table] of tableViews(final)) { const old = before.get(name) ?? { ...table, rows: [] }; - const identity = (view: TableView, row: readonly JsonValue[]) => - JSON.stringify( - view.key.length === 0 - ? row - : view.key.map((column) => row[view.columns.indexOf(column)] ?? null), - ); - const oldRows = new Map(old.rows.map((row) => [identity(old, row), row])); - const newRows = new Map( - table.rows.map((row) => [identity(table, row), row]), - ); - const keyed = (column: string) => table.key.includes(column); + const oldRows = keyedRows(old); + const newRows = keyedRows(table); for (const [id, row] of newRows) { const previous = oldRows.get(id); if (previous === undefined) push( "row_present", name, - rowWhere( - table, - row, - (column) => table.key.length === 0 || !keyed(column), - ), + rowWhere(table, row, (column) => !table.key.includes(column)), ); else if (JSON.stringify(previous) !== JSON.stringify(row)) push( @@ -378,7 +422,7 @@ const rowSuggestions = ( table, row, (column) => - keyed(column) || + table.key.includes(column) || JSON.stringify(row[table.columns.indexOf(column)]) !== JSON.stringify(previous[old.columns.indexOf(column)]), ), @@ -389,12 +433,9 @@ const rowSuggestions = ( push( "row_absent", name, - rowWhere( - old, - row, - (column) => old.key.length === 0 || old.key.includes(column), - ), + rowWhere(old, row, (column) => old.key.includes(column)), ); + suggestions.push(...unkeyedRowSuggestions(file, name, old, table)); if (old.rows.length !== table.rows.length) suggestions.push({ kind: "row_count", diff --git a/src/domain/desktop/stateChanges.test.ts b/src/domain/desktop/stateChanges.test.ts index 1321c3b28c..822db56f16 100644 --- a/src/domain/desktop/stateChanges.test.ts +++ b/src/domain/desktop/stateChanges.test.ts @@ -82,6 +82,49 @@ describe("diffStateViews", () => { changed: ["notes:[1]"], }); }); + + it("compares NULL primary-key rows as a multiset while retaining keyed matches", () => { + const table = (rows: (string | null)[][]) => ({ + tables: { + items: { columns: ["id", "body"], primary_key: ["id"], rows }, + }, + }); + const before = table([ + [null, "alpha"], + [null, "beta"], + ["fixed", "old"], + ]); + expect( + diffStateViews( + "sqlite", + before, + table([ + ["fixed", "old"], + [null, "beta"], + [null, "alpha"], + ]), + ), + ).toMatchObject({ + added_count: 0, + removed_count: 0, + changed_count: 0, + }); + expect( + diffStateViews( + "sqlite", + before, + table([ + [null, "gamma"], + [null, "beta"], + ["fixed", "new"], + ]), + ), + ).toMatchObject({ + added: ['items:[null,"gamma"]'], + removed: ['items:[null,"alpha"]'], + changed: ['items:["fixed"]'], + }); + }); }); describe("diffStateViews fidelity", () => { diff --git a/src/domain/desktop/stateChanges.ts b/src/domain/desktop/stateChanges.ts index c7e78a7e27..7885d70abe 100644 --- a/src/domain/desktop/stateChanges.ts +++ b/src/domain/desktop/stateChanges.ts @@ -245,6 +245,24 @@ const unreadTables = (view: JsonValue): string[] => .filter(([, table]) => isRecord(table) && typeof table.error === "string") .map(([name]) => name); +/** A nullable SQLite primary key cannot identify a row on its own. */ +export const sqliteRowIdentity = ( + columns: readonly JsonValue[], + key: readonly JsonValue[], + row: readonly JsonValue[], +): { value: string; keyed: boolean } => { + const indexes = key.map((column) => columns.indexOf(column)); + const keyed = + indexes.length > 0 && + indexes.every( + (index) => index >= 0 && row[index] !== null && row[index] !== undefined, + ); + return { + value: JSON.stringify(keyed ? indexes.map((index) => row[index]) : row), + keyed, + }; +}; + /** * `table:key` → row for SQLite views, keyed by primary key when there is one. * Tables unread in either version are skipped, so their rows are not misreported. @@ -258,18 +276,13 @@ const rowEntries = ( if (skipped.has(name) || !isRecord(table) || !Array.isArray(table.rows)) continue; const columns = Array.isArray(table.columns) ? table.columns : []; - const keyColumns = ( - Array.isArray(table.primary_key) ? table.primary_key : [] - ) - .map((column) => columns.indexOf(column)) - .filter((index) => index >= 0); + const keyColumns = Array.isArray(table.primary_key) + ? table.primary_key + : []; const seen = new Map(); for (const row of table.rows) { if (!Array.isArray(row)) continue; - const identity = - keyColumns.length > 0 - ? JSON.stringify(keyColumns.map((index) => row[index] ?? null)) - : JSON.stringify(row); + const identity = sqliteRowIdentity(columns, keyColumns, row).value; // Rows without a key are compared as a multiset of whole rows. const occurrence = seen.get(identity) ?? 0; seen.set(identity, occurrence + 1);