@openparachute/vault 0.1.0 → 0.2.0

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 (87) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/CLAUDE.md +2 -2
  3. package/README.md +289 -44
  4. package/core/src/core.test.ts +802 -346
  5. package/core/src/expand.ts +140 -0
  6. package/core/src/hooks.test.ts +27 -27
  7. package/core/src/hooks.ts +1 -1
  8. package/core/src/mcp.ts +102 -39
  9. package/core/src/notes.ts +82 -4
  10. package/core/src/obsidian.test.ts +11 -11
  11. package/core/src/paths.test.ts +46 -46
  12. package/core/src/schema.ts +18 -2
  13. package/core/src/store.ts +51 -51
  14. package/core/src/types.ts +29 -29
  15. package/core/src/wikilinks.test.ts +61 -61
  16. package/docs/HTTP_API.md +4 -2
  17. package/package.json +1 -1
  18. package/src/auth.test.ts +319 -0
  19. package/src/backup-launchd.test.ts +90 -0
  20. package/src/backup-launchd.ts +169 -0
  21. package/src/backup.test.ts +715 -0
  22. package/src/backup.ts +699 -0
  23. package/src/cli.ts +923 -31
  24. package/src/config.test.ts +173 -0
  25. package/src/config.ts +345 -15
  26. package/src/daemon.ts +136 -0
  27. package/src/doctor.test.ts +356 -0
  28. package/src/health.test.ts +201 -0
  29. package/src/health.ts +115 -0
  30. package/src/launchd.test.ts +91 -0
  31. package/src/launchd.ts +37 -40
  32. package/src/mcp-http.ts +1 -1
  33. package/src/mcp-tools.ts +7 -9
  34. package/src/oauth.test.ts +289 -8
  35. package/src/oauth.ts +57 -12
  36. package/src/published.test.ts +21 -21
  37. package/src/routes.ts +152 -70
  38. package/src/routing.test.ts +347 -0
  39. package/src/routing.ts +365 -0
  40. package/src/server.ts +7 -278
  41. package/src/systemd.test.ts +15 -0
  42. package/src/systemd.ts +18 -11
  43. package/src/triggers.test.ts +7 -7
  44. package/src/triggers.ts +6 -6
  45. package/src/vault-store.ts +20 -3
  46. package/src/vault.test.ts +356 -262
  47. package/.claude/settings.local.json +0 -31
  48. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  49. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  50. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  51. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  52. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  53. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  54. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  55. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  56. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  57. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  58. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  59. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  60. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  61. package/religions-abrahamic-filter.png +0 -0
  62. package/religions-buddhism-v2.png +0 -0
  63. package/religions-buddhism.png +0 -0
  64. package/religions-final.png +0 -0
  65. package/religions-v1.png +0 -0
  66. package/religions-v2.png +0 -0
  67. package/religions-zen.png +0 -0
  68. package/web/README.md +0 -73
  69. package/web/bun.lock +0 -827
  70. package/web/eslint.config.js +0 -23
  71. package/web/index.html +0 -15
  72. package/web/package.json +0 -36
  73. package/web/public/favicon.svg +0 -1
  74. package/web/public/icons.svg +0 -24
  75. package/web/src/App.tsx +0 -149
  76. package/web/src/Graph.tsx +0 -200
  77. package/web/src/NoteView.tsx +0 -155
  78. package/web/src/Sidebar.tsx +0 -186
  79. package/web/src/api.ts +0 -21
  80. package/web/src/index.css +0 -50
  81. package/web/src/main.tsx +0 -10
  82. package/web/src/types.ts +0 -37
  83. package/web/src/utils.ts +0 -107
  84. package/web/tsconfig.app.json +0 -25
  85. package/web/tsconfig.json +0 -7
  86. package/web/tsconfig.node.json +0 -24
  87. package/web/vite.config.ts +0 -15
@@ -0,0 +1,715 @@
1
+ /**
2
+ * Integration tests for the backup module.
3
+ *
4
+ * Strategy mirrors `doctor.test.ts`: spin up an isolated `PARACHUTE_HOME`
5
+ * tempdir, populate it with fake vaults / DBs / config, run backup end-to-
6
+ * end, then unpack the resulting tarball and assert on contents. This
7
+ * exercises the full pipeline — SQLite VACUUM INTO, tar assembly, local
8
+ * destination copy, retention pruning — without requiring a live daemon.
9
+ *
10
+ * We also unit-test the pure helpers (filename round-tripping, retention
11
+ * ordering, tilde expansion) so regressions don't hide behind the
12
+ * integration harness.
13
+ */
14
+
15
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
16
+ import {
17
+ mkdtempSync,
18
+ rmSync,
19
+ writeFileSync,
20
+ mkdirSync,
21
+ readFileSync,
22
+ existsSync,
23
+ readdirSync,
24
+ } from "fs";
25
+ import { join, resolve } from "path";
26
+ import { tmpdir, homedir } from "os";
27
+ import { Database } from "bun:sqlite";
28
+ import { $ } from "bun";
29
+
30
+ import {
31
+ backupFilename,
32
+ parseBackupFilename,
33
+ expandTilde,
34
+ stageSnapshot,
35
+ assembleTarball,
36
+ pruneRetention,
37
+ computeKeepSet,
38
+ listSnapshots,
39
+ tierTally,
40
+ runBackup,
41
+ readLastBackup,
42
+ nextRunEstimate,
43
+ checkDestinationWritable,
44
+ } from "./backup.ts";
45
+ import type { BackupConfig, RetentionPolicy } from "./config.ts";
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Helpers
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /** Build a small SQLite DB with one row, for snapshot-contents assertions. */
52
+ function makeFakeDb(path: string, marker: string) {
53
+ mkdirSync(resolve(path, ".."), { recursive: true });
54
+ const db = new Database(path);
55
+ db.run("CREATE TABLE marker (v TEXT)");
56
+ db.run("INSERT INTO marker VALUES (?)", [marker]);
57
+ db.close();
58
+ }
59
+
60
+ /** Extract a tar.gz into a fresh tempdir and return that dir. */
61
+ async function untar(tarball: string): Promise<string> {
62
+ const dir = mkdtempSync(join(tmpdir(), "untar-"));
63
+ await $`tar -xzf ${tarball} -C ${dir}`.quiet();
64
+ return dir;
65
+ }
66
+
67
+ /** Write a minimal vault.yaml so `listVaultsIn` picks up the vault. */
68
+ function makeFakeVault(vaultsDir: string, name: string, marker: string) {
69
+ const dir = join(vaultsDir, name);
70
+ mkdirSync(dir, { recursive: true });
71
+ writeFileSync(join(dir, "vault.yaml"), `name: ${name}\ncreated_at: "2026-01-01T00:00:00.000Z"\napi_keys:\n`);
72
+ makeFakeDb(join(dir, "vault.db"), marker);
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Pure helpers
77
+ // ---------------------------------------------------------------------------
78
+
79
+ describe("backup — pure helpers", () => {
80
+ test("backupFilename uses timestamp with colons replaced by hyphens", () => {
81
+ const ts = "2026-04-17T08:30:00.000Z";
82
+ expect(backupFilename(ts)).toBe("parachute-backup-2026-04-17T08-30-00.000Z.tar.gz");
83
+ });
84
+
85
+ test("parseBackupFilename round-trips with backupFilename", () => {
86
+ const ts = "2026-04-17T08-30-00.000Z";
87
+ const name = `parachute-backup-${ts}.tar.gz`;
88
+ expect(parseBackupFilename(name)).toEqual({ timestamp: ts });
89
+ });
90
+
91
+ test("parseBackupFilename returns null for unrelated files", () => {
92
+ expect(parseBackupFilename("something.tar.gz")).toBeNull();
93
+ expect(parseBackupFilename("parachute-backup.tar")).toBeNull(); // missing .gz
94
+ expect(parseBackupFilename("README.md")).toBeNull();
95
+ });
96
+
97
+ test("expandTilde expands leading ~/ but leaves absolute paths alone", () => {
98
+ expect(expandTilde("~/foo")).toBe(join(homedir(), "foo"));
99
+ expect(expandTilde("~")).toBe(homedir());
100
+ expect(expandTilde("/absolute/path")).toBe("/absolute/path");
101
+ expect(expandTilde("relative/path")).toBe("relative/path"); // not expanded — by design
102
+ });
103
+
104
+ test("nextRunEstimate returns null for manual, forward-moving Date otherwise", () => {
105
+ const base = new Date("2026-04-17T00:00:00Z");
106
+ expect(nextRunEstimate("manual", base)).toBeNull();
107
+ const daily = nextRunEstimate("daily", base)!;
108
+ expect(daily.getTime()).toBeGreaterThan(base.getTime());
109
+ // Weekly is strictly later than daily.
110
+ const weekly = nextRunEstimate("weekly", base)!;
111
+ expect(weekly.getTime()).toBeGreaterThan(daily.getTime());
112
+ });
113
+ });
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Tiered retention pruning (grandfather / father / son)
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Touch a synthetic snapshot file with the given Date as its embedded
121
+ * timestamp. We produce a filename in the exact format runBackup would
122
+ * produce so the parse/bucket pipeline sees a realistic input.
123
+ */
124
+ function makeSnapshot(dir: string, d: Date): string {
125
+ const name = backupFilename(d.toISOString());
126
+ writeFileSync(join(dir, name), "x");
127
+ return name;
128
+ }
129
+
130
+ /** Build a dense sequence of daily snapshots across a UTC date range. */
131
+ function dailySnapshots(dir: string, startUtc: string, days: number): Date[] {
132
+ const start = new Date(startUtc);
133
+ const out: Date[] = [];
134
+ for (let i = 0; i < days; i++) {
135
+ const d = new Date(start.getTime() + i * 86400_000);
136
+ // Fix to 12:00 UTC so every timestamp lands in the middle of the day —
137
+ // avoids local-midnight edge cases in test assertions.
138
+ d.setUTCHours(12, 0, 0, 0);
139
+ makeSnapshot(dir, d);
140
+ out.push(d);
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /** Full policy: helper so tests read naturally. */
146
+ function policy(p: Partial<RetentionPolicy>): RetentionPolicy {
147
+ return { daily: 0, weekly: 0, monthly: 0, yearly: 0, ...p };
148
+ }
149
+
150
+ describe("backup — tiered retention pruning", () => {
151
+ let dir: string;
152
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "backup-prune-")); });
153
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
154
+
155
+ test("daily tier: keeps last N snapshots unconditionally", () => {
156
+ // 10 consecutive days, keep daily=3 — the 3 most recent survive.
157
+ dailySnapshots(dir, "2026-01-01T00:00:00Z", 10);
158
+ const pruned = pruneRetention(dir, policy({ daily: 3 }));
159
+ expect(pruned).toBe(7);
160
+ const left = readdirSync(dir).filter((n) => n.startsWith("parachute-backup-")).sort();
161
+ expect(left.length).toBe(3);
162
+ // Chronological order: last 3 days are Jan 8, 9, 10.
163
+ expect(left[0]).toMatch(/2026-01-08T/);
164
+ expect(left[2]).toMatch(/2026-01-10T/);
165
+ });
166
+
167
+ test("daily: 0 disables tier but other tiers still apply", () => {
168
+ dailySnapshots(dir, "2026-01-01T00:00:00Z", 30);
169
+ // Only the monthly tier: one snapshot from January kept (the last),
170
+ // everything else pruned.
171
+ pruneRetention(dir, policy({ monthly: 1 }));
172
+ const left = readdirSync(dir).filter((n) => n.startsWith("parachute-backup-")).sort();
173
+ expect(left.length).toBe(1);
174
+ expect(left[0]).toMatch(/2026-01-30T/);
175
+ });
176
+
177
+ test("weekly tier: one snapshot per ISO week, N most recent weeks", () => {
178
+ // 4 weeks of daily snapshots — 28 entries across 4 weeks plus overflow.
179
+ dailySnapshots(dir, "2026-01-05T00:00:00Z", 28); // Mon Jan 5 → Sun Feb 1
180
+ // Weekly=2 alone: keep last-of-week for the last 2 ISO weeks only.
181
+ pruneRetention(dir, policy({ weekly: 2 }));
182
+ const left = readdirSync(dir).filter((n) => n.startsWith("parachute-backup-")).sort();
183
+ // 2 survivors: the Sunday of each of the two most recent weeks.
184
+ expect(left.length).toBe(2);
185
+ });
186
+
187
+ test("monthly tier: last snapshot of each of N months", () => {
188
+ // One snapshot per day across 3 full months.
189
+ dailySnapshots(dir, "2026-01-01T00:00:00Z", 90);
190
+ pruneRetention(dir, policy({ monthly: 2 }));
191
+ const left = readdirSync(dir).filter((n) => n.startsWith("parachute-backup-")).sort();
192
+ expect(left.length).toBe(2);
193
+ // Last-of-Feb (Feb 28, 2026 — not leap year) and last-of-Mar (Mar 31).
194
+ expect(left[0]).toMatch(/2026-02-28T/);
195
+ expect(left[1]).toMatch(/2026-03-31T/);
196
+ });
197
+
198
+ test("yearly: null means unbounded — keeps last-of-year for every year in history", () => {
199
+ // One snapshot per year across 5 years. Every year survives because
200
+ // yearly is null.
201
+ for (const y of [2022, 2023, 2024, 2025, 2026]) {
202
+ makeSnapshot(dir, new Date(Date.UTC(y, 5, 15, 12, 0, 0)));
203
+ }
204
+ const pruned = pruneRetention(dir, policy({ yearly: null }));
205
+ expect(pruned).toBe(0);
206
+ const left = readdirSync(dir).filter((n) => n.startsWith("parachute-backup-")).sort();
207
+ expect(left.length).toBe(5);
208
+ });
209
+
210
+ test("sparse data: alternate-day snapshots across 3 years, full policy", () => {
211
+ // Build roughly 550 snapshots spread every other day across three years.
212
+ // The union tier selection should degrade gracefully across the gaps.
213
+ const entries: Date[] = [];
214
+ for (let y = 2024; y <= 2026; y++) {
215
+ for (let day = 0; day < 365; day += 2) {
216
+ const d = new Date(Date.UTC(y, 0, 1 + day, 12, 0, 0));
217
+ makeSnapshot(dir, d);
218
+ entries.push(d);
219
+ }
220
+ }
221
+ // The full default-shaped policy with unbounded yearly.
222
+ pruneRetention(dir, policy({ daily: 7, weekly: 4, monthly: 12, yearly: null }));
223
+ const left = readdirSync(dir).filter((n) => n.startsWith("parachute-backup-")).sort();
224
+
225
+ // Assertions — exact membership:
226
+ // - daily: the last 7 entries in the ascending list.
227
+ // - weekly: at most 4 weeks; each contributes one keeper.
228
+ // - monthly: the 12 most recent calendar months with data.
229
+ // - yearly (null): every year with data — 2024, 2025, 2026 → 3 keepers
230
+ // (some of which overlap with the monthly/daily tiers; union dedupes).
231
+ // A loose but informative check: the keep count falls well under the
232
+ // full 550-ish and comfortably above the bare daily=7.
233
+ expect(left.length).toBeGreaterThanOrEqual(7);
234
+ // Union of tiers cannot exceed: 7 daily + 4 weekly + 12 monthly + 3 yearly
235
+ // = 26 at the absolute upper bound, minus overlap. We just bound it here.
236
+ expect(left.length).toBeLessThanOrEqual(26);
237
+
238
+ // At least one keeper per year — the yearly tier guarantees this.
239
+ const years = new Set(
240
+ left.map((n) => n.match(/parachute-backup-(\d{4})-/)?.[1]).filter(Boolean),
241
+ );
242
+ expect(years.has("2024")).toBe(true);
243
+ expect(years.has("2025")).toBe(true);
244
+ expect(years.has("2026")).toBe(true);
245
+
246
+ // The most recent snapshot is always kept (it's in the daily tier).
247
+ const mostRecent = backupFilename(entries[entries.length - 1].toISOString());
248
+ expect(left).toContain(mostRecent);
249
+ });
250
+
251
+ test("year-boundary overlap: Dec 31 vs Jan 1 land in different yearly buckets", () => {
252
+ // Snapshots on two consecutive days straddling the year boundary.
253
+ // The yearly tier should keep BOTH — one for each year.
254
+ makeSnapshot(dir, new Date(Date.UTC(2025, 11, 31, 12, 0, 0))); // Dec 31 2025
255
+ makeSnapshot(dir, new Date(Date.UTC(2026, 0, 1, 12, 0, 0))); // Jan 1 2026
256
+ pruneRetention(dir, policy({ yearly: null }));
257
+ const left = readdirSync(dir).filter((n) => n.startsWith("parachute-backup-")).sort();
258
+ expect(left.length).toBe(2);
259
+ });
260
+
261
+ test("end-of-week rollover: Sunday and Monday cross ISO-week boundary", () => {
262
+ // 2026-01-04 is Sun (ISO week 1), 2026-01-05 is Mon (ISO week 2).
263
+ // Weekly=2 should keep one from each week. Noon UTC → most timezones
264
+ // put both on the expected local calendar day.
265
+ const sun = new Date(Date.UTC(2026, 0, 4, 12, 0, 0));
266
+ const mon = new Date(Date.UTC(2026, 0, 5, 12, 0, 0));
267
+ makeSnapshot(dir, sun);
268
+ makeSnapshot(dir, mon);
269
+ const keep = computeKeepSet(listSnapshots(dir), policy({ weekly: 2 }));
270
+ expect(keep.size).toBe(2);
271
+ });
272
+
273
+ test("no-op when every snapshot is kept by some tier", () => {
274
+ dailySnapshots(dir, "2026-01-01T00:00:00Z", 5);
275
+ const pruned = pruneRetention(dir, policy({ daily: 7 }));
276
+ expect(pruned).toBe(0);
277
+ expect(readdirSync(dir).filter((n) => n.startsWith("parachute-backup-")).length).toBe(5);
278
+ });
279
+
280
+ test("ignores non-backup files in the destination directory", () => {
281
+ // iCloud sometimes drops .DS_Store / .icloud placeholder files into a
282
+ // sync dir. Retention must only touch parachute-backup-*.tar.gz.
283
+ writeFileSync(join(dir, ".DS_Store"), "x");
284
+ writeFileSync(join(dir, "README.md"), "x");
285
+ dailySnapshots(dir, "2026-01-01T00:00:00Z", 5);
286
+ pruneRetention(dir, policy({ daily: 1 }));
287
+ // The non-backup files are untouched.
288
+ expect(existsSync(join(dir, ".DS_Store"))).toBe(true);
289
+ expect(existsSync(join(dir, "README.md"))).toBe(true);
290
+ // Only 1 backup file survives.
291
+ expect(readdirSync(dir).filter((n) => n.startsWith("parachute-backup-")).length).toBe(1);
292
+ });
293
+
294
+ test("all tiers 0 / null yearly=0: everything pruned", () => {
295
+ dailySnapshots(dir, "2026-01-01T00:00:00Z", 5);
296
+ const pruned = pruneRetention(dir, policy({}));
297
+ expect(pruned).toBe(5);
298
+ expect(readdirSync(dir).filter((n) => n.startsWith("parachute-backup-")).length).toBe(0);
299
+ });
300
+
301
+ test("a snapshot satisfying multiple tiers is kept once, not duplicated", () => {
302
+ // A single snapshot at year-end/month-end/week-end satisfies all four
303
+ // tiers. Deletion count is 0, on-disk count stays at 1.
304
+ makeSnapshot(dir, new Date(Date.UTC(2026, 11, 31, 12, 0, 0)));
305
+ const pruned = pruneRetention(dir, policy({ daily: 7, weekly: 4, monthly: 12, yearly: null }));
306
+ expect(pruned).toBe(0);
307
+ const left = readdirSync(dir).filter((n) => n.startsWith("parachute-backup-"));
308
+ expect(left.length).toBe(1);
309
+ });
310
+ });
311
+
312
+ describe("backup — tierTally", () => {
313
+ let dir: string;
314
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "backup-tally-")); });
315
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
316
+
317
+ test("reports per-tier contribution counts for `backup status`", () => {
318
+ dailySnapshots(dir, "2026-01-01T00:00:00Z", 60);
319
+ const t = tierTally(dir, policy({ daily: 7, weekly: 4, monthly: 2, yearly: null }));
320
+ expect(t.total).toBe(60);
321
+ expect(t.daily).toBe(7);
322
+ // 60 daily snapshots span ~9 ISO weeks; cap at 4.
323
+ expect(t.weekly).toBe(4);
324
+ // 60 days straddles Jan + Feb + 1 day into March → monthly cap at 2.
325
+ expect(t.monthly).toBe(2);
326
+ // All 60 days are in 2026 → yearly is 1.
327
+ expect(t.yearly).toBe(1);
328
+ });
329
+ });
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // stageSnapshot — SQLite VACUUM INTO + config copy
333
+ // ---------------------------------------------------------------------------
334
+
335
+ describe("backup — stageSnapshot", () => {
336
+ let home: string;
337
+ beforeEach(() => { home = mkdtempSync(join(tmpdir(), "backup-stage-")); });
338
+ afterEach(() => { rmSync(home, { recursive: true, force: true }); });
339
+
340
+ test("snapshots top-level *.db files and mirrors vaults/", async () => {
341
+ // Top-level legacy-style DB.
342
+ makeFakeDb(join(home, "daily.db"), "top-level-marker");
343
+ // Per-vault DBs with yaml config.
344
+ const vaultsDir = join(home, "vaults");
345
+ makeFakeVault(vaultsDir, "default", "default-marker");
346
+ makeFakeVault(vaultsDir, "work", "work-marker");
347
+ // Global config.yaml.
348
+ writeFileSync(join(home, "config.yaml"), "port: 1940\n");
349
+
350
+ const stage = mkdtempSync(join(tmpdir(), "stage-"));
351
+ try {
352
+ const { stagingDir, contents } = await stageSnapshot({
353
+ configDir: home,
354
+ vaultsDir,
355
+ stagingDir: stage,
356
+ });
357
+ expect(stagingDir).toBe(stage);
358
+
359
+ // DB snapshots are at expected paths
360
+ expect(contents.dbSnapshots).toContain("config-daily.db");
361
+ expect(contents.dbSnapshots).toContain(join("vaults", "default", "vault.db"));
362
+ expect(contents.dbSnapshots).toContain(join("vaults", "work", "vault.db"));
363
+
364
+ // Config files are all there
365
+ expect(contents.configFiles).toContain("config.yaml");
366
+ expect(contents.configFiles).toContain(join("vaults", "default", "vault.yaml"));
367
+ expect(contents.configFiles).toContain(join("vaults", "work", "vault.yaml"));
368
+
369
+ // Snapshot preserves DB contents — open a snapshot and verify the
370
+ // marker row round-tripped (proves VACUUM INTO actually ran, not
371
+ // just a zero-byte file).
372
+ const defaultSnap = new Database(join(stage, "vaults", "default", "vault.db"), {
373
+ readonly: true,
374
+ });
375
+ const row = defaultSnap.query("SELECT v FROM marker").get() as { v: string };
376
+ expect(row.v).toBe("default-marker");
377
+ defaultSnap.close();
378
+ } finally {
379
+ rmSync(stage, { recursive: true, force: true });
380
+ }
381
+ });
382
+
383
+ test("empty parachute home: no DBs, no configs, no crash", async () => {
384
+ const stage = mkdtempSync(join(tmpdir(), "stage-empty-"));
385
+ try {
386
+ const { contents } = await stageSnapshot({
387
+ configDir: home,
388
+ vaultsDir: join(home, "vaults"),
389
+ stagingDir: stage,
390
+ });
391
+ expect(contents.dbSnapshots).toEqual([]);
392
+ expect(contents.configFiles).toEqual([]);
393
+ } finally {
394
+ rmSync(stage, { recursive: true, force: true });
395
+ }
396
+ });
397
+ });
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // assembleTarball
401
+ // ---------------------------------------------------------------------------
402
+
403
+ describe("backup — assembleTarball", () => {
404
+ let dir: string;
405
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "backup-tar-")); });
406
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
407
+
408
+ test("produces a readable .tar.gz containing the staging contents", async () => {
409
+ const stage = mkdtempSync(join(tmpdir(), "stage-tar-"));
410
+ try {
411
+ writeFileSync(join(stage, "hello.txt"), "hi");
412
+ mkdirSync(join(stage, "subdir"), { recursive: true });
413
+ writeFileSync(join(stage, "subdir", "nested.txt"), "nested");
414
+
415
+ const tarball = join(dir, "out.tar.gz");
416
+ await assembleTarball(stage, tarball);
417
+
418
+ expect(existsSync(tarball)).toBe(true);
419
+ const extracted = await untar(tarball);
420
+ try {
421
+ expect(readFileSync(join(extracted, "hello.txt"), "utf-8")).toBe("hi");
422
+ expect(readFileSync(join(extracted, "subdir", "nested.txt"), "utf-8")).toBe("nested");
423
+ } finally {
424
+ rmSync(extracted, { recursive: true, force: true });
425
+ }
426
+ } finally {
427
+ rmSync(stage, { recursive: true, force: true });
428
+ }
429
+ });
430
+
431
+ test("produces a valid empty tarball when staging dir is empty", async () => {
432
+ const stage = mkdtempSync(join(tmpdir(), "stage-empty-tar-"));
433
+ try {
434
+ const tarball = join(dir, "empty.tar.gz");
435
+ await assembleTarball(stage, tarball);
436
+ expect(existsSync(tarball)).toBe(true);
437
+ // tar still extracts cleanly — just produces an empty dir.
438
+ const extracted = await untar(tarball);
439
+ try {
440
+ expect(readdirSync(extracted).length).toBe(0);
441
+ } finally {
442
+ rmSync(extracted, { recursive: true, force: true });
443
+ }
444
+ } finally {
445
+ rmSync(stage, { recursive: true, force: true });
446
+ }
447
+ });
448
+ });
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // runBackup — end-to-end
452
+ // ---------------------------------------------------------------------------
453
+
454
+ describe("backup — runBackup end-to-end", () => {
455
+ let home: string;
456
+ let destDir: string;
457
+
458
+ beforeEach(() => {
459
+ home = mkdtempSync(join(tmpdir(), "backup-e2e-"));
460
+ destDir = mkdtempSync(join(tmpdir(), "backup-dest-"));
461
+ });
462
+ afterEach(() => {
463
+ rmSync(home, { recursive: true, force: true });
464
+ rmSync(destDir, { recursive: true, force: true });
465
+ });
466
+
467
+ test("writes a tarball with DB snapshots + configs to a local destination", async () => {
468
+ // Populate: one top-level DB, one vault, global config.
469
+ makeFakeDb(join(home, "daily.db"), "daily-marker");
470
+ const vaultsDir = join(home, "vaults");
471
+ makeFakeVault(vaultsDir, "default", "default-marker");
472
+ writeFileSync(join(home, "config.yaml"), "port: 1940\n");
473
+
474
+ const cfg: BackupConfig = {
475
+ schedule: "manual",
476
+ retention: { daily: 7, weekly: 4, monthly: 12, yearly: null },
477
+ destinations: [{ kind: "local", path: destDir }],
478
+ };
479
+
480
+ const now = "2026-04-17T08:30:00.000Z";
481
+ const result = await runBackup({
482
+ configDir: home,
483
+ vaultsDir,
484
+ backup: cfg,
485
+ now,
486
+ });
487
+
488
+ // Tarball landed at the destination with the expected filename.
489
+ const expectedName = backupFilename(now);
490
+ expect(result.destinations.length).toBe(1);
491
+ expect(result.destinations[0].writtenPath).toBe(join(destDir, expectedName));
492
+ expect(existsSync(join(destDir, expectedName))).toBe(true);
493
+
494
+ // Tarball contents include the vault DB + yaml + top-level DB + config.
495
+ const extracted = await untar(join(destDir, expectedName));
496
+ try {
497
+ expect(existsSync(join(extracted, "config.yaml"))).toBe(true);
498
+ expect(existsSync(join(extracted, "config-daily.db"))).toBe(true);
499
+ expect(existsSync(join(extracted, "vaults", "default", "vault.db"))).toBe(true);
500
+ expect(existsSync(join(extracted, "vaults", "default", "vault.yaml"))).toBe(true);
501
+
502
+ // Verify the snapshotted DB is readable and contains the marker row.
503
+ const snap = new Database(join(extracted, "vaults", "default", "vault.db"), {
504
+ readonly: true,
505
+ });
506
+ const row = snap.query("SELECT v FROM marker").get() as { v: string };
507
+ expect(row.v).toBe("default-marker");
508
+ snap.close();
509
+ } finally {
510
+ rmSync(extracted, { recursive: true, force: true });
511
+ }
512
+
513
+ // runBackup wrote a last-backup metadata file for `status`.
514
+ const last = readLastBackup(home);
515
+ expect(last).not.toBeNull();
516
+ expect(last!.timestamp).toBe(now);
517
+ expect(last!.destinations[0].path).toBe(join(destDir, expectedName));
518
+ });
519
+
520
+ test("retention pruning runs end-to-end: daily=3 leaves 3 most recent", async () => {
521
+ makeFakeDb(join(home, "daily.db"), "m");
522
+ const cfg: BackupConfig = {
523
+ schedule: "manual",
524
+ // Pure daily tier so this test stays focused on pipeline integration
525
+ // rather than tier bucketing (unit-tested above).
526
+ retention: { daily: 3, weekly: 0, monthly: 0, yearly: 0 },
527
+ destinations: [{ kind: "local", path: destDir }],
528
+ };
529
+
530
+ // Simulate 5 runs with monotonically increasing timestamps.
531
+ for (let i = 1; i <= 5; i++) {
532
+ const now = `2026-04-${String(i).padStart(2, "0")}T08:30:00.000Z`;
533
+ await runBackup({
534
+ configDir: home,
535
+ vaultsDir: join(home, "vaults"),
536
+ backup: cfg,
537
+ now,
538
+ });
539
+ }
540
+
541
+ // Only the 3 most recent remain.
542
+ const survivors = readdirSync(destDir).filter((n) => n.startsWith("parachute-backup-")).sort();
543
+ expect(survivors.length).toBe(3);
544
+ // The earliest (Apr 1, Apr 2) are gone; Apr 3/4/5 are kept.
545
+ expect(survivors[0]).toMatch(/2026-04-03T/);
546
+ expect(survivors[2]).toMatch(/2026-04-05T/);
547
+ });
548
+
549
+ test("per-destination failure doesn't abort other destinations", async () => {
550
+ makeFakeDb(join(home, "daily.db"), "m");
551
+ // Use an unwritable path alongside a working one. `/` is a classic
552
+ // unwritable-root target that mkdirSync(recursive: true) on a normal
553
+ // user account will reject with EACCES.
554
+ const cfg: BackupConfig = {
555
+ schedule: "manual",
556
+ retention: { daily: 7, weekly: 4, monthly: 12, yearly: null },
557
+ destinations: [
558
+ { kind: "local", path: "/this/path/should/definitely/not/exist/and/be/unwritable" },
559
+ { kind: "local", path: destDir },
560
+ ],
561
+ };
562
+ const result = await runBackup({
563
+ configDir: home,
564
+ vaultsDir: join(home, "vaults"),
565
+ backup: cfg,
566
+ now: "2026-04-17T08:30:00.000Z",
567
+ });
568
+
569
+ expect(result.destinations.length).toBe(2);
570
+ // Either the mkdirSync throws (expected) or it somehow succeeds. Assert
571
+ // only on the second destination's success — that's the contract:
572
+ // one bad destination must not poison the other.
573
+ const good = result.destinations[1];
574
+ expect(good.error).toBeUndefined();
575
+ expect(good.writtenPath).toBe(join(destDir, backupFilename("2026-04-17T08:30:00.000Z")));
576
+ });
577
+ });
578
+
579
+ // ---------------------------------------------------------------------------
580
+ // checkDestinationWritable
581
+ // ---------------------------------------------------------------------------
582
+
583
+ describe("backup — checkDestinationWritable", () => {
584
+ let dir: string;
585
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "dest-writable-")); });
586
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
587
+
588
+ test("writable path: passes + creates missing directory", () => {
589
+ const sub = join(dir, "nested", "dest");
590
+ const res = checkDestinationWritable({ kind: "local", path: sub });
591
+ expect(res.ok).toBe(true);
592
+ expect(res.path).toBe(sub);
593
+ expect(existsSync(sub)).toBe(true);
594
+ });
595
+
596
+ test("unwritable path: fails with error detail", () => {
597
+ const res = checkDestinationWritable({
598
+ kind: "local",
599
+ path: "/nonexistent/and/root-only/destination",
600
+ });
601
+ expect(res.ok).toBe(false);
602
+ expect(res.error).toBeTruthy();
603
+ });
604
+ });
605
+
606
+ // ---------------------------------------------------------------------------
607
+ // CLI integration — `parachute vault backup` + `backup status`
608
+ // ---------------------------------------------------------------------------
609
+
610
+ describe("CLI — vault backup", () => {
611
+ const CLI = resolve(import.meta.dir, "cli.ts");
612
+
613
+ function runCli(
614
+ args: string[],
615
+ parachuteHome: string,
616
+ ): { exitCode: number; stdout: string; stderr: string } {
617
+ const proc = Bun.spawnSync({
618
+ cmd: ["bun", CLI, ...args],
619
+ env: { ...process.env, PARACHUTE_HOME: parachuteHome },
620
+ stdout: "pipe",
621
+ stderr: "pipe",
622
+ });
623
+ return {
624
+ exitCode: proc.exitCode ?? -1,
625
+ stdout: new TextDecoder().decode(proc.stdout),
626
+ stderr: new TextDecoder().decode(proc.stderr),
627
+ };
628
+ }
629
+
630
+ let home: string;
631
+ let destDir: string;
632
+ beforeEach(() => {
633
+ home = mkdtempSync(join(tmpdir(), "cli-backup-"));
634
+ destDir = mkdtempSync(join(tmpdir(), "cli-backup-dest-"));
635
+ });
636
+ afterEach(() => {
637
+ rmSync(home, { recursive: true, force: true });
638
+ rmSync(destDir, { recursive: true, force: true });
639
+ });
640
+
641
+ test("exits non-zero with a clear message when no destinations are configured", () => {
642
+ writeFileSync(join(home, "config.yaml"), "port: 1940\n");
643
+ const res = runCli(["backup"], home);
644
+ expect(res.exitCode).not.toBe(0);
645
+ expect(res.stderr).toMatch(/No backup destinations configured/);
646
+ });
647
+
648
+ test("`vault backup` writes a tarball when a local destination is configured", async () => {
649
+ // Populate a minimal vault + config with a local destination.
650
+ writeFileSync(
651
+ join(home, "config.yaml"),
652
+ [
653
+ "port: 1940",
654
+ "default_vault: default",
655
+ "backup:",
656
+ " schedule: manual",
657
+ " retention:",
658
+ " daily: 7",
659
+ " weekly: 4",
660
+ " monthly: 12",
661
+ " yearly: null",
662
+ " destinations:",
663
+ " - kind: local",
664
+ ` path: ${destDir}`,
665
+ ].join("\n") + "\n",
666
+ );
667
+ // A minimal vault so stageSnapshot has a DB to snapshot.
668
+ makeFakeVault(join(home, "vaults"), "default", "cli-marker");
669
+
670
+ const res = runCli(["backup"], home);
671
+ expect(res.exitCode, res.stderr).toBe(0);
672
+ expect(res.stdout).toMatch(/Running backup/);
673
+ expect(res.stdout).toMatch(/local →/);
674
+
675
+ const tarballs = readdirSync(destDir).filter((n) => n.startsWith("parachute-backup-"));
676
+ expect(tarballs.length).toBe(1);
677
+
678
+ // Unpack and verify the vault DB + vault.yaml are in there.
679
+ const extracted = await untar(join(destDir, tarballs[0]));
680
+ try {
681
+ expect(existsSync(join(extracted, "vaults", "default", "vault.db"))).toBe(true);
682
+ expect(existsSync(join(extracted, "vaults", "default", "vault.yaml"))).toBe(true);
683
+ expect(existsSync(join(extracted, "config.yaml"))).toBe(true);
684
+ } finally {
685
+ rmSync(extracted, { recursive: true, force: true });
686
+ }
687
+ });
688
+
689
+ test("`vault backup status` prints schedule / destinations / last run", () => {
690
+ writeFileSync(
691
+ join(home, "config.yaml"),
692
+ [
693
+ "port: 1940",
694
+ "backup:",
695
+ " schedule: daily",
696
+ " retention:",
697
+ " daily: 7",
698
+ " weekly: 4",
699
+ " monthly: 12",
700
+ " yearly: null",
701
+ " destinations:",
702
+ " - kind: local",
703
+ ` path: ${destDir}`,
704
+ ].join("\n") + "\n",
705
+ );
706
+ const res = runCli(["backup", "status"], home);
707
+ expect(res.exitCode).toBe(0);
708
+ expect(res.stdout).toMatch(/Schedule:\s+daily/);
709
+ // Tiered retention line: "7 daily / 4 weekly / 12 monthly / ∞ yearly"
710
+ expect(res.stdout).toMatch(/Retention:\s+7 daily \/ 4 weekly \/ 12 monthly \/ ∞ yearly/);
711
+ expect(res.stdout).toMatch(new RegExp(`local:\\s+${destDir.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}`));
712
+ // No backup has been run yet — assert on the "never" line.
713
+ expect(res.stdout).toMatch(/Last run:\s+\(never\)/);
714
+ });
715
+ });