@openparachute/vault 0.7.9-rc.4 → 0.7.9-rc.6

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 (40) hide show
  1. package/README.md +4 -0
  2. package/core/src/compact.test.ts +248 -0
  3. package/core/src/delta.test.ts +49 -0
  4. package/core/src/delta.ts +21 -0
  5. package/core/src/doctor.ts +41 -1
  6. package/core/src/history-capture.test.ts +244 -0
  7. package/core/src/history-import.test.ts +82 -0
  8. package/core/src/history-import.ts +157 -0
  9. package/core/src/history.test.ts +229 -0
  10. package/core/src/history.ts +681 -0
  11. package/core/src/mcp-manifest.ts +17 -0
  12. package/core/src/mcp.ts +28 -3
  13. package/core/src/notes.ts +6 -1
  14. package/core/src/portable-md.ts +1 -1
  15. package/core/src/schema-v29-note-versions.test.ts +63 -0
  16. package/core/src/schema-v30-note-deltas.test.ts +51 -0
  17. package/core/src/schema-v31-history-import.test.ts +25 -0
  18. package/core/src/schema.ts +234 -1
  19. package/core/src/store.ts +103 -14
  20. package/core/src/types.ts +35 -2
  21. package/core/src/vendor/fossil-delta.ts +472 -0
  22. package/package.json +1 -1
  23. package/src/cli.ts +10 -1
  24. package/src/config.ts +42 -0
  25. package/src/history-import-mcp.test.ts +94 -0
  26. package/src/history-import-routes.test.ts +91 -0
  27. package/src/history-import.test.ts +318 -0
  28. package/src/history-import.ts +482 -0
  29. package/src/mcp-http.ts +13 -0
  30. package/src/mcp-tools.ts +18 -2
  31. package/src/mirror-config.ts +38 -0
  32. package/src/mirror-manager.ts +6 -0
  33. package/src/mirror-retirement.test.ts +79 -0
  34. package/src/mirror-routes.ts +12 -2
  35. package/src/routes.ts +226 -86
  36. package/src/routing.ts +8 -3
  37. package/src/server.ts +33 -14
  38. package/src/vault-compact-routes.test.ts +257 -0
  39. package/src/vault-history-routes.test.ts +562 -0
  40. package/src/vault-store.ts +13 -0
@@ -0,0 +1,82 @@
1
+ import { test, expect } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { SqliteStore } from "./store.js";
4
+ import { resolveHistoryPolicy, eraseHistory, getVersion } from "./history.js";
5
+ import { applyImportedNote, beginImportRun, importTargetDigest, getImportedVersion, importStorageIndex, type ImportedObservation } from "./history-import.js";
6
+
7
+ const run = { run_id: "manifest", source_fingerprint: "source", tip: "tip", options_digest: "options" };
8
+ const rows: ImportedObservation[] = ["older", "newer"].map(content => ({ content, path: "history", metadata: {}, extension: "md", created_at: null, observed_at: "2026-01-01T00:00:00Z", commit: content, blob: content }));
9
+ async function fixture() {
10
+ const db = new Database(":memory:");
11
+ const store = new SqliteStore(db);
12
+ const note = await store.createNote("live", { path: "history" });
13
+ beginImportRun(db, run);
14
+ const opts = { run, noteId: note.id, observations: rows, policy: resolveHistoryPolicy({ min_versions: 20 }), now: Date.parse("2026-01-02T00:00:00Z"), targetDigest: importTargetDigest(db, note.id) };
15
+ return { db, store, note, opts };
16
+ }
17
+ test("import preserves live state and native allocation; refs survive erase without resurrection", async () => {
18
+ const { db, store, note, opts } = await fixture();
19
+ try {
20
+ const result = applyImportedNote(db, opts);
21
+ expect(result.receipt.retained_count).toBe(2);
22
+ expect(getImportedVersion(db, note.id, 0)?.content).toBe("newer");
23
+ expect(getImportedVersion(db, note.id, 1)?.content).toBe("older");
24
+ expect((await store.getNote(note.id))?.content).toBe("live");
25
+ await store.updateNote(note.id, { content: "native" });
26
+ expect(getVersion(db, note.id, 0)?.content).toBe("live");
27
+ eraseHistory(db, note.id);
28
+ expect(applyImportedNote(db, opts).skipped).toBe(true);
29
+ expect(getImportedVersion(db, note.id, 0)).toBeNull();
30
+ expect(db.query("SELECT count(*) n FROM note_versions").get()).toEqual({ n: 0 });
31
+ expect(() => applyImportedNote(db, { ...opts, run: { ...run, run_id: "different" } })).toThrow("already owned");
32
+ } finally { db.close(); }
33
+ });
34
+ test("failed native-drop projection rolls back blobs, references and receipt", async () => {
35
+ const { db, opts } = await fixture();
36
+ try {
37
+ expect(() => applyImportedNote(db, { ...opts, expectedNativeDrops: [123] })).toThrow("deletion set");
38
+ for (const table of ["history_import_receipts", "history_import_refs", "note_versions", "note_blobs"]) expect(db.query(`SELECT count(*) n FROM ${table}`).get()).toEqual({ n: 0 });
39
+ } finally { db.close(); }
40
+ });
41
+ test("stale target and oversized note cannot leave a partial import", async () => {
42
+ const { db, store, note, opts } = await fixture();
43
+ try {
44
+ await store.updateNote(note.id, { content: "changed" });
45
+ expect(() => applyImportedNote(db, opts)).toThrow("Stale");
46
+ expect(() => applyImportedNote(db, { ...opts, targetDigest: importTargetDigest(db, note.id), observations: [...rows, { ...rows[0]!, content: "x".repeat(2_000_001) }] })).toThrow("Oversized");
47
+ expect(db.query("SELECT count(*) n FROM history_import_receipts").get()).toEqual({ n: 0 });
48
+ expect(db.query("SELECT count(*) n FROM note_versions WHERE version_ix<0").get()).toEqual({ n: 0 });
49
+ } finally { db.close(); }
50
+ });
51
+ test("import allocation rejects unsafe and negative public indices", () => {
52
+ expect(importStorageIndex(0)).toBe(-1);
53
+ for (const n of [-1, 0.5, Number.MAX_SAFE_INTEGER, Infinity, NaN]) expect(() => importStorageIndex(n)).toThrow();
54
+ });
55
+
56
+ test("native allocation starts at zero even when the newest import was removed", async () => {
57
+ const { db, store, note, opts } = await fixture();
58
+ try {
59
+ applyImportedNote(db, opts);
60
+ db.prepare("DELETE FROM note_versions WHERE note_id=? AND version_ix=-1").run(note.id);
61
+ await store.updateNote(note.id, { content: "native edit" });
62
+ expect(getVersion(db, note.id, 0)?.content).toBe("live");
63
+ expect(getImportedVersion(db, note.id, 1)?.content).toBe("older");
64
+ } finally { db.close(); }
65
+ });
66
+
67
+ test("import policy discloses native removals separately and honors disabled retention", async () => {
68
+ const { db, store, note, opts } = await fixture();
69
+ try {
70
+ await store.updateNote(note.id, { content: "first native" });
71
+ await store.updateNote(note.id, { content: "second native" });
72
+ const result = applyImportedNote(db, { ...opts, targetDigest: importTargetDigest(db, note.id), policy: resolveHistoryPolicy({ min_versions: 1, max_versions: 1 }), expectedNativeDrops: [0] });
73
+ expect(result.nativeDrops).toEqual([0]);
74
+ expect(result.receipt.pruned_native).toBe(1);
75
+ expect(result.receipt.pruned_imported).toBe(2);
76
+ expect(getVersion(db, note.id, 1)?.content).toBe("first native");
77
+ expect((await store.getNote(note.id))?.content).toBe("second native");
78
+ const other = await store.createNote("other");
79
+ const disabled = applyImportedNote(db, { ...opts, noteId: other.id, targetDigest: importTargetDigest(db, other.id), policy: resolveHistoryPolicy({ enabled: false, min_versions: 0, max_versions: 1 }) });
80
+ expect(disabled.receipt.retained_count).toBe(2);
81
+ } finally { db.close(); }
82
+ });
@@ -0,0 +1,157 @@
1
+ /** Git-independent import primitives. Callers own source parsing and offline access. */
2
+ import type { Database } from "bun:sqlite";
3
+ import { transaction } from "./txn.js";
4
+ import { HistoryUnrecoverableError, hashContent, byteLength, VERSION_MAX_BYTES, pruneVersions, compactNote, getVersion, type HistoryPolicy, type VersionRow } from "./history.js";
5
+
6
+ export interface ImportedObservation {
7
+ content: string;
8
+ path: string | null;
9
+ metadata: Record<string, unknown>;
10
+ extension: string;
11
+ created_at: string | null;
12
+ observed_at: string;
13
+ commit: string;
14
+ blob: string;
15
+ }
16
+ export interface ImportRun {
17
+ run_id: string;
18
+ source_fingerprint: string;
19
+ tip: string;
20
+ options_digest: string;
21
+ }
22
+ export interface ImportReceipt {
23
+ note_id: string;
24
+ run_id: string;
25
+ state_digest: string;
26
+ imported_count: number;
27
+ retained_count: number;
28
+ pruned_imported: number;
29
+ pruned_native: number;
30
+ completed_at: string;
31
+ }
32
+ export function canonicalJson(value: unknown): string {
33
+ function ordered(v: unknown): unknown {
34
+ if (Array.isArray(v)) return v.map(ordered);
35
+ if (v !== null && typeof v === "object") return Object.fromEntries(Object.entries(v).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, x]) => [k, ordered(x)]));
36
+ return v;
37
+ }
38
+ return JSON.stringify(ordered(value));
39
+ }
40
+ export function importStorageIndex(index: number): number {
41
+ const storage = -index - 1;
42
+ if (!Number.isSafeInteger(index) || index < 0 || !Number.isSafeInteger(storage)) throw new Error("Invalid import_ix");
43
+ return storage;
44
+ }
45
+ export function projectHistoryRow<T extends VersionRow>(row: T): T | (Omit<T, "version_ix"> & { origin: "git-import"; import_ix: number }) {
46
+ if (row.version_ix >= 0) return row;
47
+ const { version_ix, ...rest } = row;
48
+ return { ...rest, origin: "git-import", import_ix: -version_ix - 1 };
49
+ }
50
+ export function getImportedVersion(db: Database, id: string, index: number) {
51
+ const ix = importStorageIndex(index);
52
+ if (!db.prepare("SELECT 1 FROM history_import_refs WHERE note_id=? AND import_ix=?").get(id, index)) return null;
53
+ try {
54
+ const row = getVersion(db, id, ix);
55
+ return row?.op === "import" ? row : null;
56
+ } catch (error) {
57
+ if (error instanceof HistoryUnrecoverableError) throw new ImportedHistoryUnrecoverableError(id, index, error.reason);
58
+ throw error;
59
+ }
60
+ }
61
+ export function beginImportRun(db: Database, run: ImportRun): void {
62
+ transaction(db, () => {
63
+ const existing = db.prepare("SELECT run_id,source_fingerprint,tip,options_digest FROM history_import_runs WHERE run_id=?").get(run.run_id);
64
+ if (existing) {
65
+ if (canonicalJson(existing) !== canonicalJson(run)) throw new Error("Import run conflicts with its manifest");
66
+ return;
67
+ }
68
+ db.prepare("INSERT INTO history_import_runs VALUES(?,?,?,?, 'applying', ?)").run(run.run_id, run.source_fingerprint, run.tip, run.options_digest, new Date().toISOString());
69
+ });
70
+ }
71
+ export function readImportReceipt(db: Database, id: string): ImportReceipt | null {
72
+ return db.prepare("SELECT * FROM history_import_receipts WHERE note_id=?").get(id) as ImportReceipt | null;
73
+ }
74
+ /** Content-addressed global blob changes are excluded from the per-note state fingerprint. */
75
+ export function importTargetDigest(db: Database, id: string): string {
76
+ return hashContent(canonicalJson({
77
+ note: db.prepare("SELECT id,content,path,metadata,extension,created_at,updated_at FROM notes WHERE id=?").get(id),
78
+ versions: db.prepare("SELECT * FROM note_versions WHERE note_id=? ORDER BY version_ix").all(id),
79
+ }));
80
+ }
81
+ export function importObservationDigest(rows: ImportedObservation[]): string {
82
+ return hashContent(canonicalJson(rows));
83
+ }
84
+ export function applyImportedNote(db: Database, opts: {
85
+ run: ImportRun; noteId: string; observations: ImportedObservation[];
86
+ policy: HistoryPolicy; now: number; targetDigest: string;
87
+ expectedNativeDrops?: number[];
88
+ }): { receipt: ImportReceipt; nativeDrops: number[]; skipped: boolean } {
89
+ return transaction(db, () => {
90
+ const prior = readImportReceipt(db, opts.noteId);
91
+ const digest = importObservationDigest(opts.observations);
92
+ if (prior) {
93
+ if (prior.run_id !== opts.run.run_id || prior.state_digest !== digest) throw new Error("Note already owned by another import; resume its original manifest");
94
+ return { receipt: prior, nativeDrops: [], skipped: true };
95
+ }
96
+ if (!db.prepare("SELECT 1 FROM notes WHERE id=?").get(opts.noteId)) throw new Error("Import requires a live note");
97
+ if (importTargetDigest(db, opts.noteId) !== opts.targetDigest) throw new Error("Stale import manifest: target note changed");
98
+ if (!opts.observations.length) throw new Error("Cannot receipt an empty import");
99
+ if (db.prepare("SELECT 1 FROM note_versions WHERE note_id=? AND version_ix<0").get(opts.noteId)) throw new Error("Unreceipted imported history exists");
100
+ const nativeBefore = db.prepare("SELECT version_ix FROM note_versions WHERE note_id=? AND version_ix>=0 ORDER BY version_ix").all(opts.noteId) as { version_ix: number }[];
101
+ const receipt: ImportReceipt = { note_id: opts.noteId, run_id: opts.run.run_id, state_digest: digest, imported_count: opts.observations.length, retained_count: 0, pruned_imported: 0, pruned_native: 0, completed_at: new Date(opts.now).toISOString() };
102
+ db.prepare("INSERT INTO history_import_receipts VALUES(?,?,?,?,?,?,?,?)").run(receipt.note_id, receipt.run_id, receipt.state_digest, receipt.imported_count, 0, 0, 0, receipt.completed_at);
103
+ for (let i = 0; i < opts.observations.length; i++) {
104
+ const row = opts.observations[i]!;
105
+ const size = byteLength(row.content);
106
+ if (size > VERSION_MAX_BYTES) throw new Error("Oversized imported observation: quarantine the whole note");
107
+ const index = opts.observations.length - i - 1;
108
+ const storage = importStorageIndex(index);
109
+ const hash = hashContent(row.content);
110
+ db.prepare("INSERT OR IGNORE INTO note_blobs(hash,content,byte_size) VALUES(?,?,?)").run(hash, row.content, size);
111
+ db.prepare(`INSERT INTO note_versions(note_id,version_ix,content_hash,path,metadata,extension,superseded_at,actor,via,op,content_len,encoding,created_at)
112
+ 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
+ db.prepare("INSERT INTO history_import_refs VALUES(?,?,?,?)").run(opts.noteId, index, row.commit, row.blob);
114
+ }
115
+ if (opts.policy.enabled) pruneVersions(db, opts.noteId, opts.policy, opts.now);
116
+ compactNote(db, opts.noteId, opts.policy);
117
+ 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
+ for (let i = 0; i < opts.observations.length; i++) {
119
+ const index = opts.observations.length - i - 1;
120
+ if (after.has(importStorageIndex(index)) && getImportedVersion(db, opts.noteId, index)?.content !== opts.observations[i]!.content) throw new Error("Imported content failed verification");
121
+ }
122
+ const nativeDrops = nativeBefore.map(r => r.version_ix).filter(ix => !after.has(ix));
123
+ if (opts.expectedNativeDrops && canonicalJson(nativeDrops) !== canonicalJson(opts.expectedNativeDrops)) throw new Error("Native deletion set differs from final manifest");
124
+ receipt.retained_count = [...after].filter(ix => ix < 0).length;
125
+ receipt.pruned_imported = receipt.imported_count - receipt.retained_count;
126
+ receipt.pruned_native = nativeDrops.length;
127
+ 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);
128
+ return { receipt, nativeDrops, skipped: false };
129
+ });
130
+ }
131
+
132
+ /** Public import errors must not expose the negative storage key, even in prose. */
133
+ export class ImportedHistoryUnrecoverableError extends Error {
134
+ readonly code = "HISTORY_UNRECOVERABLE";
135
+ readonly error_type = "history_unrecoverable";
136
+ readonly origin = "git-import";
137
+ constructor(readonly note_id: string, readonly import_ix: number, readonly reason: "overflow" | "delta_orphan") {
138
+ super(`Imported version content is unrecoverable: "${note_id}" import ${import_ix}`);
139
+ this.name = "ImportedHistoryUnrecoverableError";
140
+ }
141
+ }
142
+ export function parseHistorySelector(value: { version_ix?: unknown; origin?: unknown; import_ix?: unknown }):
143
+ { version_ix: number } | { origin: "git-import"; import_ix: number } | null {
144
+ const imported = value.origin !== undefined || value.import_ix !== undefined;
145
+ if (imported) {
146
+ if (value.version_ix !== undefined || value.origin !== "git-import" || typeof value.import_ix !== "number"
147
+ || !Number.isSafeInteger(value.import_ix) || value.import_ix < 0) throw invalidHistorySelector();
148
+ importStorageIndex(value.import_ix);
149
+ return { origin: "git-import", import_ix: value.import_ix };
150
+ }
151
+ if (value.version_ix === undefined) return null;
152
+ if (typeof value.version_ix !== "number" || !Number.isSafeInteger(value.version_ix) || value.version_ix < 0) throw invalidHistorySelector();
153
+ return { version_ix: value.version_ix };
154
+ }
155
+ function invalidHistorySelector() {
156
+ return Object.assign(new Error("Specify either a nonnegative version_ix or origin git-import with a nonnegative import_ix"), { error_type: "invalid_request", field: "versions" });
157
+ }
@@ -0,0 +1,229 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { BunSqliteStore } from "./store.js";
4
+ // Keep base runs executable: the absent module makes individual pins RED,
5
+ // rather than aborting collection for the entire file.
6
+ const history = await import("./history.js").catch(() => null);
7
+ let db: Database;
8
+ let store: BunSqliteStore;
9
+ beforeEach(() => {
10
+ db = new Database(":memory:");
11
+ store = new BunSqliteStore(db);
12
+ });
13
+ afterEach(() => db.close());
14
+ const count = (table: "note_versions" | "note_blobs") =>
15
+ (db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;
16
+
17
+ describe("vault#524 history retention and blob integrity", () => {
18
+ it("P2 metadata flips share one 116 KB body after the first capture", async () => {
19
+ const body = "é".repeat(58_000);
20
+ const n = await store.createNote(body, { path: "p" });
21
+ await store.updateNote(n.id, { metadata: { status: 1 } });
22
+ const bytes = (
23
+ db.prepare("SELECT SUM(byte_size) AS n FROM note_blobs").get() as {
24
+ n: number;
25
+ }
26
+ ).n;
27
+ let previous = (await store.listNoteVersions(n.id))[0]!;
28
+ for (let i = 2; i <= 5; i++) {
29
+ await store.updateNote(n.id, { metadata: { status: i } });
30
+ expect(count("note_blobs")).toBe(1);
31
+ expect(
32
+ (
33
+ db.prepare("SELECT SUM(byte_size) AS n FROM note_blobs").get() as {
34
+ n: number;
35
+ }
36
+ ).n,
37
+ ).toBe(bytes);
38
+ const rows = await store.listNoteVersions(n.id);
39
+ expect(rows).toHaveLength(i);
40
+ expect(rows[0]!.content_hash).toBe(previous.content_hash);
41
+ expect(rows[0]!.content_len).toBe(116_000);
42
+ previous = rows[0]!;
43
+ }
44
+ const size = db
45
+ .prepare(
46
+ `SELECT LENGTH(CAST(path AS BLOB))+LENGTH(CAST(metadata AS BLOB))+LENGTH(CAST(extension AS BLOB))+
47
+ LENGTH(CAST(superseded_at AS BLOB))+LENGTH(CAST(COALESCE(actor,'') AS BLOB))+LENGTH(CAST(COALESCE(via,'') AS BLOB))+
48
+ LENGTH(CAST(op AS BLOB))+LENGTH(CAST(content_hash AS BLOB))+32 AS n FROM note_versions WHERE note_id=? AND version_ix=4`,
49
+ )
50
+ .get(n.id) as { n: number };
51
+ expect(size.n).toBeLessThan(1024);
52
+ });
53
+ for (const [edits, age, expected, minIx] of [
54
+ [4, 1, 4, 0],
55
+ [8, 1, 5, 3],
56
+ [2, 0, 2, 0],
57
+ [9, 0, 3, 6],
58
+ ]) {
59
+ it(`P3 retention arithmetic ${edits} edits age ${age}`, async () => {
60
+ store = new BunSqliteStore(db, {
61
+ history: { min_versions: 3, max_versions: 5, max_age_days: age },
62
+ });
63
+ const n = await store.createNote("start");
64
+ for (let i = 0; i < edits!; i++) {
65
+ await store.updateNote(n.id, { content: String(i) });
66
+ await new Promise((r) => setTimeout(r, 2));
67
+ }
68
+ const row = db
69
+ .prepare(
70
+ "SELECT COUNT(*) AS n, MIN(version_ix) AS lo, MAX(version_ix) AS hi FROM note_versions WHERE note_id=?",
71
+ )
72
+ .get(n.id) as { n: number; lo: number; hi: number };
73
+ expect(row).toEqual({ n: expected, lo: minIx, hi: edits! - 1 });
74
+ });
75
+ }
76
+ it("P6 shared blobs survive pruning and vanish only after the last reference", async () => {
77
+ const a = await store.createNote("shared"),
78
+ b = await store.createNote("shared");
79
+ await store.updateNote(a.id, { content: "a" });
80
+ await store.updateNote(b.id, { content: "b" });
81
+ expect(count("note_blobs")).toBe(1);
82
+ const hash = (await store.listNoteVersions(a.id))[0]!.content_hash;
83
+ expect(() =>
84
+ db.prepare("DELETE FROM note_blobs WHERE hash=?").run(hash),
85
+ ).toThrow(/FOREIGN KEY/);
86
+ db.prepare("UPDATE note_versions SET superseded_at=? WHERE note_id=?").run(
87
+ "2000-01-01T00:00:00.000Z",
88
+ a.id,
89
+ );
90
+ history!.pruneVersions(
91
+ db,
92
+ a.id,
93
+ history!.resolveHistoryPolicy({
94
+ min_versions: 0,
95
+ max_versions: 1,
96
+ max_age_days: 1,
97
+ }),
98
+ );
99
+ expect(count("note_versions")).toBe(1);
100
+ expect(count("note_blobs")).toBe(1);
101
+ await store.eraseNoteHistory(b.id);
102
+ expect(count("note_blobs")).toBe(0);
103
+ });
104
+ it("P7a null deleted retention issues no query and keeps history", async () => {
105
+ const n = await store.createNote("one");
106
+ await store.updateNote(n.id, { content: "two" });
107
+ await store.deleteNote(n.id);
108
+ const before = count("note_versions");
109
+ const probe = new Proxy(db, {
110
+ get(target, key) {
111
+ if (key === "prepare") throw new Error("query attempted");
112
+ return Reflect.get(target, key);
113
+ },
114
+ });
115
+ expect(
116
+ history!.sweepDeletedHistory(probe, history!.resolveHistoryPolicy()),
117
+ ).toEqual({ notesSwept: 0, versionsDeleted: 0, blobsDeleted: 0 });
118
+ expect(count("note_versions")).toBe(before);
119
+ });
120
+ it("P7b sweeps old deleted chains and preserves recent deleted and live chains", async () => {
121
+ store = new BunSqliteStore(db, { history: { deleted_retention_days: 7 } });
122
+ const old = await store.createNote("old"),
123
+ recent = await store.createNote("recent"),
124
+ live = await store.createNote("live");
125
+ await store.deleteNote(old.id);
126
+ await store.deleteNote(recent.id);
127
+ await store.updateNote(live.id, { content: "now" });
128
+ db.prepare(
129
+ "UPDATE note_versions SET superseded_at='2000-01-01T00:00:00.000Z' WHERE note_id IN (?,?)",
130
+ ).run(old.id, live.id);
131
+ expect(store.sweepDeletedHistory()).toEqual({
132
+ notesSwept: 1,
133
+ versionsDeleted: 1,
134
+ blobsDeleted: 1,
135
+ });
136
+ expect(await store.listNoteVersions(old.id)).toHaveLength(0);
137
+ expect(await store.listNoteVersions(recent.id)).toHaveLength(1);
138
+ expect(await store.listNoteVersions(live.id)).toHaveLength(1);
139
+ });
140
+ it("P7c tombstones survive the version ceiling", async () => {
141
+ store = new BunSqliteStore(db, {
142
+ history: { min_versions: 0, max_versions: 1 },
143
+ });
144
+ const n = await store.createNote("a");
145
+ await store.deleteNote(n.id);
146
+ await store.createNote("b", { id: n.id });
147
+ await store.updateNote(n.id, { content: "c" });
148
+ expect((await store.listNoteVersions(n.id)).map((v) => v.op)).toEqual([
149
+ "update",
150
+ "delete",
151
+ ]);
152
+ });
153
+ it("P13 oversized prior body rejects update with no partial history", async () => {
154
+ const content = "é".repeat(1_000_001),
155
+ n = await store.createNote(content);
156
+ await expect(
157
+ store.updateNote(n.id, { content: "small" }),
158
+ ).rejects.toMatchObject({ code: "HISTORY_OVERFLOW" });
159
+ expect((await store.getNote(n.id))!.content).toBe(content);
160
+ expect(count("note_versions")).toBe(0);
161
+ expect(count("note_blobs")).toBe(0);
162
+ });
163
+ it("P19a/b oversized delete records size and attribution without a blob", async () => {
164
+ const content = "é".repeat(1_000_001),
165
+ n = await store.createNote(content);
166
+ await expect(
167
+ store.updateNote(n.id, { content: "x" }),
168
+ ).rejects.toMatchObject({ code: "HISTORY_OVERFLOW" });
169
+ await store.deleteNote(n.id, { actor: "a", via: "v" });
170
+ expect(await store.getNote(n.id)).toBeNull();
171
+ expect(count("note_blobs")).toBe(0);
172
+ expect(await store.getNoteVersion(n.id, 0)).toMatchObject({
173
+ op: "delete",
174
+ content_hash: null,
175
+ encoding: "overflow",
176
+ content: null,
177
+ content_len: 2_000_002,
178
+ actor: "a",
179
+ via: "v",
180
+ });
181
+ });
182
+ it("P19d NULL overflow hash cannot poison full blob GC", async () => {
183
+ const n = await store.createNote("x".repeat(2_000_001));
184
+ await store.deleteNote(n.id);
185
+ const second = await store.createNote("orphan");
186
+ await store.updateNote(second.id, { content: "new" });
187
+ db.prepare("DELETE FROM note_versions WHERE note_id=?").run(second.id);
188
+ expect(count("note_blobs")).toBe(1);
189
+ const third = await store.createNote("third");
190
+ await store.eraseNoteHistory(third.id);
191
+ expect(count("note_blobs")).toBe(0);
192
+ expect(count("note_versions")).toBe(1);
193
+ });
194
+ it("P21 clamp preserves the 100-version floor without unbounded growth", async () => {
195
+ expect(
196
+ history!.resolveHistoryPolicy({ min_versions: 100, max_versions: 20 })
197
+ .max_versions,
198
+ ).toBe(100);
199
+ store = new BunSqliteStore(db, {
200
+ history: { min_versions: 100, max_versions: 20 },
201
+ });
202
+ const n = await store.createNote("0");
203
+ for (let i = 0; i < 150; i++)
204
+ await store.updateNote(n.id, { content: String(i) });
205
+ expect(count("note_versions")).toBe(100);
206
+ });
207
+ it("P21 zero ceiling keeps the newest version and absurd ages cannot break writes", async () => {
208
+ const policy = history!.resolveHistoryPolicy({
209
+ min_versions: 0,
210
+ max_versions: 0,
211
+ max_age_days: 200000000,
212
+ deleted_retention_days: 200000000,
213
+ });
214
+ expect(policy).toMatchObject({
215
+ max_versions: 1,
216
+ max_age_days: 36500,
217
+ deleted_retention_days: 36500,
218
+ });
219
+ store = new BunSqliteStore(db, { history: policy });
220
+ const n = await store.createNote("first");
221
+ await store.updateNote(n.id, { content: "second" });
222
+ await store.updateNote(n.id, { content: "third" });
223
+ expect(await store.listNoteVersions(n.id)).toHaveLength(1);
224
+ expect((await store.getNoteVersion(n.id, 1))!.content).toBe("second");
225
+ await store.deleteNote(n.id);
226
+ history!.sweepDeletedHistory(db, policy);
227
+ expect(await store.getNote(n.id)).toBeNull();
228
+ });
229
+ });