@openparachute/vault 0.7.9-rc.4 → 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.
@@ -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
+ });