@openparachute/vault 0.7.9-rc.3 → 0.7.9-rc.5

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.
Files changed (38) hide show
  1. package/core/src/compact.test.ts +248 -0
  2. package/core/src/delta.test.ts +49 -0
  3. package/core/src/delta.ts +21 -0
  4. package/core/src/doctor.ts +41 -1
  5. package/core/src/history-capture.test.ts +244 -0
  6. package/core/src/history.test.ts +229 -0
  7. package/core/src/history.ts +679 -0
  8. package/core/src/link-count.test.ts +29 -0
  9. package/core/src/links.ts +43 -0
  10. package/core/src/mcp-manifest.ts +17 -6
  11. package/core/src/mcp.ts +42 -14
  12. package/core/src/notes.ts +6 -1
  13. package/core/src/portable-md.ts +1 -1
  14. package/core/src/schema-v29-note-versions.test.ts +63 -0
  15. package/core/src/schema-v30-note-deltas.test.ts +51 -0
  16. package/core/src/schema.ts +202 -1
  17. package/core/src/store.ts +103 -14
  18. package/core/src/types.ts +35 -2
  19. package/core/src/vendor/fossil-delta.ts +472 -0
  20. package/core/src/wikilinks.ts +68 -1
  21. package/package.json +1 -1
  22. package/src/config.ts +42 -0
  23. package/src/mcp-tools.ts +12 -2
  24. package/src/release-plan.test.ts +90 -1
  25. package/src/routes.ts +276 -95
  26. package/src/routing.ts +8 -3
  27. package/src/test-support/vault-714-find-path.json +58 -0
  28. package/src/test-support/vault-714-graph.json +27 -0
  29. package/src/test-support/vault-714-has_links.json +96 -0
  30. package/src/test-support/vault-714-include_broken_links.json +118 -0
  31. package/src/test-support/vault-714-include_link_count.json +348 -0
  32. package/src/test-support/vault-714-include_links.json +86 -0
  33. package/src/test-support/vault-714-near.json +78 -0
  34. package/src/test-support/vault-714-unresolved-wikilinks.json +6 -0
  35. package/src/vault-compact-routes.test.ts +257 -0
  36. package/src/vault-history-routes.test.ts +562 -0
  37. package/src/vault-store.ts +11 -0
  38. package/src/vault.test.ts +243 -1
@@ -0,0 +1,248 @@
1
+ import { test, expect, beforeEach, afterEach, spyOn } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { BunSqliteStore } from "./store.js";
4
+ import * as h from "./history.js";
5
+ const codec = await import("./delta.js").catch(() => null);
6
+ let db: Database, store: BunSqliteStore;
7
+ beforeEach(() => { db = new Database(":memory:"); store = new BunSqliteStore(db); });
8
+ afterEach(() => db.close());
9
+ const blobs = () => db.prepare("SELECT hash,content,byte_size,encoding,delta_of FROM note_blobs ORDER BY hash").all();
10
+ const versions = () => db.prepare("SELECT note_id,version_ix,content_hash,path,metadata,extension,superseded_at,actor,via,op,content_len,encoding,created_at FROM note_versions ORDER BY note_id,version_ix").all();
11
+ async function seed(count = 12, size = 20000, label = "note") {
12
+ const body = (label + " abcdefghijklmnop\n").repeat(Math.ceil(size / (label.length + 18))).slice(0, size);
13
+ const n = await store.createNote(body, { path: label, created_at: "2001-01-01T00:00:00.000Z" });
14
+ for (let i = 0; i < count; i++)
15
+ await store.updateNote(n.id, { append: `\nedit ${i}` });
16
+ return n;
17
+ }
18
+ function randomHex(size: number) { const bytes = new Uint8Array(size / 2); for (let i = 0; i < bytes.length; i += 65536)
19
+ crypto.getRandomValues(bytes.subarray(i, i + 65536)); return Buffer.from(bytes).toString("hex"); }
20
+ async function map(id: string) { return Promise.all((await store.listNoteVersions(id, { limit: 1000 })).map(async (v) => [v.version_ix, (await store.getNoteVersion(id, v.version_ix))!.content])); }
21
+ test("P3/P8 stars roll over, preserve all bodies and rerun idempotently", async () => {
22
+ const n = await seed(50);
23
+ const before = await map(n.id);
24
+ expect(store.compactNote(n.id).blobs_deltified).toBeGreaterThanOrEqual(45);
25
+ expect(h.historyStorageStats(db).whole_blobs).toBeLessThanOrEqual(3);
26
+ expect(h.historyStorageStats(db).whole_blobs).toBeGreaterThanOrEqual(1);
27
+ expect(h.historyStorageStats(db).orphan_deltas).toBe(0);
28
+ expect(await map(n.id)).toEqual(before);
29
+ const after = blobs();
30
+ expect(store.compactNote(n.id).blobs_deltified).toBe(0);
31
+ expect(blobs()).toEqual(after);
32
+ });
33
+ test("P4/P5 GC removes orphan deltas, protects bases, and drains two waves", async () => {
34
+ const n = await seed();
35
+ store.compactNote(n.id);
36
+ const first = db.prepare("SELECT v.version_ix,b.hash,b.delta_of FROM note_versions v JOIN note_blobs b ON b.hash=v.content_hash WHERE b.encoding='fossil-delta' ORDER BY v.version_ix LIMIT 1").get() as any;
37
+ expect(() => db.prepare("DELETE FROM note_blobs WHERE hash=?").run(first.delta_of)).toThrow("FOREIGN KEY");
38
+ db.prepare("DELETE FROM note_versions WHERE note_id=? AND version_ix=?").run(n.id, first.version_ix);
39
+ h.gcBlobs(db);
40
+ expect(db.prepare("SELECT hash FROM note_blobs WHERE hash=?").get(first.hash)).toBeNull();
41
+ expect(db.prepare("SELECT hash FROM note_blobs WHERE hash=?").get(first.delta_of)).not.toBeNull();
42
+ h.eraseHistory(db, n.id);
43
+ expect(blobs()).toEqual([]);
44
+ });
45
+ test("P4 shared base survives erasure of another note", async () => {
46
+ const n = await seed();
47
+ const base = (await store.listNoteVersions(n.id))[0]!;
48
+ const text = (await store.getNoteVersion(n.id, base.version_ix))!.content!;
49
+ const other = await store.createNote(text);
50
+ await store.updateNote(other.id, { append: "other" });
51
+ store.compactNote(n.id);
52
+ h.eraseHistory(db, n.id);
53
+ expect(h.readBlobContent(db, base.content_hash!)).toBe(text);
54
+ expect((await store.getNoteVersion(other.id, 0))!.content).toBe(text);
55
+ });
56
+ test("P6 triggers and off switch leave blobs unchanged; writes never compact", async () => {
57
+ const n = await seed(9);
58
+ let before = blobs();
59
+ expect(store.compactNote(n.id).blobs_deltified).toBe(0);
60
+ expect(blobs()).toEqual(before);
61
+ expect(h.compactNote(db, n.id, h.resolveHistoryPolicy({ compact_min_versions: 2, compact_ratio: 100 })).blobs_deltified).toBe(0);
62
+ expect(blobs()).toEqual(before);
63
+ const large = await seed(20, 20000, "off");
64
+ before = blobs();
65
+ expect(h.compactNote(db, large.id, h.resolveHistoryPolicy({ compact_enabled: false, max_bytes_per_note: 65536, min_versions: 1 })).versions_dropped).toBe(0);
66
+ expect(blobs()).toEqual(before);
67
+ for (let i = 0; i < 200; i++)
68
+ await store.updateNote(n.id, { append: `ordinary ${i}` });
69
+ expect(db.prepare("SELECT hash FROM note_blobs WHERE encoding IS NOT NULL").all()).toEqual([]);
70
+ // A live empty body has size zero, not the newest retained body's size.
71
+ const emptyDb = new Database(":memory:");
72
+ try {
73
+ const emptyStore = new BunSqliteStore(emptyDb, { history: { compact_min_versions: 2 } });
74
+ const empty = await emptyStore.createNote("empty fixture".repeat(2000));
75
+ await emptyStore.updateNote(empty.id, { append: "one" });
76
+ await emptyStore.updateNote(empty.id, { content: "" });
77
+ expect(emptyStore.compactHistory({ budgetMs: null, maxNotes: null }).notes_compacted).toBe(1);
78
+ } finally { emptyDb.close(); }
79
+ });
80
+ test("P7 third encode failure rolls back all changes", async () => {
81
+ const n = await seed();
82
+ const b = blobs(), v = versions();
83
+ expect(codec).not.toBeNull();
84
+ const original = codec!.encodeDelta;
85
+ let count = 0;
86
+ const mock = spyOn(codec!, "encodeDelta").mockImplementation((a, b) => { if (++count === 3)
87
+ throw Error("injected"); return original(a, b); });
88
+ try {
89
+ expect(() => store.compactNote(n.id)).toThrow("injected");
90
+ expect(blobs()).toEqual(b);
91
+ expect(versions()).toEqual(v);
92
+ }
93
+ finally {
94
+ mock.mockRestore();
95
+ }
96
+ expect(store.compactNote(n.id).blobs_deltified).toBeGreaterThan(0);
97
+ });
98
+ test("P8 bounded passes resume and converge; unbounded visits all", async () => {
99
+ for (let i = 0; i < 3; i++)
100
+ await seed(12, 20000, `note${i}`);
101
+ for (let left = 2; left >= 0; left--) {
102
+ const r = store.compactHistory({ maxNotes: 1, budgetMs: null });
103
+ expect(r.notes_compacted).toBe(1);
104
+ expect(r.remaining_candidates).toBe(left);
105
+ expect(r.stopped_by).toBe(left ? "max_notes" : "complete");
106
+ }
107
+ expect(store.compactHistory({ maxNotes: null, budgetMs: null }).notes_scanned).toBe(0);
108
+ for (let i = 0; i < 3; i++)
109
+ await seed(12, 20000, `more${i}`);
110
+ const budget = store.compactHistory({ budgetMs: 1, maxNotes: null });
111
+ expect(budget.stopped_by).toBe("budget");
112
+ expect(budget.notes_scanned).toBe(1);
113
+ expect(budget.notes_compacted).toBe(1);
114
+ const all = store.compactHistory({ budgetMs: null, maxNotes: null });
115
+ expect(all.stopped_by).toBe("complete");
116
+ expect(all.remaining_candidates).toBe(0);
117
+ });
118
+ test("P16/P8 incompressible candidates are refused on every pass", async () => {
119
+ const bodies = Array.from({ length: 5 }, () => randomHex(20480));
120
+ expect(codec).not.toBeNull();
121
+ expect(codec!.encodeDelta(bodies[1]!, bodies[0]!).length).toBeGreaterThanOrEqual(20480 * .9);
122
+ const n = await store.createNote(bodies[0]!);
123
+ for (let i = 1; i <= 5; i++)
124
+ await store.updateNote(n.id, { content: bodies[i % 5]! });
125
+ const p = h.resolveHistoryPolicy({ compact_min_versions: 2, compact_ratio: 1 });
126
+ const before = blobs();
127
+ for (let i = 0; i < 2; i++) {
128
+ const result = h.compactNote(db, n.id, p);
129
+ expect(result.blobs_deltified).toBe(0);
130
+ expect(result.blobs_skipped_too_large).toBeGreaterThanOrEqual(1);
131
+ expect(blobs()).toEqual(before);
132
+ expect(h.compactVault(db, p, { budgetMs: null, maxNotes: null })).toMatchObject({ notes_scanned: 1, stopped_by: "complete" });
133
+ }
134
+ const twin = await seed();
135
+ expect(store.compactNote(twin.id)).toMatchObject({ blobs_skipped_too_large: 0 });
136
+ expect(h.historyStorageStats(db).delta_blobs).toBeGreaterThan(0);
137
+ });
138
+ test("P14 overflow rows stay unchanged during compaction", async () => {
139
+ const n = await store.createNote("x".repeat(h.VERSION_MAX_BYTES + 1));
140
+ await store.deleteNote(n.id);
141
+ const before = await store.listNoteVersions(n.id);
142
+ expect(before[0]!.created_at).toBe(n.createdAt);
143
+ await seed();
144
+ store.compactHistory({ budgetMs: null, maxNotes: null });
145
+ expect(await store.listNoteVersions(n.id)).toEqual(before);
146
+ expect(await store.getNoteVersion(n.id, 0)).toMatchObject({ content: null, encoding: "overflow" });
147
+ });
148
+ test("P15 compaction precedes ceiling, floor includes protected tombstone", async () => {
149
+ const n = await seed(40, 50000);
150
+ expect(h.compactNote(db, n.id, h.resolveHistoryPolicy({ max_bytes_per_note: 300000 })).versions_dropped).toBe(0);
151
+ expect(h.noteHistoryBytes(db, n.id)).toBeLessThan(300000);
152
+ // A random corpus cannot shrink below the floor's 20 whole bodies.
153
+ const q = await store.createNote(randomHex(50000));
154
+ for (let i = 0; i < 40; i++)
155
+ await store.updateNote(q.id, { content: randomHex(50000) });
156
+ await store.deleteNote(q.id);
157
+ const r = h.compactNote(db, q.id, h.resolveHistoryPolicy({ max_bytes_per_note: 65536 }));
158
+ expect(r.versions_dropped).toBe(21);
159
+ const remaining = await store.listNoteVersions(q.id);
160
+ expect(remaining).toHaveLength(20);
161
+ expect(remaining.filter(v => v.op === "delete")).toHaveLength(1);
162
+ expect(Math.min(...remaining.map(v => v.version_ix))).toBe(21);
163
+ expect(h.noteHistoryBytes(db, q.id)).toBeGreaterThan(65536);
164
+ for (const v of remaining)
165
+ expect((await store.getNoteVersion(q.id, v.version_ix))!.content).not.toBeNull();
166
+ });
167
+ test("P15 over-byte trigger works below minimum count and null disables dropping", async () => {
168
+ const n = await seed(5, 1999000);
169
+ expect(store.compactNote(n.id).blobs_deltified).toBeGreaterThan(0);
170
+ expect(h.noteHistoryBytes(db, n.id)).toBeLessThan(8388608);
171
+ const before = await store.countNoteVersions(n.id);
172
+ h.compactNote(db, n.id, h.resolveHistoryPolicy({ max_bytes_per_note: null }));
173
+ expect(await store.countNoteVersions(n.id)).toBe(before);
174
+ });
175
+ test("P15 attribution drops while indirect base remains on disk", async () => {
176
+ const n = await seed();
177
+ store.compactNote(n.id);
178
+ const base = db.prepare("SELECT delta_of FROM note_blobs WHERE encoding='fossil-delta' LIMIT 1").get() as any;
179
+ const before = h.noteHistoryBytes(db, n.id), count = blobs().length;
180
+ db.prepare("DELETE FROM note_versions WHERE note_id=? AND content_hash=?").run(n.id, base.delta_of);
181
+ h.gcBlobs(db);
182
+ expect(h.noteHistoryBytes(db, n.id)).toBeLessThan(before);
183
+ expect(blobs()).toHaveLength(count);
184
+ });
185
+ test("P17 creation time capture, restore marker and pre-v30 fallback", async () => {
186
+ const n = await seed(2);
187
+ await store.deleteNote(n.id);
188
+ expect((await store.listNoteVersions(n.id)).every(v => v.created_at === n.createdAt)).toBe(true);
189
+ expect((await store.restoreNoteVersion(n.id, 0, {})).createdAt).toBe(n.createdAt);
190
+ expect((await store.listNoteVersions(n.id))[0]!.created_at).toBe(n.createdAt);
191
+ await store.deleteNote(n.id);
192
+ const tomb = (await store.listNoteVersions(n.id))[0]!;
193
+ db.prepare("UPDATE note_versions SET created_at=NULL WHERE note_id=?").run(n.id);
194
+ expect((await store.restoreNoteVersion(n.id, 0, {})).createdAt).toBe(tomb.superseded_at);
195
+ });
196
+ test("P18/P19 counts, clamps and defaults", async () => {
197
+ const n = await seed(7);
198
+ expect(await store.countNoteVersions(n.id)).toBe(7);
199
+ expect(await store.countNoteVersions("absent")).toBe(0);
200
+ expect(h.resolveHistoryPolicy({ compact_ratio: 0, compact_run_length: 500, max_bytes_per_note: 10 })).toMatchObject({ compact_ratio: 1, compact_run_length: 100, max_bytes_per_note: 65536, compact_budget_ms: 250, compact_max_notes: 50 });
201
+ expect(h.resolveHistoryPolicy({ max_bytes_per_note: null }).max_bytes_per_note).toBeNull();
202
+ });
203
+ test("P20 oldest 116KB version reads p95 under 50ms", async () => {
204
+ const n = await seed(24, 116000);
205
+ store.compactNote(n.id);
206
+ const times: number[] = [];
207
+ for (let i = 0; i < 40; i++) {
208
+ const t = performance.now();
209
+ expect((await store.getNoteVersion(n.id, 0))!.content).toBe(n.content);
210
+ times.push(performance.now() - t);
211
+ }
212
+ times.sort((a, b) => a - b);
213
+ console.log("P20 read latency", { p50: times[20], p95: times[38], max: times[39] });
214
+ expect(times[38]!).toBeLessThan(50);
215
+ });
216
+ test("P23 corrupt chain cannot rewrite a healthy whole; vault continues", async () => {
217
+ const n = await seed(12), good = await seed(12, 20000, "good");
218
+ const rows = await store.listNoteVersions(n.id);
219
+ expect(codec).not.toBeNull();
220
+ const [r, d, w] = rows;
221
+ const content = (hash: string) => (db.prepare("SELECT content FROM note_blobs WHERE hash=?").get(hash) as any).content as string;
222
+ const dt = content(d!.content_hash!), wt = content(w!.content_hash!), rt = content(r!.content_hash!);
223
+ db.prepare("UPDATE note_blobs SET content=?,encoding='fossil-delta',delta_of=? WHERE hash=?").run(codec!.encodeDelta(wt, dt), w!.content_hash, d!.content_hash);
224
+ db.prepare("UPDATE note_blobs SET content=?,encoding='fossil-delta',delta_of=? WHERE hash=?").run(codec!.encodeDelta(dt, rt), d!.content_hash, r!.content_hash);
225
+ const before = blobs();
226
+ expect(() => store.compactNote(n.id)).toThrow("base_not_whole");
227
+ expect(blobs()).toEqual(before);
228
+ expect(store.compactHistory({ maxNotes: null, budgetMs: null })).toMatchObject({ notes_failed: 1, notes_compacted: 1 });
229
+ expect((await store.getNoteVersion(good.id, 0))!.content).toBe(good.content);
230
+ });
231
+
232
+ test("P10(i)/P21(f) explicit IDs and guard precedence cannot widen or stall a pass", async () => {
233
+ for (let i = 0; i < 3; i++) await seed(12, 20000, `guard${i}`);
234
+ const before = blobs();
235
+ expect(store.compactHistory({ noteId: "", budgetMs: null, maxNotes: null })).toMatchObject({ notes_scanned: 1, notes_compacted: 0 });
236
+ expect(blobs()).toEqual(before);
237
+ expect(store.compactHistory({ budgetMs: 0, maxNotes: 0 }).stopped_by).toBe("max_notes");
238
+ const clock = spyOn(performance, "now");
239
+ let tick = 0;
240
+ clock.mockImplementation(() => (tick += 10));
241
+ try {
242
+ expect(store.compactHistory({ budgetMs: 1, maxNotes: 1 })).toMatchObject({ notes_scanned: 1, notes_compacted: 1, stopped_by: "max_notes" });
243
+ expect(store.compactHistory({ budgetMs: 1, maxNotes: null })).toMatchObject({ notes_scanned: 1, notes_compacted: 1, stopped_by: "budget" });
244
+ } finally { clock.mockRestore(); }
245
+ const empty = new Database(":memory:");
246
+ try { expect(h.compactVault(empty, h.resolveHistoryPolicy(), { budgetMs: 0, maxNotes: 0 }).stopped_by).toBe("disabled"); }
247
+ finally { empty.close(); }
248
+ });
@@ -0,0 +1,49 @@
1
+ import { test, expect } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ const codec = await import("./delta.js").catch(() => null);
4
+ function randomHex(size: number) {
5
+ const bytes = new Uint8Array(size / 2);
6
+ for (let i = 0; i < bytes.length; i += 65536)
7
+ crypto.getRandomValues(bytes.subarray(i, i + 65536));
8
+ return Buffer.from(bytes).toString("hex");
9
+ }
10
+ for (const [name, seed] of [["ASCII", "hello world"], ["CJK", "日本語"], ["emoji", "🐢 café naïve"], ["combining", "école résumé"], ["high Latin", "ÿþý"]]) {
11
+ test(`P1 ${name} mid-body roundtrip`, () => {
12
+ expect(codec).not.toBeNull();
13
+ const lines = Array.from({ length: 500 }, (_, i) => `${i}: ${seed}`);
14
+ const base = lines.join("\n");
15
+ lines[200] = "new 🐙 line";
16
+ lines.splice(300, 0, "inserted");
17
+ const target = lines.join("\n"), payload = codec!.encodeDelta(base, target);
18
+ expect(payload).toMatch(/^[A-Za-z0-9+/=]*$/);
19
+ expect(codec!.decodeDelta(base, payload)).toBe(target);
20
+ });
21
+ }
22
+ test("P1 large independent bodies exercise a large delta without spread", () => {
23
+ expect(codec).not.toBeNull();
24
+ const base = randomHex(2000000), target = randomHex(2000000);
25
+ const payload = codec!.encodeDelta(base, target);
26
+ expect(atob(payload).length).toBeGreaterThanOrEqual(1000000);
27
+ expect(codec!.decodeDelta(base, payload)).toBe(target);
28
+ });
29
+ test("P1 identity is small and corrupted checksum / copied base throws", () => {
30
+ expect(codec).not.toBeNull();
31
+ const base = "unique numbered line\n".repeat(500);
32
+ expect(codec!.encodeDelta(base, base).length).toBeLessThan(32);
33
+ const payload = codec!.encodeDelta(base, base + "tail");
34
+ const bytes = Buffer.from(payload, "base64");
35
+ // Alter a checksum digit, not a framing byte.
36
+ bytes[bytes.length - 2] = bytes[bytes.length - 2] === 48 ? 49 : 48;
37
+ expect(() => codec!.decodeDelta(base, bytes.toString("base64"))).toThrow("bad checksum");
38
+ expect(() => codec!.decodeDelta(base.replaceAll("u", "X"), payload)).toThrow("bad checksum");
39
+ });
40
+ test("P2 vendor provenance and removed string surface", () => {
41
+ expect(codec).not.toBeNull();
42
+ for (const key of ["createStringDelta", "applyStringDelta", "getStringDeltaTargetSize"])
43
+ expect(key in codec!).toBe(false);
44
+ const source = readFileSync(new URL("./vendor/fossil-delta.ts", import.meta.url), "utf8");
45
+ expect(source.startsWith("// @" + "ts-nocheck\n")).toBe(true);
46
+ expect(source).toContain("BSD 2-Clause");
47
+ expect(source).toContain("29d795837d08ff9305e145d20611e721e52aa29969c10c39c8f9d3d51be67b6c");
48
+ expect(source).toContain("sha512-rcvnd0xjV7KNkEbXhTCsjHhONvdami+WWhTYGf4ORkPk9ixkQDmVeII96F6wBD/6qa9zBjH15bOzSARKsQ/rKg==");
49
+ });
@@ -0,0 +1,21 @@
1
+ import { createDelta, applyDelta } from "./vendor/fossil-delta.js";
2
+ /** Encode UTF-8 text as a base64 byte delta, without an argument-sized spread. */
3
+ export function encodeDelta(base: string, target: string): string {
4
+ const bytes = createDelta(new TextEncoder().encode(base), new TextEncoder().encode(target));
5
+ return toBase64(bytes);
6
+ }
7
+ function toBase64(bytes: Uint8Array | number[]): string {
8
+ const chunks: string[] = [];
9
+ for (let start = 0; start < bytes.length; start += 8192) {
10
+ let chunk = "";
11
+ for (let i = start; i < Math.min(start + 8192, bytes.length); i++)
12
+ chunk += String.fromCharCode(bytes[i]!);
13
+ chunks.push(chunk);
14
+ }
15
+ return btoa(chunks.join(""));
16
+ }
17
+ /** Reconstruct exactly one delta; checksum verification remains enabled. */
18
+ export function decodeDelta(base: string, payload: string): string {
19
+ const bytes = Uint8Array.from(atob(payload), c => c.charCodeAt(0));
20
+ return new TextDecoder().decode(new Uint8Array(applyDelta(new TextEncoder().encode(base), bytes)));
21
+ }
@@ -47,6 +47,7 @@
47
47
  * out-of-scope activity.
48
48
  */
49
49
 
50
+ import { historyTablesPresent, deletedHistoryStats, historyStorageStats, topNotesByHistoryBytes } from "./history.js";
50
51
  import { Database } from "bun:sqlite";
51
52
  import { loadTagHierarchy, findHierarchyCycles } from "./tag-hierarchy.js";
52
53
  import { listIndexedFields } from "./indexed-fields.js";
@@ -58,7 +59,10 @@ export type DoctorFindingType =
58
59
  | "parent_names_cycle"
59
60
  | "mixed_type_indexed_field"
60
61
  | "orphaned_indexed_field_declarer"
61
- | "dead_tag_metadata_reference";
62
+ | "dead_tag_metadata_reference"
63
+ | "deleted_note_history"
64
+ | "history_storage"
65
+ | "history_delta_orphan";
62
66
 
63
67
  export type DoctorSeverity = "error" | "warning" | "info";
64
68
 
@@ -126,6 +130,9 @@ export function runDoctorScan(db: Database, opts?: DoctorScanOpts): DoctorReport
126
130
  ...scanMixedTypeIndexedFields(db, allowedTags),
127
131
  ...scanOrphanedIndexedFieldDeclarers(db, allowedTags),
128
132
  ...scanDeadTagMetadataReferences(db, allowedTags),
133
+ ...(allowedTags === null ? scanDeletedNoteHistory(db) : []),
134
+ ...(allowedTags === null ? scanHistoryStorage(db) : []),
135
+ ...(allowedTags === null ? scanDeltaOrphans(db) : []),
129
136
  ];
130
137
 
131
138
  const errors = findings.filter((f) => f.severity === "error").length;
@@ -380,3 +387,36 @@ function scanDeadTagMetadataReferences(db: Database, allowedTags: Set<string> |
380
387
  }
381
388
  return findings;
382
389
  }
390
+
391
+ /** Deleted rows have no tags to authorize against; callers omit this for scoped sessions. */
392
+ function scanDeletedNoteHistory(db: Database): DoctorFinding[] {
393
+ if (!historyTablesPresent(db)) return [];
394
+ const stats = deletedHistoryStats(db);
395
+ if (stats.versions === 0) return [];
396
+ return [{ type: "deleted_note_history", severity: "info",
397
+ subject: `${stats.notes} deleted note(s)`,
398
+ detail: `${stats.versions} version row(s) retained for notes that no longer exist, holding ~${stats.bytes} bytes of blob content. Blobs shared with a live note's history are counted here as well, so this is an upper bound, not an exact figure.`
399
+ + (stats.overflow_tombstones > 0 ? ` ${stats.overflow_tombstones} of these are overflow tombstone(s): the note exceeded the 2 MB version ceiling, so its deletion, size and metadata were recorded but its content was not and cannot be restored.` : ``),
400
+ remedy: "Set `history.deleted_retention_days` in the vault's vault.yaml to sweep these automatically (null — the default — keeps them until an explicit erase), or erase one note's history permanently with DELETE /api/notes/<id>/versions.",
401
+ }];
402
+ }
403
+
404
+ function scanHistoryStorage(db: Database): DoctorFinding[] {
405
+ if (!historyTablesPresent(db)) return [];
406
+ const s = historyStorageStats(db);
407
+ if (s.whole_blobs + s.delta_blobs === 0) return [];
408
+ const top = topNotesByHistoryBytes(db, 5);
409
+ return [{ type: "history_storage", severity: "info",
410
+ subject: `${s.whole_blobs + s.delta_blobs} history blob(s)`,
411
+ detail: `${s.whole_bytes} byte(s) stored whole across ${s.whole_blobs} blob(s) and ${s.delta_bytes} byte(s) stored as fossil deltas across ${s.delta_blobs} blob(s). Largest histories by stored bytes: ${top.map(t => `${t.note_id} (${t.bytes} bytes, ${t.versions} versions)`).join(", ")}.`,
412
+ remedy: "Run POST /api/history/compact (vault:admin) to compact now, or set history.max_bytes_per_note in the vault's vault.yaml to bound per-note history. Turning history.compact_enabled off also stops max_bytes_per_note being enforced." }];
413
+ }
414
+ function scanDeltaOrphans(db: Database): DoctorFinding[] {
415
+ if (!historyTablesPresent(db)) return [];
416
+ const s = historyStorageStats(db);
417
+ if (s.orphan_deltas + s.unknown_encoding_blobs === 0) return [];
418
+ return [{ type: "history_delta_orphan", severity: "error",
419
+ subject: `${s.orphan_deltas + s.unknown_encoding_blobs} unrecoverable history blob(s)`,
420
+ detail: `${s.orphan_deltas} blob(s) are stored as fossil deltas whose base blob is missing or is itself a delta, and ${s.unknown_encoding_blobs} carry an encoding this version does not know how to read, so the versions referencing them cannot be reconstructed. Reading one answers 409 history_unrecoverable; listing versions still works.`,
421
+ remedy: "Erase the affected note's history with DELETE /api/notes/<id>/versions, or restore the vault database from backup. This is a corruption finding, not a retention setting." }];
422
+ }
@@ -0,0 +1,244 @@
1
+ import { it, expect, beforeEach, afterEach } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { BunSqliteStore } from "./store.js";
7
+ import {
8
+ exportVaultToDir,
9
+ FsImportSource,
10
+ importVault,
11
+ } from "./portable-md.js";
12
+ let db: Database, store: BunSqliteStore;
13
+ beforeEach(() => {
14
+ db = new Database(":memory:");
15
+ store = new BunSqliteStore(db);
16
+ });
17
+ afterEach(() => db.close());
18
+ const count = (table: "note_versions" | "note_blobs") =>
19
+ (db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;
20
+ for (const leg of ["path", "transition"] as const)
21
+ it(`P1 rollback after ${leg} rejection leaves no phantom history`, async () => {
22
+ const n = await store.createNote("original", {
23
+ path: "a",
24
+ metadata: { status: "a" },
25
+ });
26
+ await store.createNote("occupied", { path: "b" });
27
+ const before = count("note_blobs");
28
+ if (leg === "path")
29
+ await expect(
30
+ store.updateNote(n.id, { content: "bad", path: "b" }),
31
+ ).rejects.toMatchObject({ code: "PATH_CONFLICT" });
32
+ else
33
+ await expect(
34
+ store.updateNote(n.id, {
35
+ content: "bad",
36
+ state_transition: { field: "status", from: "wrong", to: "b" },
37
+ }),
38
+ ).rejects.toMatchObject({ code: "TRANSITION_CONFLICT" });
39
+ expect(count("note_versions")).toBe(0);
40
+ expect(count("note_blobs")).toBe(before);
41
+ expect((await store.getNote(n.id))!.content).toBe("original");
42
+ });
43
+ it("P4 sequential append/prepend preserve reconstructable prior bodies", async () => {
44
+ const content = "---\ntitle: T\n---\nbody\n";
45
+ const n = await store.createNote(content);
46
+ await store.updateNote(n.id, { append: "a\n" });
47
+ await store.updateNote(n.id, { prepend: "p\n" });
48
+ const rows = await store.listNoteVersions(n.id);
49
+ expect(rows).toHaveLength(2);
50
+ expect(rows.map((v) => v.version_ix)).toEqual([1, 0]);
51
+ expect(rows.map((v) => v.op)).toEqual(["prepend", "append"]);
52
+ expect(rows[0]!.content_hash).not.toBe(rows[1]!.content_hash);
53
+ expect((await store.getNoteVersion(n.id, 0))!.content + "a\n").toBe(
54
+ (await store.getNoteVersion(n.id, 1))!.content!,
55
+ );
56
+ expect((await store.getNote(n.id))!.content).toBe(
57
+ "---\ntitle: T\n---\np\nbody\na\n",
58
+ );
59
+ // This pin makes no concurrency claim: synchronous transactions serialize these calls.
60
+ });
61
+ it("P5 live restore captures the prior body and preserves the source version", async () => {
62
+ const n = await store.createNote("0", { path: "original" });
63
+ for (let i = 1; i <= 5; i++)
64
+ await store.updateNote(n.id, { content: String(i) });
65
+ const source = await store.getNoteVersion(n.id, 1),
66
+ before = await store.getNote(n.id);
67
+ const r = await store.restoreNoteVersion(n.id, 1, {
68
+ if_updated_at: before!.updatedAt!,
69
+ actor: "restorer",
70
+ via: "api",
71
+ });
72
+ expect(r.content).toBe(source!.content!);
73
+ expect(r.updatedAt! > before!.updatedAt!).toBe(true);
74
+ expect(r.lastUpdatedBy).toBe("restorer");
75
+ const rows = await store.listNoteVersions(n.id);
76
+ expect(rows).toHaveLength(6);
77
+ expect(rows[0]!.op).toBe("restore");
78
+ expect((await store.getNoteVersion(n.id, rows[0]!.version_ix))!.content).toBe(
79
+ "5",
80
+ );
81
+ expect(await store.getNoteVersion(n.id, 1)).toEqual(source);
82
+ });
83
+ it("P5 restore reads its source before a one-version prune", async () => {
84
+ store = new BunSqliteStore(db, {
85
+ history: { min_versions: 1, max_versions: 1 },
86
+ });
87
+ const n = await store.createNote("one");
88
+ await store.updateNote(n.id, { content: "two" });
89
+ expect((await store.restoreNoteVersion(n.id, 0, {})).content).toBe("one");
90
+ expect(await store.listNoteVersions(n.id)).toHaveLength(1);
91
+ });
92
+ it("P8a tag rename captures only rewritten notes", async () => {
93
+ await store.upsertTagRecord("old", {});
94
+ const a = await store.createNote("#old a"),
95
+ b = await store.createNote("#old b"),
96
+ c = await store.createNote("untouched");
97
+ await store.renameTag("old", "new");
98
+ for (const n of [a, b])
99
+ expect(await store.getNoteVersion(n.id, 0)).toMatchObject({
100
+ op: "tag-rename",
101
+ content: n.content,
102
+ });
103
+ expect(await store.listNoteVersions(c.id)).toHaveLength(0);
104
+ });
105
+ it("P8b path rename captures the source brackets and renamed note", async () => {
106
+ const b = await store.createNote("B", { path: "B" }),
107
+ a = await store.createNote("see [[B]]", { path: "A" });
108
+ await store.updateNote(b.id, { path: "C", actor: "renamer", via: "api" });
109
+ expect(await store.getNoteVersion(a.id, 0)).toMatchObject({
110
+ content: "see [[B]]",
111
+ op: "cascade-rename",
112
+ actor: "renamer",
113
+ via: "api",
114
+ });
115
+ expect(await store.getNoteVersion(b.id, 0)).toMatchObject({
116
+ path: "B",
117
+ op: "update",
118
+ });
119
+ });
120
+ it("P9 skipUpdatedAt metadata writes still capture with NULL attribution", async () => {
121
+ const n = await store.createNote("body");
122
+ const r = await store.updateNote(n.id, {
123
+ metadata: { x: 1 },
124
+ skipUpdatedAt: true,
125
+ });
126
+ expect(r.updatedAt).toBe(n.updatedAt);
127
+ expect(await store.listNoteVersions(n.id)).toHaveLength(1);
128
+ expect(await store.getNoteVersion(n.id, 0)).toMatchObject({
129
+ actor: null,
130
+ via: null,
131
+ op: "update",
132
+ metadata: {},
133
+ });
134
+ });
135
+ it("P10 deletion captures final state; blow-away opt-out does not", async () => {
136
+ const a = await store.createNote("final"),
137
+ b = await store.createNote("skip");
138
+ await store.deleteNote(a.id, { actor: "a", via: "v" });
139
+ await store.deleteNote(b.id, { captureHistory: false });
140
+ expect(await store.getNote(a.id)).toBeNull();
141
+ expect(await store.getNoteVersion(a.id, 0)).toMatchObject({
142
+ content: "final",
143
+ op: "delete",
144
+ actor: "a",
145
+ via: "v",
146
+ });
147
+ expect(await store.listNoteVersions(b.id)).toHaveLength(0);
148
+ });
149
+ it("P11b skipUpdatedAt transition reaches the mutating-key guard", async () => {
150
+ const n = await store.createNote("body", { metadata: { status: "a" } });
151
+ const r = await store.updateNote(n.id, {
152
+ state_transition: { field: "status", from: "a", to: "b" },
153
+ skipUpdatedAt: true,
154
+ });
155
+ expect(r.updatedAt).toBe(n.updatedAt);
156
+ expect(await store.listNoteVersions(n.id)).toHaveLength(1);
157
+ expect(await store.getNoteVersion(n.id, 0)).toMatchObject({
158
+ metadata: { status: "a" },
159
+ });
160
+ });
161
+ it("P12 blow-away round trip reattaches the existing version chains", async () => {
162
+ const outDir = mkdtempSync(join(tmpdir(), "history-import-"));
163
+ try {
164
+ const notes = [];
165
+ for (let i = 0; i < 3; i++) {
166
+ const n = await store.createNote("a", { path: `p${i}` });
167
+ await store.updateNote(n.id, { content: "b" });
168
+ await store.updateNote(n.id, { content: "c" });
169
+ notes.push(n);
170
+ }
171
+ const before = new Map(
172
+ await Promise.all(
173
+ notes.map(
174
+ async (n) => [n.id, await store.listNoteVersions(n.id)] as const,
175
+ ),
176
+ ),
177
+ );
178
+ await exportVaultToDir(store, { outDir, vaultName: "history" });
179
+ const stats = await importVault(
180
+ store,
181
+ new FsImportSource({ inDir: outDir }),
182
+ { blowAway: true },
183
+ );
184
+ expect(stats.notes_wiped).toBe(3);
185
+ expect(stats.notes_created).toBe(3);
186
+ expect(stats.notes_updated).toBe(0);
187
+ expect(
188
+ (
189
+ db
190
+ .prepare("SELECT COUNT(*) AS n FROM note_versions WHERE op='delete'")
191
+ .get() as { n: number }
192
+ ).n,
193
+ ).toBe(0);
194
+ // Replay recreates these IDs, with zero update-branch upserts, so MAX advances by zero.
195
+ for (const n of notes)
196
+ expect(await store.listNoteVersions(n.id)).toEqual(before.get(n.id)!);
197
+ for (const n of notes) await store.deleteNote(n.id);
198
+ expect(
199
+ (
200
+ db
201
+ .prepare("SELECT COUNT(*) AS n FROM note_versions WHERE op='delete'")
202
+ .get() as { n: number }
203
+ ).n,
204
+ ).toBe(3);
205
+ } finally {
206
+ rmSync(outDir, { recursive: true, force: true });
207
+ }
208
+ });
209
+ it("P20 re-create uses tombstone path/time, selected metadata, no tags", async () => {
210
+ const n = await store.createNote("one", {
211
+ path: "p",
212
+ tags: ["work"],
213
+ metadata: { k: 1 },
214
+ });
215
+ await store.updateNote(n.id, { content: "two", metadata: { k: 2 } });
216
+ await store.deleteNote(n.id, { actor: "a" });
217
+ const tomb = (await store.listNoteVersions(n.id))[0]!;
218
+ const r = await store.restoreNoteVersion(n.id, 0, {});
219
+ expect(r).toMatchObject({
220
+ id: n.id,
221
+ path: "p",
222
+ content: "one",
223
+ metadata: { k: 1 },
224
+ tags: [],
225
+ createdAt: n.createdAt,
226
+ });
227
+ const rows = await store.listNoteVersions(n.id);
228
+ expect(rows[0]).toMatchObject({
229
+ op: "restore",
230
+ content_hash: tomb.content_hash,
231
+ });
232
+ expect(rows.some((v) => v.op === "delete")).toBe(true);
233
+ });
234
+ it("P20d path collision rolls back re-creation and its marker", async () => {
235
+ const n = await store.createNote("one", { path: "p" });
236
+ await store.deleteNote(n.id);
237
+ await store.createNote("other", { path: "p" });
238
+ const before = await store.listNoteVersions(n.id);
239
+ await expect(store.restoreNoteVersion(n.id, 0, {})).rejects.toMatchObject({
240
+ code: "PATH_CONFLICT",
241
+ });
242
+ expect(await store.getNote(n.id)).toBeNull();
243
+ expect(await store.listNoteVersions(n.id)).toEqual(before);
244
+ });