@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,562 @@
1
+ import { test, expect, beforeEach, afterEach, spyOn } from "bun:test";
2
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { handleNotes, handleTags, type WriteCtx } from "./routes.ts";
6
+ import {
7
+ writeVaultConfig,
8
+ readVaultConfig,
9
+ vaultConfigPath,
10
+ } from "./config.ts";
11
+ import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
12
+ import { generateScopedMcpTools } from "./mcp-tools.ts";
13
+ import { runDoctorScan } from "../core/src/doctor.ts";
14
+ let taskDir: string,
15
+ savedHome: string | undefined,
16
+ store: ReturnType<typeof getVaultStore>;
17
+ beforeEach(() => {
18
+ savedHome = process.env.PARACHUTE_HOME;
19
+ taskDir = mkdtempSync(join(tmpdir(), "vault-history-"));
20
+ process.env.PARACHUTE_HOME = taskDir;
21
+ writeVaultConfig({
22
+ name: "history",
23
+ api_keys: [],
24
+ created_at: new Date().toISOString(),
25
+ });
26
+ store = getVaultStore("history");
27
+ });
28
+ afterEach(() => {
29
+ clearVaultStoreCache();
30
+ rmSync(taskDir, { recursive: true, force: true });
31
+ if (savedHome === undefined) delete process.env.PARACHUTE_HOME;
32
+ else process.env.PARACHUTE_HOME = savedHome;
33
+ });
34
+ async function call(
35
+ id: string,
36
+ sub = "",
37
+ method = "GET",
38
+ body?: unknown,
39
+ scoped = false,
40
+ ctx: WriteCtx = { actor: "tester", via: "api" },
41
+ ) {
42
+ const req = new Request(`http://localhost/api/notes/${id}${sub}`, {
43
+ method,
44
+ ...(body === undefined
45
+ ? {}
46
+ : {
47
+ headers: { "Content-Type": "application/json" },
48
+ body: JSON.stringify(body),
49
+ }),
50
+ });
51
+ const response = await handleNotes(
52
+ req,
53
+ store,
54
+ `/${id}${sub.split("?")[0]}`,
55
+ "history",
56
+ scoped
57
+ ? { raw: ["journal"], allowed: new Set(["journal"]) }
58
+ : { raw: null, allowed: null },
59
+ ctx,
60
+ );
61
+ return { status: response.status, body: (await response.json()) as any };
62
+ }
63
+ function query(tags: string[] | null = null, tool = "query-notes") {
64
+ return generateScopedMcpTools("history", {
65
+ permission: "full",
66
+ scopes: ["vault:history:read"],
67
+ legacyDerived: false,
68
+ scoped_tags: tags,
69
+ vault_name: null,
70
+ caller_jti: null,
71
+ actor: "tester",
72
+ via: "api",
73
+ }).find((t) => t.name === tool)!.execute;
74
+ }
75
+ async function fixture() {
76
+ const n = await store.createNote("zero", { path: "p", tags: ["journal"] });
77
+ await store.updateNote(n.id, { content: "one" });
78
+ await store.updateNote(n.id, { content: "two" });
79
+ return n;
80
+ }
81
+ test("P5 REST restore rejects stale optimistic tokens without capture", async () => {
82
+ const n = await fixture();
83
+ const before = await store.listNoteVersions(n.id);
84
+ const r = await call(n.id, "/restore", "POST", {
85
+ version_ix: 0,
86
+ if_updated_at: "1999-01-01T00:00:00.000Z",
87
+ });
88
+ expect(r.status).toBe(409);
89
+ expect(r.body.error_type).toBe("conflict");
90
+ const nonString = await call(n.id, "/restore", "POST", {
91
+ version_ix: 0,
92
+ if_updated_at: 12345,
93
+ });
94
+ expect(nonString.status).toBe(409);
95
+ expect(nonString.body.error_type).toBe("conflict");
96
+ expect(await store.listNoteVersions(n.id)).toEqual(before);
97
+ expect((await store.getNote(n.id))!.content).toBe("two");
98
+ });
99
+ test("P7/P10 deleted history is readable unscoped and indistinguishable from missing when scoped", async () => {
100
+ const n = await fixture();
101
+ await call(n.id, "", "DELETE");
102
+ expect((await call(n.id)).status).toBe(404);
103
+ expect((await call(n.id, "/versions")).body.versions[0]).toMatchObject({
104
+ op: "delete",
105
+ actor: "tester",
106
+ via: "api",
107
+ });
108
+ expect(await call(n.id, "/versions", "GET", undefined, true)).toEqual({
109
+ status: 404,
110
+ body: { error: "Not found", error_type: "not_found" },
111
+ });
112
+ });
113
+ test("P11 REST transition-only writes capture the old metadata", async () => {
114
+ const n = await store.createNote("body", { metadata: { status: "a" } });
115
+ const r = await call(n.id, "", "PATCH", {
116
+ state_transition: { field: "status", from: "a", to: "b" },
117
+ });
118
+ expect(r.status).toBe(200);
119
+ expect(await store.getNoteVersion(n.id, 0)).toMatchObject({
120
+ metadata: { status: "a" },
121
+ });
122
+ expect(await store.listNoteVersions(n.id)).toHaveLength(1);
123
+ });
124
+ test("P13 REST overflowing prior body rejects the write with 413", async () => {
125
+ const n = await store.createNote("é".repeat(1000001));
126
+ const r = await call(n.id, "", "PATCH", { content: "small", force: true });
127
+ expect(r.status).toBe(413);
128
+ expect(r.body.error_type).toBe("history_overflow");
129
+ expect(await store.listNoteVersions(n.id)).toHaveLength(0);
130
+ });
131
+ test("P15 REST list/get and MCP project the same ordered versions", async () => {
132
+ const n = await fixture(),
133
+ rest = await call(n.id, "/versions?limit=1&offset=1");
134
+ expect(rest.status).toBe(200);
135
+ expect(rest.body.total).toBe(2);
136
+ expect((await call(n.id, "/versions?limit=0")).body).toEqual({
137
+ versions: [],
138
+ total: 2,
139
+ });
140
+ expect(rest.body.versions).toHaveLength(1);
141
+ expect(rest.body.versions[0].version_ix).toBe(0);
142
+ expect(rest.body.versions[0]).not.toHaveProperty("content");
143
+ const mcp: any = await query()({
144
+ versions: { note_id: n.id, limit: 1, offset: 1 },
145
+ });
146
+ expect(mcp).toEqual(rest.body);
147
+ expect(await query()({ versions: { note_id: n.id, limit: -1 } })).toEqual({
148
+ versions: [],
149
+ total: 2,
150
+ });
151
+ const one = await call(n.id, "/versions/0");
152
+ expect(one.body.content).toBe("zero");
153
+ expect(await query()({ versions: { note_id: n.id, version_ix: 0 } })).toEqual(
154
+ one.body,
155
+ );
156
+ expect((await call(n.id, "/versions/999")).status).toBe(404);
157
+ for (const ix of ["-1", "1.5", "oops", "0x0"])
158
+ expect((await call(n.id, `/versions/${ix}`)).status).toBe(400);
159
+ expect((await call(n.id, "/restore", "POST", {})).body.error_type).toBe(
160
+ "missing_required_field",
161
+ );
162
+ expect(
163
+ (await call(n.id, "/restore", "POST", { version_ix: -1 })).status,
164
+ ).toBe(400);
165
+ });
166
+ for (const mode of ["aggregate", "near", "search", "cursor", "semantic", "id"])
167
+ test(`P15 MCP versions excludes ${mode}`, async () => {
168
+ const n = await fixture();
169
+ const values: Record<string, unknown> = {
170
+ aggregate: { group_by: "tag" },
171
+ near: { note_id: n.id },
172
+ search: "zero",
173
+ cursor: "invalid",
174
+ semantic: true,
175
+ id: n.id,
176
+ };
177
+ await expect(
178
+ query()({ versions: { note_id: n.id }, [mode]: values[mode] }),
179
+ ).rejects.toMatchObject({ error_type: "invalid_query", field: "versions" });
180
+ });
181
+ test("P16 doctor reports deleted-history upper bounds without mutation or scoped leakage", async () => {
182
+ const a = await store.createNote("same"),
183
+ b = await store.createNote("same");
184
+ await store.deleteNote(a.id);
185
+ await store.deleteNote(b.id);
186
+ const before = store.db
187
+ .prepare(
188
+ "SELECT note_id, version_ix, content_hash, path, metadata, extension, superseded_at, actor, via, op, content_len, encoding FROM note_versions ORDER BY note_id",
189
+ )
190
+ .all();
191
+ const report = runDoctorScan(store.db);
192
+ expect(
193
+ report.findings.find((f) => f.type === "deleted_note_history"),
194
+ ).toMatchObject({
195
+ severity: "info",
196
+ subject: "2 deleted note(s)",
197
+ detail: expect.stringContaining("upper bound"),
198
+ });
199
+ const mcp: any = await query(null, "doctor")({});
200
+ expect(
201
+ mcp.findings.find((f: any) => f.type === "deleted_note_history"),
202
+ ).toMatchObject({
203
+ severity: "info",
204
+ subject: "2 deleted note(s)",
205
+ detail: expect.stringContaining("upper bound"),
206
+ });
207
+ const scopedDoctor: any = await query(["journal"], "doctor")({});
208
+ expect(
209
+ scopedDoctor.findings.some((f: any) => f.type === "deleted_note_history"),
210
+ ).toBe(false);
211
+ expect(
212
+ report.findings.filter((f) => f.type === "deleted_note_history"),
213
+ ).toHaveLength(1);
214
+ expect(
215
+ runDoctorScan(store.db, {
216
+ allowedTags: new Set(["journal"]),
217
+ }).findings.some((f) => f.type === "deleted_note_history"),
218
+ ).toBe(false);
219
+ expect(
220
+ store.db
221
+ .prepare(
222
+ "SELECT note_id, version_ix, content_hash, path, metadata, extension, superseded_at, actor, via, op, content_len, encoding FROM note_versions ORDER BY note_id",
223
+ )
224
+ .all(),
225
+ ).toEqual(before);
226
+ expect(await store.deletedHistoryStats()).toMatchObject({
227
+ notes: 2,
228
+ versions: 2,
229
+ bytes: 8,
230
+ });
231
+ });
232
+ test("P18 MCP protects the new object result from out-of-scope disclosure", async () => {
233
+ const x = await store.createNote("X_SENTINEL", { tags: ["journal"] }),
234
+ y = await store.createNote("Y_SECRET_SENTINEL", { tags: ["work"] });
235
+ await store.updateNote(x.id, { content: "x2" });
236
+ await store.updateNote(y.id, { content: "y2" });
237
+ await store.updateNote(x.id, { content: "x3" });
238
+ await store.updateNote(y.id, { content: "y3" });
239
+ const denied: any = await query(["journal"])({ versions: { note_id: y.id } });
240
+ expect(denied.error_type).toBe("not_found");
241
+ expect(JSON.stringify(denied)).not.toContain("Y_SECRET_SENTINEL");
242
+ expect(denied).not.toHaveProperty("versions");
243
+ expect(
244
+ ((await query(["journal"])({ versions: { note_id: x.id } })) as any)
245
+ .versions,
246
+ ).toHaveLength(2);
247
+ expect(
248
+ ((await query()({ versions: { note_id: y.id } })) as any).versions,
249
+ ).toHaveLength(2);
250
+ expect(
251
+ ((await query()({ versions: { note_id: y.id, version_ix: 0 } })) as any)
252
+ .content,
253
+ ).toBe("Y_SECRET_SENTINEL");
254
+ for (const tags of [["journal"], null]) {
255
+ const error = await query(tags)({
256
+ id: y.id,
257
+ versions: { note_id: x.id },
258
+ }).catch((e) => e);
259
+ expect(error).toMatchObject({
260
+ error_type: "invalid_query",
261
+ field: "versions",
262
+ hint: "drop id when using versions",
263
+ });
264
+ expect(JSON.stringify(error)).not.toContain("Y_SECRET_SENTINEL");
265
+ }
266
+ const contentDenied: any = await query(["journal"])({
267
+ versions: { note_id: y.id, version_ix: 0 },
268
+ });
269
+ expect(contentDenied.error_type).toBe("not_found");
270
+ expect(JSON.stringify(contentDenied)).not.toContain("Y_SECRET_SENTINEL");
271
+ const empty = await store.createNote("EMPTY_SECRET_SENTINEL", {
272
+ tags: ["work"],
273
+ });
274
+ const emptyDenied: any = await query(["journal"])({
275
+ versions: { note_id: empty.id },
276
+ });
277
+ expect(emptyDenied.error_type).toBe("not_found");
278
+ expect(emptyDenied).not.toHaveProperty("versions");
279
+ expect(JSON.stringify(emptyDenied)).not.toContain("EMPTY_SECRET_SENTINEL");
280
+ await store.deleteNote(y.id);
281
+ for (const tags of [["journal"], null])
282
+ await expect(
283
+ query(tags)({ versions: { note_id: y.id } }),
284
+ ).rejects.toMatchObject({ error_type: "not_found" });
285
+ });
286
+ test("P18h missing versions do not disclose a scoped-out path's canonical id", async () => {
287
+ const ref = "private/history-target";
288
+ const n = await store.createNote("PRIVATE_CONTENT", {
289
+ path: ref,
290
+ tags: ["work"],
291
+ metadata: { secret: "PRIVATE_METADATA" },
292
+ });
293
+ await store.updateNote(n.id, { content: "later" });
294
+ const params = { versions: { note_id: ref, version_ix: 999 } };
295
+ const scoped = await query(["journal"])(params);
296
+ expect(scoped).toEqual({
297
+ error: "Note not found",
298
+ error_type: "not_found",
299
+ id: ref,
300
+ });
301
+ expect(JSON.stringify(scoped)).not.toContain(n.id);
302
+ const unscoped = await query()(params);
303
+ expect(unscoped).toEqual({
304
+ error: `Version not found: "${ref}"@999`,
305
+ error_type: "not_found",
306
+ id: ref,
307
+ version_ix: 999,
308
+ });
309
+ expect(JSON.stringify(unscoped)).not.toContain(n.id);
310
+ });
311
+ test("P19 overflow deletion remains possible but its tombstone cannot restore", async () => {
312
+ const n = await store.createNote("x".repeat(2000001));
313
+ expect((await call(n.id, "", "DELETE")).status).toBe(200);
314
+ expect((await call(n.id, "/versions/0")).body).toMatchObject({
315
+ encoding: "overflow",
316
+ content: null,
317
+ content_hash: null,
318
+ });
319
+ const r = await call(n.id, "/restore", "POST", { version_ix: 0 });
320
+ expect(r.status).toBe(409);
321
+ expect(r.body.error_type).toBe("history_unrecoverable");
322
+ });
323
+ test("P20 REST recreates untagged at the tombstone path and guards conflicts", async () => {
324
+ const n = await fixture();
325
+ await store.deleteNote(n.id);
326
+ for (const [sub, method, body] of [
327
+ ["/versions", "GET", undefined],
328
+ ["/versions/0", "GET", undefined],
329
+ ["/restore", "POST", { version_ix: 0 }],
330
+ ] as const)
331
+ expect((await call(n.id, sub, method, body, true)).status).toBe(404);
332
+ expect(
333
+ (
334
+ await call(n.id, "/restore", "POST", {
335
+ version_ix: 0,
336
+ if_updated_at: "stale",
337
+ })
338
+ ).status,
339
+ ).toBe(409);
340
+ const r = await call(n.id, "/restore", "POST", { version_ix: 0 });
341
+ expect(r.status).toBe(200);
342
+ expect(r.body).toMatchObject({
343
+ id: n.id,
344
+ path: "p",
345
+ content: "zero",
346
+ tags: [],
347
+ recreated: true,
348
+ restored_from: 0,
349
+ });
350
+ });
351
+ test("P22 config round-trips all five history fields including zero and null", () => {
352
+ const c = readVaultConfig("history")!;
353
+ c.history = {
354
+ enabled: false,
355
+ min_versions: 0,
356
+ max_versions: 7,
357
+ max_age_days: 0,
358
+ deleted_retention_days: null,
359
+ };
360
+ writeVaultConfig(c);
361
+ expect(readVaultConfig("history")!.history).toEqual(c.history);
362
+ const p = vaultConfigPath("history"),
363
+ text = readFileSync(p, "utf8");
364
+ writeFileSync(p, text.replace("max_versions: 7", "max_versions: nonsense"));
365
+ expect(readVaultConfig("history")!.history?.max_versions).toBeUndefined();
366
+ expect(readVaultConfig("history")!.history?.min_versions).toBe(0);
367
+ });
368
+ async function strictFixture() {
369
+ await store.upsertTagRecord("task", {});
370
+ const n = await store.createNote("old", {
371
+ tags: ["task"],
372
+ metadata: { status: "obsolete" },
373
+ });
374
+ await store.upsertTagRecord("task", {
375
+ fields: {
376
+ status: { type: "string", enum: ["active", "archived"], strict: true },
377
+ },
378
+ });
379
+ await store.updateNote(n.id, { metadata: { status: "active" } });
380
+ return n;
381
+ }
382
+ test("P23 restore enforces today's strict schema without changing note or history", async () => {
383
+ const n = await strictFixture(),
384
+ before = await store.getNote(n.id),
385
+ versions = await store.listNoteVersions(n.id);
386
+ const r = await call(n.id, "/restore", "POST", { version_ix: 0 });
387
+ expect(r.status).toBe(422);
388
+ expect(r.body.error_type).toBe("schema_validation");
389
+ expect(await store.getNote(n.id)).toEqual(before);
390
+ expect(await store.listNoteVersions(n.id)).toEqual(versions);
391
+ });
392
+ test("P24 migration bypass restores, captures once, and emits the bypass log", async () => {
393
+ const n = await strictFixture();
394
+ const log = spyOn(console, "warn").mockImplementation(() => {});
395
+ try {
396
+ const r = await call(n.id, "/restore", "POST", { version_ix: 0 }, false, {
397
+ actor: "migrator",
398
+ via: "api",
399
+ bypassStrict: true,
400
+ });
401
+ expect(r.status).toBe(200);
402
+ expect((await store.getNote(n.id))!.metadata.status).toBe("obsolete");
403
+ expect(await store.listNoteVersions(n.id)).toHaveLength(2);
404
+ expect(JSON.stringify(log.mock.calls)).toContain("strict_schema_bypass");
405
+ } finally {
406
+ log.mockRestore();
407
+ }
408
+ });
409
+
410
+ test("P8c REST tag rename carries the caller attribution", async () => {
411
+ await store.upsertTagRecord("old", {});
412
+ const n = await store.createNote("#old body");
413
+ const r = await handleTags(
414
+ new Request("http://localhost/api/tags/old/rename", {
415
+ method: "POST",
416
+ headers: { "Content-Type": "application/json" },
417
+ body: JSON.stringify({ new_name: "new" }),
418
+ }),
419
+ store,
420
+ "/old/rename",
421
+ { raw: null, allowed: null },
422
+ { actor: "renamer", via: "api" },
423
+ );
424
+ expect(r.status).toBe(200);
425
+ expect(await store.getNoteVersion(n.id, 0)).toMatchObject({
426
+ op: "tag-rename",
427
+ actor: "renamer",
428
+ via: "api",
429
+ });
430
+ });
431
+ test("P20 REST path collision and absent lineage return existing error vocabulary", async () => {
432
+ const n = await fixture();
433
+ await store.deleteNote(n.id);
434
+ await store.createNote("occupant", { path: "p" });
435
+ const before = await store.listNoteVersions(n.id);
436
+ const r = await call(n.id, "/restore", "POST", { version_ix: 0 });
437
+ expect(r.status).toBe(409);
438
+ expect(r.body.error_type).toBe("path_conflict");
439
+ expect(await store.getNote(n.id)).toBeNull();
440
+ expect(await store.listNoteVersions(n.id)).toEqual(before);
441
+ expect(
442
+ (
443
+ await call("01M2GT8W9W19TXZR57NQ5JZD3J", "/restore", "POST", {
444
+ version_ix: 0,
445
+ })
446
+ ).status,
447
+ ).toBe(404);
448
+ });
449
+ test("P22 parser distinguishes zero deleted retention and retains following keys", () => {
450
+ const p = vaultConfigPath("history");
451
+ writeFileSync(
452
+ p,
453
+ `name: history\ncreated_at: "2026-09-14T00:00:00Z"\napi_keys: []\nhistory:\n enabled: false\n min_versions: 5\n max_versions: 9\n max_age_days: 30\n deleted_retention_days: 0\ndescription: following\n`,
454
+ );
455
+ const c = readVaultConfig("history")!;
456
+ expect(c.history).toEqual({
457
+ enabled: false,
458
+ min_versions: 5,
459
+ max_versions: 9,
460
+ max_age_days: 30,
461
+ deleted_retention_days: 0,
462
+ });
463
+ expect(c.description).toBe("following");
464
+ writeVaultConfig(c);
465
+ expect(readVaultConfig("history")!.history).toEqual(c.history);
466
+ });
467
+ test("P7e/P15 full routing requires admin for irreversible erasure", async () => {
468
+ const { generateKeyPair, exportJWK, SignJWT } = await import("jose");
469
+ const { route } = await import("./routing.ts");
470
+ const { resetJwksCache, resetRevocationCache } = await import("./hub-jwt.ts");
471
+ const { privateKey, publicKey } = await generateKeyPair("RS256", {
472
+ extractable: true,
473
+ });
474
+ const jwk = await exportJWK(publicKey);
475
+ const server = Bun.serve({
476
+ port: 0,
477
+ fetch(req) {
478
+ return new URL(req.url).pathname === "/.well-known/jwks.json"
479
+ ? Response.json({
480
+ keys: [{ ...jwk, kid: "history-test", alg: "RS256", use: "sig" }],
481
+ })
482
+ : Response.json({ generated_at: new Date().toISOString(), jtis: [] });
483
+ },
484
+ });
485
+ const origin = `http://127.0.0.1:${server.port}`,
486
+ savedOrigin = process.env.PARACHUTE_HUB_ORIGIN,
487
+ savedJwks = process.env.PARACHUTE_HUB_JWKS_ORIGIN;
488
+ process.env.PARACHUTE_HUB_ORIGIN = origin;
489
+ process.env.PARACHUTE_HUB_JWKS_ORIGIN = origin;
490
+ resetJwksCache();
491
+ resetRevocationCache();
492
+ try {
493
+ const n = await fixture();
494
+ await store.deleteNote(n.id);
495
+ const request = async (verb: string, tagRename = false, doctor = false) => {
496
+ const token = await new SignJWT({
497
+ scope: `vault:history:${verb}`,
498
+ client_id: "history-test",
499
+ })
500
+ .setProtectedHeader({ alg: "RS256", kid: "history-test" })
501
+ .setIssuer(origin)
502
+ .setSubject("history-tester")
503
+ .setAudience("vault.history")
504
+ .setIssuedAt()
505
+ .setExpirationTime("1m")
506
+ .setJti(`history-${verb}`)
507
+ .sign(privateKey);
508
+ const path = doctor
509
+ ? "/vault/history/api/doctor"
510
+ : tagRename
511
+ ? "/vault/history/api/tags/old/rename"
512
+ : `/vault/history/api/notes/${n.id}/versions`;
513
+ const r = await route(
514
+ new Request(`http://localhost${path}`, {
515
+ method: doctor ? "GET" : tagRename ? "POST" : "DELETE",
516
+ headers: {
517
+ Authorization: `Bearer ${token}`,
518
+ "Content-Type": "application/json",
519
+ },
520
+ ...(tagRename ? { body: JSON.stringify({ new_name: "new" }) } : {}),
521
+ }),
522
+ path,
523
+ );
524
+ return { status: r.status, body: (await r.json()) as any };
525
+ };
526
+ const other = await store.createNote("other");
527
+ await store.deleteNote(other.id);
528
+ const doctor = await request("read", false, true);
529
+ expect(doctor.status).toBe(200);
530
+ expect(
531
+ doctor.body.findings.find((f: any) => f.type === "deleted_note_history"),
532
+ ).toMatchObject({
533
+ severity: "info",
534
+ subject: "2 deleted note(s)",
535
+ detail: expect.stringContaining("upper bound"),
536
+ });
537
+ const denied = await request("write");
538
+ expect(denied.status).toBe(403);
539
+ expect(denied.body.required_scope).toBe("vault:admin");
540
+ expect(await store.listNoteVersions(n.id)).toHaveLength(3);
541
+ const erased = await request("admin");
542
+ expect(erased.status).toBe(200);
543
+ expect(erased.body.erased).toBe(true);
544
+ expect((await call(n.id, "/versions")).status).toBe(404);
545
+ await store.upsertTagRecord("old", {});
546
+ const tagged = await store.createNote("#old body");
547
+ expect((await request("admin", true)).status).toBe(200);
548
+ expect(await store.getNoteVersion(tagged.id, 0)).toMatchObject({
549
+ op: "tag-rename",
550
+ actor: "history-tester",
551
+ via: "api",
552
+ });
553
+ } finally {
554
+ server.stop(true);
555
+ if (savedOrigin === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
556
+ else process.env.PARACHUTE_HUB_ORIGIN = savedOrigin;
557
+ if (savedJwks === undefined) delete process.env.PARACHUTE_HUB_JWKS_ORIGIN;
558
+ else process.env.PARACHUTE_HUB_JWKS_ORIGIN = savedJwks;
559
+ resetJwksCache();
560
+ resetRevocationCache();
561
+ }
562
+ });
@@ -5,6 +5,7 @@
5
5
  * implementation to avoid duplicating wikilink hooks and other behavior.
6
6
  */
7
7
 
8
+ import { readVaultConfig } from "./config.ts";
8
9
  import { SqliteStore } from "../core/src/store.ts";
9
10
  import { defaultHookRegistry } from "../core/src/hooks.ts";
10
11
  import type { Store } from "../core/src/types.ts";
@@ -106,12 +107,22 @@ export function getVaultStore(name: string): SqliteStore {
106
107
  // because) semantic search is off — no second read of the resolution.
107
108
  const embeddingProvider = getSharedEmbeddingProvider();
108
109
  store = new SqliteStore(db, {
110
+ history: readVaultConfig(name)?.history,
109
111
  hooks: defaultHookRegistry,
110
112
  embeddingProvider,
111
113
  embeddingDisabledReason: embeddingProvider ? undefined : EMBEDDINGS_DISABLED_REASON,
112
114
  });
113
115
  stores.set(name, store);
114
116
  storeToVault.set(store, name);
117
+ try { store.sweepDeletedHistory(); }
118
+ catch (error) { console.warn(`[vault] deleted history sweep failed for ${name}:`, error); }
119
+ try {
120
+ // Synchronous like the sweep: bounds are checked between notes, so one
121
+ // note may overshoot. Cached opens do not repeat this maintenance pass.
122
+ const summary = store.compactHistory();
123
+ if (summary.notes_scanned > 0 || summary.remaining_candidates > 0) console.log(`[vault] history compaction for ${name}: scanned ${summary.notes_scanned}, compacted ${summary.notes_compacted}, failed ${summary.notes_failed}, deltified ${summary.blobs_deltified}, ${summary.bytes_before}->${summary.bytes_after} bytes in ${summary.duration_ms}ms (stopped_by ${summary.stopped_by}, ${summary.remaining_candidates} candidate(s) remaining)`);
124
+ } catch (error) { console.warn(`[vault] history compaction failed for ${name}:`, error); }
125
+
115
126
  }
116
127
  return store;
117
128
  }