@openparachute/vault 0.7.9-rc.7 → 0.7.9-rc.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ import { test, expect } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { BunSqliteStore } from "./store.js";
4
+ import { initSchema } from "./schema.js";
5
+ import { runDoctorScan } from "./doctor.js";
6
+
7
+ test("no ledger rows are not evidence of a downgrade", async () => {
8
+ const db = new Database(":memory:");
9
+ try {
10
+ const store = new BunSqliteStore(db);
11
+ const note = await store.createNote("before");
12
+ await store.updateNote(note.id, { content: "after" });
13
+ db.exec("DELETE FROM schema_version; UPDATE history_compact_state SET refused=1");
14
+ initSchema(db);
15
+ expect(db.prepare("SELECT refused FROM history_compact_state").get()).toEqual({ refused: 1 });
16
+ db.exec("DROP TABLE history_compact_state; DELETE FROM schema_version");
17
+ initSchema(db);
18
+ expect(db.prepare("SELECT versions,refused FROM history_compact_state").get()).toEqual({ versions: 1, refused: 0 });
19
+ } finally { db.close(); }
20
+ });
21
+
22
+ for (const tied of [false, true]) test(`a downgrade ledger entry rebuilds existing stale hints (tied=${tied})`, async () => {
23
+ const db = new Database(":memory:");
24
+ try {
25
+ const store = new BunSqliteStore(db, { history: { compact_min_versions: 2, compact_ratio: 1 } });
26
+ const note = await store.createNote("old".repeat(1000));
27
+ await store.updateNote(note.id, { append: "first" });
28
+ const stale = db.prepare("SELECT * FROM history_compact_state WHERE note_id=?").get(note.id) as any;
29
+ await store.updateNote(note.id, { append: "second" });
30
+ await store.updateNote(note.id, { content: "x" });
31
+ // Reproduce the older writer: history/live rows advance but hints do not,
32
+ // and that binary records its own version on open.
33
+ db.prepare("UPDATE history_compact_state SET versions=?,stored=?,live=?,refused=1 WHERE note_id=?").run(stale.versions, stale.stored, stale.live, note.id);
34
+ db.prepare("UPDATE schema_version SET applied_at='2026-01-01T00:00:00.000Z'").run();
35
+ db.prepare("INSERT OR REPLACE INTO schema_version VALUES(31,?)").run(tied ? "2026-01-01T00:00:00.000Z" : "2026-01-02T00:00:00.000Z");
36
+ expect(runDoctorScan(db).findings.some(f=>f.type==="history_compact_state_drift")).toBe(true);
37
+ const rows = db.prepare("SELECT * FROM note_versions").all();
38
+ initSchema(db);
39
+ expect(runDoctorScan(db).findings.some(f=>f.type==="history_compact_state_drift")).toBe(false);
40
+ expect(db.prepare("SELECT * FROM note_versions").all()).toEqual(rows);
41
+ expect(store.compactHistory({ budgetMs: null, maxNotes: null }).notes_scanned).toBe(1);
42
+ db.prepare("UPDATE history_compact_state SET refused=1").run();
43
+ initSchema(db);
44
+ expect(db.prepare("SELECT refused FROM history_compact_state WHERE note_id=?").get(note.id)).toEqual({ refused: 1 });
45
+ } finally { db.close(); }
46
+ });
@@ -0,0 +1,129 @@
1
+ import { test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { BunSqliteStore } from "./store.js";
4
+ import { initSchema } from "./schema.js";
5
+ import { runDoctorScan } from "./doctor.js";
6
+ import { pruneVersions, resolveHistoryPolicy, sweepDeletedHistory } from "./history.js";
7
+ import { readCompactState } from "./history-compact-state.js";
8
+ import { applyImportedNote, beginImportRun, importTargetDigest } from "./history-import.js";
9
+ let db: Database, store: BunSqliteStore;
10
+ beforeEach(() => { db = new Database(":memory:"); store = new BunSqliteStore(db, { history: { compact_min_versions: 2, compact_ratio: 1 } }); });
11
+ afterEach(() => db.close());
12
+ const state = (id: string) => db.query("SELECT versions,stored,live,refused FROM history_compact_state WHERE note_id=?").get(id) as { versions: number; stored: number; live: number; refused: number } | null;
13
+ test("live bytes refresh after mutation, empty histories are excluded and nonempty edits re-arm", async () => {
14
+ const n = await store.createNote("large".repeat(1000));
15
+ await store.updateNote(n.id, { append: "changed" });
16
+ await store.updateNote(n.id, { content: "" });
17
+ expect(state(n.id)?.live).toBe(0);
18
+ expect(store.compactHistory({ budgetMs: null, maxNotes: null }).notes_scanned).toBe(0);
19
+ await store.updateNote(n.id, { content: "x" });
20
+ expect(state(n.id)?.live).toBe(1);
21
+ expect(store.compactHistory({ budgetMs: null, maxNotes: null }).notes_scanned).toBe(1);
22
+ });
23
+ test("incompressible refusal disappears on pass two and any metadata capture re-arms", async () => {
24
+ const n = await store.createNote("a");
25
+ await store.updateNote(n.id, { content: "b" });
26
+ await store.updateNote(n.id, { content: "c" });
27
+ expect(store.compactHistory({ budgetMs: null, maxNotes: null }).notes_scanned).toBe(1);
28
+ expect(state(n.id)?.refused).toBe(1);
29
+ expect(store.compactHistory({ budgetMs: null, maxNotes: null }).notes_scanned).toBe(0);
30
+ await store.updateNote(n.id, { metadata: { status: 1 } });
31
+ expect(state(n.id)?.refused).toBe(0);
32
+ expect(store.compactHistory({ budgetMs: null, maxNotes: null }).notes_scanned).toBe(1);
33
+ });
34
+ test("shared blob rewrites refresh other notes without re-arming refusal", async () => {
35
+ const body = "shared history\n".repeat(1000);
36
+ const a = await store.createNote(body), b = await store.createNote(body);
37
+ await store.updateNote(a.id, { append: "a" });
38
+ await store.updateNote(a.id, { content: "small" });
39
+ await store.updateNote(b.id, { content: "small" });
40
+ db.query("UPDATE history_compact_state SET refused=1 WHERE note_id=?").run(b.id);
41
+ const before = state(b.id)!;
42
+ expect(store.compactNote(a.id).blobs_deltified).toBeGreaterThan(0);
43
+ expect(state(b.id)!.stored).toBeLessThan(before.stored);
44
+ expect(state(b.id)!.refused).toBe(1);
45
+ });
46
+ test("v31 migration backfills deleted and live history once and preserves bodies", async () => {
47
+ const n = await store.createNote("old");
48
+ await store.updateNote(n.id, { content: "new" });
49
+ await store.deleteNote(n.id);
50
+ const before = db.query("SELECT * FROM note_versions").all();
51
+ db.exec("DROP TABLE history_compact_state; UPDATE schema_version SET version=31");
52
+ initSchema(db);
53
+ expect(state(n.id)).toEqual({ versions: 2, stored: 6, live: 3, refused: 0 });
54
+ expect(db.query("PRAGMA foreign_key_list(history_compact_state)").all()).toEqual([]);
55
+ expect(db.query("SELECT * FROM note_versions").all()).toEqual(before);
56
+ expect((await store.getNoteVersion(n.id, 0))?.content).toBe("old");
57
+ db.query("UPDATE history_compact_state SET refused=1").run();
58
+ initSchema(db);
59
+ expect(state(n.id)?.refused).toBe(1);
60
+ await store.eraseNoteHistory(n.id);
61
+ expect(state(n.id)).toBeNull();
62
+ expect(db.query("PRAGMA foreign_key_check").all()).toEqual([]);
63
+ });
64
+ test("doctor reports bounded hint drift without repairing history", async () => {
65
+ const n = await store.createNote("before");
66
+ await store.updateNote(n.id, { content: "after" });
67
+ db.query("UPDATE history_compact_state SET stored=999 WHERE note_id=?").run(n.id);
68
+ const result = runDoctorScan(db);
69
+ expect(result.findings.some(f => f.type === ("history_compact_state_drift" as any) && f.severity === "warning")).toBe(true);
70
+ expect(state(n.id)?.stored).toBe(999);
71
+ expect(runDoctorScan(db, { allowedTags: ["restricted"] }).findings.some(f => f.type === "history_compact_state_drift")).toBe(false);
72
+ });
73
+ test("prune, imported observations, deleted compaction and sweep maintain exact counters", async () => {
74
+ const n = await store.createNote("body".repeat(1000));
75
+ const exact = () => {
76
+ const actual = readCompactState(db, n.id)!;
77
+ expect(state(n.id)).toMatchObject({ versions: actual.versions, stored: actual.stored, live: actual.live });
78
+ };
79
+ for (let i=0;i<4;i++) { await store.updateNote(n.id, { append: String(i) }); exact(); }
80
+ const policy = resolveHistoryPolicy({ min_versions: 1, max_versions: 2, compact_min_versions: 2, compact_ratio: 1 });
81
+ db.transaction(() => pruneVersions(db, n.id, policy))(); exact();
82
+ const run = { run_id: "state-test", source_fingerprint: "source", tip: "tip", options_digest: "options" };
83
+ beginImportRun(db, run);
84
+ applyImportedNote(db, { run, noteId: n.id, targetDigest: importTargetDigest(db, n.id), policy: resolveHistoryPolicy({ enabled: false }), now: Date.now(), observations: [{ content: "import", path: null, metadata: {}, extension: "md", created_at: null, observed_at: new Date().toISOString(), commit: "commit", blob: "blob" }] });
85
+ exact(); expect(state(n.id)?.refused).toBe(0);
86
+ await store.deleteNote(n.id); exact();
87
+ expect(store.compactNote(n.id).blobs_deltified).toBeGreaterThan(0); exact();
88
+ const remaining = await store.listNoteVersions(n.id);
89
+ for (const v of remaining) expect((await store.getNoteVersion(n.id, v.version_ix))?.content).not.toBeNull();
90
+ expect(db.query("SELECT 1 FROM note_blobs b JOIN note_blobs base ON base.hash=b.delta_of WHERE base.encoding IS NOT NULL").all()).toEqual([]);
91
+ expect(db.query("PRAGMA foreign_key_check").all()).toEqual([]);
92
+ sweepDeletedHistory(db, resolveHistoryPolicy({ deleted_retention_days: 1 }), new Date(Date.now()+3*86400000));
93
+ expect(state(n.id)).toBeNull();
94
+ });
95
+ test("explicit-ID recreation refreshes live bytes and bounded remaining is a total", async () => {
96
+ for(let i=0;i<10;i++) {
97
+ const n = await store.createNote("abc", { id: `candidate-${i}` });
98
+ await store.updateNote(n.id, { content: "def" });
99
+ await store.updateNote(n.id, { content: "x" });
100
+ }
101
+ const first = store.compactHistory({ maxNotes: 1, budgetMs: null });
102
+ expect(first.remaining_candidates).toBe(9);
103
+ await store.deleteNote("candidate-0");
104
+ await store.createNoteRaw("", { id: "candidate-0" });
105
+ expect(state("candidate-0")?.live).toBe(0);
106
+ });
107
+ test("failed enclosing write rolls back history and scheduling state together", async () => {
108
+ const n = await store.createNote("before");
109
+ await store.updateNote(n.id, { content: "after" });
110
+ const before = state(n.id);
111
+ db.exec("CREATE TRIGGER reject_hint BEFORE UPDATE ON history_compact_state BEGIN SELECT RAISE(ABORT,'injected refresh failure'); END");
112
+ await expect(store.updateNote(n.id, { content: "must roll back" })).rejects.toThrow("injected refresh failure");
113
+ expect((await store.getNote(n.id))?.content).toBe("after");
114
+ expect(state(n.id)).toEqual(before);
115
+ expect(await store.countNoteVersions(n.id)).toBe(1);
116
+ });
117
+ test("empty live content still obeys the history byte ceiling", async () => {
118
+ store = new BunSqliteStore(db, { history: { min_versions: 1, max_bytes_per_note: 65536, compact_min_versions: 2 } });
119
+ const body = () => Buffer.from(crypto.getRandomValues(new Uint8Array(15000))).toString("hex");
120
+ const n = await store.createNote(body());
121
+ for(let i=0;i<5;i++) await store.updateNote(n.id, { content: body() });
122
+ await store.updateNote(n.id, { content: "" });
123
+ expect(state(n.id)?.stored).toBe(180000);
124
+ const pass = store.compactHistory({ budgetMs: null, maxNotes: null });
125
+ expect(pass.notes_scanned).toBe(1);
126
+ expect(pass.versions_dropped).toBe(4);
127
+ expect(state(n.id)).toMatchObject({ stored: 60000, live: 0, refused: 0 });
128
+ expect(store.compactHistory({ budgetMs: null, maxNotes: null }).notes_scanned).toBe(0);
129
+ });
@@ -74,7 +74,7 @@ test("P6 triggers and off switch leave blobs unchanged; writes never compact", a
74
74
  const empty = await emptyStore.createNote("empty fixture".repeat(2000));
75
75
  await emptyStore.updateNote(empty.id, { append: "one" });
76
76
  await emptyStore.updateNote(empty.id, { content: "" });
77
- expect(emptyStore.compactHistory({ budgetMs: null, maxNotes: null }).notes_compacted).toBe(1);
77
+ expect(emptyStore.compactHistory({ budgetMs: null, maxNotes: null }).notes_scanned).toBe(0);
78
78
  } finally { emptyDb.close(); }
79
79
  });
80
80
  test("P7 third encode failure rolls back all changes", async () => {
@@ -115,7 +115,7 @@ test("P8 bounded passes resume and converge; unbounded visits all", async () =>
115
115
  expect(all.stopped_by).toBe("complete");
116
116
  expect(all.remaining_candidates).toBe(0);
117
117
  });
118
- test("P16/P8 incompressible candidates are refused on every pass", async () => {
118
+ test("P16/P8 explicit retries remain safe; automatic passes skip refused candidates", async () => {
119
119
  const bodies = Array.from({ length: 5 }, () => randomHex(20480));
120
120
  expect(codec).not.toBeNull();
121
121
  expect(codec!.encodeDelta(bodies[1]!, bodies[0]!).length).toBeGreaterThanOrEqual(20480 * .9);
@@ -129,7 +129,7 @@ test("P16/P8 incompressible candidates are refused on every pass", async () => {
129
129
  expect(result.blobs_deltified).toBe(0);
130
130
  expect(result.blobs_skipped_too_large).toBeGreaterThanOrEqual(1);
131
131
  expect(blobs()).toEqual(before);
132
- expect(h.compactVault(db, p, { budgetMs: null, maxNotes: null })).toMatchObject({ notes_scanned: 1, stopped_by: "complete" });
132
+ expect(h.compactVault(db, p, { budgetMs: null, maxNotes: null })).toMatchObject({ notes_scanned: 0, stopped_by: "complete" });
133
133
  }
134
134
  const twin = await seed();
135
135
  expect(store.compactNote(twin.id)).toMatchObject({ blobs_skipped_too_large: 0 });
@@ -48,11 +48,67 @@
48
48
  */
49
49
 
50
50
  import { historyTablesPresent, deletedHistoryStats, historyStorageStats, topNotesByHistoryBytes } from "./history.js";
51
+ import { readCompactState, type CompactState } from "./history-compact-state.js";
51
52
  import { Database } from "bun:sqlite";
52
53
  import { loadTagHierarchy, findHierarchyCycles } from "./tag-hierarchy.js";
53
54
  import { listIndexedFields } from "./indexed-fields.js";
54
55
  import { pruneOrphanedIndexedFields } from "./indexed-fields.js";
55
56
  import { loadSchemaConfig } from "./schema-defaults.js";
57
+ import { readBlobContent, hashContent, HistoryDeltaOrphanError } from "./history.js";
58
+
59
+ function scanCompactStateDrift(db: Database): DoctorFinding[] {
60
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='history_compact_state'").get()) return [];
61
+ const sample = db.prepare("SELECT note_id,versions,stored,live FROM history_compact_state ORDER BY stored DESC LIMIT 200").all() as CompactState[];
62
+ const drift = sample.filter(row => {
63
+ const actual = readCompactState(db, row.note_id);
64
+ return !actual || actual.versions !== row.versions || actual.stored !== row.stored || actual.live !== row.live;
65
+ }).length;
66
+ return drift ? [{ type: "history_compact_state_drift", severity: "warning", subject: "history compaction scheduling hints",
67
+ detail: `${drift} of ${sample.length} sampled hint rows differ from history. Sample is bounded to the 200 largest hints; this is not a full audit.`,
68
+ remedy: "Investigate missed history-write refreshes. Self-hosted: history rebuild-state --vault <name>. Cloud: deploy a core-pin migration that rebuilds hints; no operator repair API. This scan does not repair them." }] : [];
69
+ }
70
+
71
+ export interface HistoryAuditPage {
72
+ checked: number;
73
+ corrupt: number;
74
+ examples: string[];
75
+ complete: boolean;
76
+ next_after: string | null;
77
+ }
78
+
79
+ /** Read-only keyset page. Budgets apply between blobs, not inside one decode.
80
+ * A sequence of pages is not a snapshot: use a consistent backup for a complete audit.
81
+ */
82
+ export function auditHistoryBlobs(db: Database, after = "", maxBlobs = 100, budgetMs = 250): HistoryAuditPage {
83
+ if (after !== "" && !/^[a-f0-9]{64}$/.test(after)) throw new Error("history_after must be a SHA-256 hash");
84
+ if (!Number.isInteger(maxBlobs) || maxBlobs < 1 || maxBlobs > 500) throw new Error("history_max_blobs must be an integer from 1 to 500");
85
+ if (!Number.isInteger(budgetMs) || budgetMs < 1 || budgetMs > 1000) throw new Error("history_budget_ms must be an integer from 1 to 1000");
86
+ const page: HistoryAuditPage = { checked: 0, corrupt: 0, examples: [], complete: true, next_after: null };
87
+ if (!historyTablesPresent(db)) return page;
88
+ const start = performance.now();
89
+ const rows = db.prepare("SELECT hash FROM note_blobs WHERE hash > ? ORDER BY hash LIMIT ?").all(after, maxBlobs + 1) as { hash: string }[];
90
+ let cursor = after;
91
+ for (const row of rows) {
92
+ if (page.checked >= maxBlobs || (page.checked > 0 && performance.now() - start >= budgetMs)) {
93
+ page.complete = false;
94
+ page.next_after = cursor;
95
+ break;
96
+ }
97
+ let damaged = false;
98
+ try {
99
+ const body = readBlobContent(db, row.hash);
100
+ damaged = body === null || hashContent(body) !== row.hash;
101
+ } catch (error) {
102
+ // Operational database failures must abort, not masquerade as corruption.
103
+ if (!(error instanceof HistoryDeltaOrphanError)) throw error;
104
+ damaged = true;
105
+ }
106
+ page.checked++;
107
+ cursor = row.hash;
108
+ if (damaged) { page.corrupt++; if (page.examples.length < 5) page.examples.push(row.hash); }
109
+ }
110
+ return page;
111
+ }
56
112
 
57
113
  export type DoctorFindingType =
58
114
  | "dangling_parent_name"
@@ -62,7 +118,9 @@ export type DoctorFindingType =
62
118
  | "dead_tag_metadata_reference"
63
119
  | "deleted_note_history"
64
120
  | "history_storage"
65
- | "history_delta_orphan";
121
+ | "history_delta_orphan"
122
+ | "history_content_audit"
123
+ | "history_compact_state_drift";
66
124
 
67
125
  export type DoctorSeverity = "error" | "warning" | "info";
68
126
 
@@ -86,6 +144,7 @@ export interface DoctorReport {
86
144
  findings: DoctorFinding[];
87
145
  summary: string;
88
146
  scanned_at: string;
147
+ history_audit?: HistoryAuditPage;
89
148
  }
90
149
 
91
150
  export interface DoctorScanOpts {
@@ -95,6 +154,10 @@ export interface DoctorScanOpts {
95
154
  * each finding type is scoped.
96
155
  */
97
156
  allowedTags?: Set<string> | null;
157
+ deep?: boolean;
158
+ history_after?: string;
159
+ history_max_blobs?: number;
160
+ history_budget_ms?: number;
98
161
  }
99
162
 
100
163
  /** Cap on exemplar note IDs embedded in a finding's `detail` (vault#552 — keep report bodies bounded). */
@@ -124,6 +187,7 @@ function makeNoteInScope(db: Database, allowedTags: Set<string> | null): (id: st
124
187
 
125
188
  export function runDoctorScan(db: Database, opts?: DoctorScanOpts): DoctorReport {
126
189
  const allowedTags = opts?.allowedTags ?? null;
190
+ if (opts?.deep && allowedTags !== null) throw new Error("deep history audit requires an unrestricted session");
127
191
  const findings: DoctorFinding[] = [
128
192
  ...scanDanglingParentNames(db, allowedTags),
129
193
  ...scanParentNamesCycles(db, allowedTags),
@@ -133,8 +197,15 @@ export function runDoctorScan(db: Database, opts?: DoctorScanOpts): DoctorReport
133
197
  ...(allowedTags === null ? scanDeletedNoteHistory(db) : []),
134
198
  ...(allowedTags === null ? scanHistoryStorage(db) : []),
135
199
  ...(allowedTags === null ? scanDeltaOrphans(db) : []),
200
+ ...(allowedTags === null ? scanCompactStateDrift(db) : []),
136
201
  ];
137
202
 
203
+ const audit = opts?.deep ? auditHistoryBlobs(db, opts.history_after, opts.history_max_blobs, opts.history_budget_ms) : undefined;
204
+ if (audit) findings.push({ type: "history_content_audit", severity: audit.corrupt ? "error" : audit.complete ? "info" : "warning",
205
+ subject: "history content audit page",
206
+ detail: `Checked ${audit.checked} blob(s); ${audit.corrupt} corrupt. ${audit.complete ? "End of scan reached." : "Incomplete: continue with next_after."} Examples: ${audit.examples.join(", ") || "none"}.`,
207
+ remedy: "Continue incomplete scans with history_after=next_after. For snapshot-level assurance, audit a consistent backup. Preserve backups and investigate corruption; this audit never repairs or erases history." });
208
+
138
209
  const errors = findings.filter((f) => f.severity === "error").length;
139
210
  const warnings = findings.filter((f) => f.severity === "warning").length;
140
211
  const infos = findings.filter((f) => f.severity === "info").length;
@@ -143,7 +214,8 @@ export function runDoctorScan(db: Database, opts?: DoctorScanOpts): DoctorReport
143
214
  ? "clean — no integrity findings"
144
215
  : `${findings.length} finding(s): ${errors} error(s), ${warnings} warning(s), ${infos} info(s)`;
145
216
 
146
- return { findings, summary, scanned_at: new Date().toISOString() };
217
+ return { findings, summary, scanned_at: new Date().toISOString(),
218
+ ...(audit ? { history_audit: audit } : {}) };
147
219
  }
148
220
 
149
221
  // ---------------------------------------------------------------------------
@@ -0,0 +1,51 @@
1
+ import { test, expect } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { SqliteStore } from "./store.js";
4
+ import { auditHistoryBlobs, runDoctorScan } from "./doctor.js";
5
+ import { hashContent } from "./history.js";
6
+ import { encodeDelta } from "./delta.js";
7
+
8
+ test("deep audit pages whole/delta blobs, detects content damage without writes", () => {
9
+ const db = new Database(":memory:"); new SqliteStore(db);
10
+ try {
11
+ const insert = db.prepare("INSERT INTO note_blobs(hash,content,byte_size,encoding,delta_of) VALUES(?,?,?,?,?)");
12
+ const base = "base text ".repeat(100), changed = base + "new tail";
13
+ const a = hashContent(base), b = hashContent(changed), c = hashContent("original");
14
+ insert.run(a, base, base.length, null, null);
15
+ const delta = encodeDelta(base, changed);
16
+ insert.run(b, delta, delta.length, "fossil-delta", a);
17
+ insert.run(c, "tampered", 8, null, null);
18
+ const before = db.prepare("SELECT * FROM note_blobs ORDER BY hash").all();
19
+ let after = "", checked = 0, corrupt = 0;
20
+ do {
21
+ const p = auditHistoryBlobs(db, after, 1);
22
+ checked += p.checked; corrupt += p.corrupt;
23
+ if (p.complete) { expect(p.next_after).toBeNull(); break; }
24
+ expect(p.next_after! > after).toBe(true); after = p.next_after!;
25
+ } while (true);
26
+ expect(checked).toBe(3); expect(corrupt).toBe(1);
27
+ const report = runDoctorScan(db, { deep: true });
28
+ expect(report.history_audit?.examples).toEqual([c]);
29
+ expect(report.findings.find(f => f.type === "history_content_audit")?.severity).toBe("error");
30
+ expect(db.prepare("SELECT * FROM note_blobs ORDER BY hash").all()).toEqual(before);
31
+ db.prepare("UPDATE note_blobs SET content='invalid delta' WHERE hash=?").run(b);
32
+ expect(auditHistoryBlobs(db).corrupt).toBe(2);
33
+ } finally { db.close(); }
34
+ });
35
+
36
+ test("deep audit is explicit, bounded, scope-safe, and reports empty completion", () => {
37
+ const db = new Database(":memory:"); new SqliteStore(db);
38
+ try {
39
+ expect(runDoctorScan(db).history_audit).toBeUndefined();
40
+ expect(auditHistoryBlobs(db)).toEqual({ checked: 0, corrupt: 0, examples: [], complete: true, next_after: null });
41
+ expect(() => runDoctorScan(db, { deep: true, allowedTags: new Set() })).toThrow("unrestricted");
42
+ for (const n of [0, -1, 501, NaN, 1.5]) expect(() => auditHistoryBlobs(db, "", n)).toThrow();
43
+ expect(() => auditHistoryBlobs(db, "bad cursor")).toThrow();
44
+ expect(() => auditHistoryBlobs(db, "", 100, 1001)).toThrow();
45
+ for (let i=0;i<7;i++) db.prepare("INSERT INTO note_blobs(hash,content,byte_size) VALUES(?,?,?)").run(hashContent(String(i)), "broken", 6);
46
+ const p = auditHistoryBlobs(db, "", 2);
47
+ expect(p.checked).toBe(2); expect(p.complete).toBe(false); expect(p.next_after).not.toBeNull();
48
+ expect(auditHistoryBlobs(db).examples).toHaveLength(5);
49
+ expect(runDoctorScan(db, { deep: true, history_max_blobs: 2 }).summary).toContain("error");
50
+ } finally { db.close(); }
51
+ });
@@ -86,9 +86,17 @@ it("P5 restore reads its source before a one-version prune", async () => {
86
86
  });
87
87
  const n = await store.createNote("one");
88
88
  await store.updateNote(n.id, { content: "two" });
89
- expect((await store.restoreNoteVersion(n.id, 0, {})).content).toBe("one");
89
+ expect((await store.restoreNoteVersion(n.id, 0, { if_updated_at: (await store.getNote(n.id))!.updatedAt })).content).toBe("one");
90
90
  expect(await store.listNoteVersions(n.id)).toHaveLength(1);
91
91
  });
92
+ it("live restore requires a precondition and leaves current note and history unchanged without one", async () => {
93
+ const n = await store.createNote("old");
94
+ await store.updateNote(n.id, { content: "new" });
95
+ const before = await store.getNote(n.id), versions = await store.listNoteVersions(n.id);
96
+ await expect(store.restoreNoteVersion(n.id, 0, {})).rejects.toMatchObject({ code: "PRECONDITION_REQUIRED" });
97
+ expect(await store.getNote(n.id)).toEqual(before);
98
+ expect(await store.listNoteVersions(n.id)).toEqual(versions);
99
+ });
92
100
  it("P8a tag rename captures only rewritten notes", async () => {
93
101
  await store.upsertTagRecord("old", {});
94
102
  const a = await store.createNote("#old a"),
@@ -0,0 +1,52 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { transaction } from "./txn.js";
3
+
4
+ /** Rebuild derived hints only, atomically; authoritative history is untouched. */
5
+ export function rebuildCompactState(db: Database): number {
6
+ return transaction(db, () => {
7
+ if (db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='history_compact_state'").get()) {
8
+ db.exec("DROP TABLE history_compact_state");
9
+ }
10
+ db.exec(`CREATE TABLE history_compact_state (
11
+ note_id TEXT PRIMARY KEY, versions INTEGER NOT NULL, stored INTEGER NOT NULL,
12
+ live INTEGER NOT NULL, refused INTEGER NOT NULL DEFAULT 0, refreshed_at INTEGER NOT NULL
13
+ );
14
+ CREATE INDEX idx_history_compact_candidates ON history_compact_state(refused, stored DESC);
15
+ CREATE INDEX IF NOT EXISTS idx_note_versions_note_hash ON note_versions(note_id, content_hash);`);
16
+ db.prepare(`INSERT INTO history_compact_state(note_id,versions,stored,live,refused,refreshed_at)
17
+ SELECT note_id,versions,stored,live,0,? FROM (${COMPACT_STATE_AGGREGATE.replace("__FILTER__", "")})`).run(Date.now());
18
+ return (db.prepare("SELECT COUNT(*) AS n FROM history_compact_state").get() as { n: number }).n;
19
+ });
20
+ }
21
+
22
+ // Scheduling hints only. History remains authoritative, including for deleted notes.
23
+ export const COMPACT_STATE_AGGREGATE = `SELECT v.note_id,
24
+ (SELECT COUNT(*) FROM note_versions n WHERE n.note_id=v.note_id AND n.content_hash IS NOT NULL) AS versions,
25
+ SUM(b.byte_size) AS stored,
26
+ COALESCE(LENGTH(CAST(live_note.content AS BLOB)),
27
+ (SELECT content_len FROM note_versions newest WHERE newest.note_id=v.note_id ORDER BY version_ix DESC LIMIT 1),0) AS live
28
+ FROM (SELECT DISTINCT note_id,content_hash FROM note_versions WHERE content_hash IS NOT NULL __FILTER__) v
29
+ JOIN note_blobs b ON b.hash=v.content_hash LEFT JOIN notes live_note ON live_note.id=v.note_id
30
+ GROUP BY v.note_id`;
31
+
32
+ export interface CompactState { note_id: string; versions: number; stored: number; live: number }
33
+ export function readCompactState(db: Database, noteId: string): CompactState | null {
34
+ return db.prepare(COMPACT_STATE_AGGREGATE.replace("__FILTER__", "AND note_id=?")).get(noteId) as CompactState | null;
35
+ }
36
+
37
+ /** Caller owns the enclosing write transaction; run after the live-row mutation. */
38
+ export function refreshCompactState(db: Database, noteId: string, opts?: { refused?: boolean }): void {
39
+ // Keep the existing graceful behavior of writes before history tables exist.
40
+ const tables = db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name IN ('note_versions','note_blobs','history_compact_state')").get() as { n: number };
41
+ if (tables.n !== 3) return;
42
+ const state = readCompactState(db, noteId);
43
+ if (!state) {
44
+ db.prepare("DELETE FROM history_compact_state WHERE note_id=?").run(noteId);
45
+ return;
46
+ }
47
+ db.prepare(`INSERT INTO history_compact_state(note_id,versions,stored,live,refused,refreshed_at)
48
+ VALUES(?,?,?,?,?,?) ON CONFLICT(note_id) DO UPDATE SET
49
+ versions=excluded.versions,stored=excluded.stored,live=excluded.live,
50
+ refused=excluded.refused,refreshed_at=excluded.refreshed_at`)
51
+ .run(noteId, state.versions, state.stored, state.live, opts?.refused ? 1 : 0, Date.now());
52
+ }
@@ -1,6 +1,7 @@
1
1
  /** Git-independent import primitives. Callers own source parsing and offline access. */
2
2
  import type { Database } from "bun:sqlite";
3
3
  import { transaction } from "./txn.js";
4
+ import { refreshCompactState } from "./history-compact-state.js";
4
5
  import { HistoryUnrecoverableError, hashContent, byteLength, VERSION_MAX_BYTES, pruneVersions, compactNote, getVersion, type HistoryPolicy, type VersionRow } from "./history.js";
5
6
 
6
7
  export interface ImportedObservation {
@@ -112,7 +113,7 @@ export function applyImportedNote(db: Database, opts: {
112
113
  VALUES(?,?,?,?,?,?,?,NULL,'git-import','import',?,NULL,?)`).run(opts.noteId, storage, hash, row.path, canonicalJson(row.metadata), row.extension, row.observed_at, size, row.created_at);
113
114
  db.prepare("INSERT INTO history_import_refs VALUES(?,?,?,?)").run(opts.noteId, index, row.commit, row.blob);
114
115
  }
115
- if (opts.policy.enabled) pruneVersions(db, opts.noteId, opts.policy, opts.now);
116
+ if (opts.policy.enabled) pruneVersions(db, opts.noteId, opts.policy, opts.now, false);
116
117
  compactNote(db, opts.noteId, opts.policy);
117
118
  const after = new Set((db.prepare("SELECT version_ix FROM note_versions WHERE note_id=?").all(opts.noteId) as { version_ix: number }[]).map(r => r.version_ix));
118
119
  for (let i = 0; i < opts.observations.length; i++) {
@@ -125,6 +126,7 @@ export function applyImportedNote(db: Database, opts: {
125
126
  receipt.pruned_imported = receipt.imported_count - receipt.retained_count;
126
127
  receipt.pruned_native = nativeDrops.length;
127
128
  db.prepare("UPDATE history_import_receipts SET retained_count=?,pruned_imported=?,pruned_native=? WHERE note_id=?").run(receipt.retained_count, receipt.pruned_imported, receipt.pruned_native, opts.noteId);
129
+ refreshCompactState(db, opts.noteId);
128
130
  return { receipt, nativeDrops, skipped: false };
129
131
  });
130
132
  }
@@ -0,0 +1,11 @@
1
+ import { test, expect } from "bun:test";
2
+ import { projectHistoryProvenance } from "./history-visibility.ts";
3
+
4
+ test("history redaction removes only transport provenance without mutating stored data", () => {
5
+ const row = Object.freeze({ actor: "editor", via: "api", content: "editor mentioned in text",
6
+ metadata: { actor: "user-authored value" }, origin: "git-import", import_ix: 0 });
7
+ expect(projectHistoryProvenance(row, false)).toBe(row);
8
+ expect(projectHistoryProvenance(row, true)).toEqual({ content: row.content, metadata: row.metadata,
9
+ origin: "git-import", import_ix: 0 });
10
+ expect(row.actor).toBe("editor");
11
+ });
@@ -0,0 +1,9 @@
1
+ /** Response-only redaction; never remove stored provenance or rewrite note content.
2
+ * Tag-restricted sessions do not receive historical editor/interface identities.
3
+ * The transport must establish note visibility before applying this projection.
4
+ */
5
+ export function projectHistoryProvenance<T extends object>(row: T, restricted: boolean): T | Omit<T, "actor" | "via"> {
6
+ if (!restricted) return row;
7
+ const { actor: _actor, via: _via, ...visible } = row as T & { actor?: unknown; via?: unknown };
8
+ return visible;
9
+ }