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:
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();
|
||||
|
||||
@@ -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)}`)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user