fix(desktop): preserve nullable SQLite row identity (#8999)

Use whole-row multiset identity for incomplete/NULL declared keys and share it with task drafting. Count actual projected predicates across all rows so timestamp omission and duplicate multiplicities yield satisfiable checks.

Fixes #8997.

Validation:106 focused tests; nativeSQLite public CLI before/after cases and generatedPython evaluator agreement.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
This commit is contained in:
xin zhuang authored and GitHub committed 2026-11-06 09:54:08 +00:00
1 parent 985b9eae96
commit aefd903adb
6 files changed
+438 -40

No files matched your search

@@ -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();
+25 -7
View File
@@ -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<Record<string, JsonValue>>,
): 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)}`)
+203
View File
@@ -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: {
+64 -23
View File
@@ -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<string, Record<string, JsonValue>>();
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",
+43
View File
@@ -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", () => {
+22 -9
View File
@@ -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<string, number>();
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);