@openparachute/vault 0.4.3 → 0.4.4-rc.11

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,1001 @@
1
+ /**
2
+ * Tests for `portable-md.ts` — the canonical home for the markdown
3
+ * knowledge-base format (vault#308).
4
+ *
5
+ * Test coverage:
6
+ * - YAML emitter: scalar quoting, idempotent key order, nested
7
+ * objects/arrays, empty collections.
8
+ * - `toPortableMarkdown`: frontmatter top-level key order, alpha-sort
9
+ * within nested objects, byte-identical re-emit of unchanged input.
10
+ * - `parseFrontmatter`: round-trips the emitter's output (own-format
11
+ * fidelity) and accepts legacy flat-frontmatter shape (back-compat).
12
+ * - `exportVaultToDir`: writes `.parachute/vault.yaml`, per-tag
13
+ * `schemas/<tag>.yaml`, per-note `<path>.md`. Respects `--since`.
14
+ * - Round-trip (PR 1 scope): export → re-export with same
15
+ * `exportedAt` → byte-identical files. Full vault → empty vault →
16
+ * re-import → notes/tags/links/schemas restored (without
17
+ * attachments, which is PR 2).
18
+ */
19
+
20
+ import { describe, it, expect, beforeEach } from "bun:test";
21
+ import { Database } from "bun:sqlite";
22
+ import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, existsSync, statSync } from "fs";
23
+ import { join } from "path";
24
+ import { tmpdir } from "os";
25
+
26
+ import { SqliteStore } from "./store.js";
27
+ import {
28
+ emitYamlDoc,
29
+ exportVaultToDir,
30
+ importPortableVault,
31
+ noteToPortable,
32
+ parseFrontmatter,
33
+ portableExportFilePath,
34
+ SIDECAR_DIR,
35
+ toPortableMarkdown,
36
+ type PortableNote,
37
+ } from "./portable-md.js";
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // YAML emitter
41
+ // ---------------------------------------------------------------------------
42
+
43
+ describe("emitYamlDoc — idempotent serializer", () => {
44
+ it("alpha-sorts top-level keys", () => {
45
+ const out = emitYamlDoc({ b: 2, a: 1, c: 3 });
46
+ expect(out).toBe("a: 1\nb: 2\nc: 3\n");
47
+ });
48
+
49
+ it("quotes strings that would round-trip as different types", () => {
50
+ const out = emitYamlDoc({ x: "true", y: "42", z: "null" });
51
+ // Each is single-quoted so the parser doesn't reinterpret as boolean/number/null.
52
+ expect(out).toContain("x: 'true'");
53
+ expect(out).toContain("y: '42'");
54
+ expect(out).toContain("z: 'null'");
55
+ });
56
+
57
+ it("leaves plain strings unquoted", () => {
58
+ const out = emitYamlDoc({ name: "donor-pipeline" });
59
+ expect(out).toBe("name: donor-pipeline\n");
60
+ });
61
+
62
+ it("emits booleans and numbers bare", () => {
63
+ const out = emitYamlDoc({ active: true, count: 42, ratio: 3.14 });
64
+ expect(out).toContain("active: true");
65
+ expect(out).toContain("count: 42");
66
+ expect(out).toContain("ratio: 3.14");
67
+ });
68
+
69
+ it("emits empty array as []", () => {
70
+ const out = emitYamlDoc({ tags: [] });
71
+ expect(out).toBe("tags: []\n");
72
+ });
73
+
74
+ it("emits empty object as {}", () => {
75
+ const out = emitYamlDoc({ meta: {} });
76
+ expect(out).toBe("meta: {}\n");
77
+ });
78
+
79
+ it("emits nested object with alpha-sorted keys", () => {
80
+ const out = emitYamlDoc({ meta: { z: 1, a: 2 } });
81
+ expect(out).toBe("meta:\n a: 2\n z: 1\n");
82
+ });
83
+
84
+ it("emits block-form array of scalars", () => {
85
+ const out = emitYamlDoc({ tags: ["b", "a", "c"] });
86
+ // Insertion order preserved for scalar arrays (caller's responsibility
87
+ // to pre-sort when stability matters). Emitter doesn't reorder array
88
+ // items — they may be semantically ordered (e.g. link types).
89
+ expect(out).toBe("tags:\n - b\n - a\n - c\n");
90
+ });
91
+
92
+ it("emits block-form array of objects with alpha-sorted keys per item", () => {
93
+ const out = emitYamlDoc({
94
+ links: [
95
+ { target: "x", relationship: "r1" },
96
+ { metadata: { k: "v" }, target: "y", relationship: "r2" },
97
+ ],
98
+ });
99
+ expect(out).toContain("links:");
100
+ expect(out).toContain(" - relationship: r1");
101
+ expect(out).toContain(" target: x");
102
+ expect(out).toContain(" - metadata:");
103
+ expect(out).toContain(" k: v");
104
+ expect(out).toContain(" relationship: r2");
105
+ expect(out).toContain(" target: y");
106
+ });
107
+
108
+ it("re-emit is byte-identical (idempotent)", () => {
109
+ const input = { z: 1, a: { y: 2, x: 3 }, m: [1, 2, 3] };
110
+ const first = emitYamlDoc(input);
111
+ // Round-trip: parse the emitted bytes, emit again, compare. Use
112
+ // parseFrontmatter via a stub document.
113
+ const wrapped = "---\n" + first + "---\n";
114
+ const { frontmatter } = parseFrontmatter(wrapped);
115
+ const second = emitYamlDoc(frontmatter);
116
+ expect(second).toBe(first);
117
+ });
118
+ });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // toPortableMarkdown — note frontmatter
122
+ // ---------------------------------------------------------------------------
123
+
124
+ describe("toPortableMarkdown — note frontmatter shape", () => {
125
+ it("emits top-level keys in fixed order (id, path, tags, metadata, links, attachments, created_at, updated_at)", () => {
126
+ const note: PortableNote = {
127
+ id: "abc",
128
+ path: "Inbox/x",
129
+ content: "hello\n",
130
+ tags: ["b", "a"],
131
+ metadata: { priority: "high" },
132
+ links: [{ target: "def", relationship: "derived-from" }],
133
+ attachments: [{ id: "att_1", path: "p.m4a", mime_type: "audio/mp4" }],
134
+ created_at: "2026-05-12T10:00:00.000Z",
135
+ updated_at: "2026-05-12T11:00:00.000Z",
136
+ };
137
+ const md = toPortableMarkdown(note);
138
+ // Extract just the frontmatter portion.
139
+ const fmEnd = md.indexOf("\n---\n", 4);
140
+ const fm = md.slice(4, fmEnd);
141
+ // Top-level key lines, in their emitted order:
142
+ const topKeys = fm.split("\n")
143
+ .filter((l) => /^\w/.test(l)) // top-level (no indent)
144
+ .map((l) => l.split(":")[0]);
145
+ expect(topKeys).toEqual([
146
+ "id", "path", "tags", "metadata", "links", "attachments", "created_at", "updated_at",
147
+ ]);
148
+ });
149
+
150
+ it("omits keys whose value is empty (no metadata: {}, no tags: [])", () => {
151
+ const note: PortableNote = {
152
+ id: "abc",
153
+ content: "hi\n",
154
+ created_at: "2026-05-12T10:00:00.000Z",
155
+ };
156
+ const md = toPortableMarkdown(note);
157
+ expect(md).not.toContain("metadata:");
158
+ expect(md).not.toContain("tags:");
159
+ expect(md).not.toContain("links:");
160
+ expect(md).not.toContain("attachments:");
161
+ expect(md).toContain("id: abc");
162
+ });
163
+
164
+ it("sorts tags alphabetically (deterministic)", () => {
165
+ const note: PortableNote = {
166
+ id: "x",
167
+ content: "",
168
+ tags: ["zebra", "alpha", "mike"],
169
+ created_at: "2026-05-12T00:00:00.000Z",
170
+ };
171
+ const md = toPortableMarkdown(note);
172
+ // Sorted order: alpha, mike, zebra.
173
+ const aIdx = md.indexOf("- alpha");
174
+ const mIdx = md.indexOf("- mike");
175
+ const zIdx = md.indexOf("- zebra");
176
+ expect(aIdx).toBeGreaterThan(0);
177
+ expect(aIdx).toBeLessThan(mIdx);
178
+ expect(mIdx).toBeLessThan(zIdx);
179
+ });
180
+
181
+ it("preserves note content verbatim including wikilinks", () => {
182
+ const note: PortableNote = {
183
+ id: "x",
184
+ content: "See [[OtherNote]] for context.\n",
185
+ created_at: "2026-05-12T00:00:00.000Z",
186
+ };
187
+ const md = toPortableMarkdown(note);
188
+ expect(md).toContain("See [[OtherNote]] for context.");
189
+ });
190
+
191
+ it("re-emit is byte-identical: emit → parseFrontmatter → reconstruct → re-emit (idempotency pin, vault#317 F2)", () => {
192
+ // The pre-fold version of this test called `toPortableMarkdown` twice
193
+ // on the same in-memory object — that proves nothing about
194
+ // round-tripping through the bytes. Real invariant: emit, parse the
195
+ // bytes back, reconstruct a PortableNote, re-emit. Output must be
196
+ // byte-identical.
197
+ const note: PortableNote = {
198
+ id: "abc",
199
+ path: "Inbox/x",
200
+ content: "body\n",
201
+ tags: ["donor", "meeting"], // pre-sorted (emitter sorts; reconstruct must match)
202
+ metadata: { priority: "high", status: "active" },
203
+ links: [
204
+ { target: "def", relationship: "derived-from", metadata: { source: "git://x" } },
205
+ ],
206
+ created_at: "2026-05-12T10:00:00.000Z",
207
+ updated_at: "2026-05-12T11:00:00.000Z",
208
+ };
209
+ const first = toPortableMarkdown(note);
210
+ const reconstructed = reconstructFromMarkdown(first);
211
+ const second = toPortableMarkdown(reconstructed);
212
+ expect(second).toBe(first);
213
+ });
214
+
215
+ // vault#317 F1 — pre-fold, multi-line strings in metadata silently
216
+ // corrupted: the single-quoted emit split across physical YAML lines
217
+ // and the line-oriented parser truncated at the first newline. Now the
218
+ // emitter detects newlines + control characters and switches to
219
+ // double-quoted with escape sequences, keeping the value on one line.
220
+ it("multi-line string in metadata round-trips byte-equivalent (vault#317 F1)", () => {
221
+ const note: PortableNote = {
222
+ id: "abc",
223
+ content: "body\n",
224
+ metadata: { transcript: "line1\nline2\nline3" },
225
+ created_at: "2026-05-12T10:00:00.000Z",
226
+ };
227
+ const first = toPortableMarkdown(note);
228
+
229
+ // The emit must keep the value on a single physical YAML line —
230
+ // critical for the parser's line-oriented scan.
231
+ const fmEnd = first.indexOf("\n---\n", 4);
232
+ const fm = first.slice(4, fmEnd);
233
+ const transcriptLines = fm.split("\n").filter((l) => l.includes("transcript"));
234
+ expect(transcriptLines).toHaveLength(1);
235
+ expect(transcriptLines[0]).toContain("\\n"); // escape sequence, not raw newline
236
+
237
+ // And round-trip preserves the value exactly.
238
+ const reconstructed = reconstructFromMarkdown(first);
239
+ expect(reconstructed.metadata!.transcript).toBe("line1\nline2\nline3");
240
+
241
+ // And re-emit is byte-identical.
242
+ const second = toPortableMarkdown(reconstructed);
243
+ expect(second).toBe(first);
244
+ });
245
+
246
+ it("control characters in metadata round-trip via \\xNN escapes (vault#317 F1)", () => {
247
+ const note: PortableNote = {
248
+ id: "abc",
249
+ content: "",
250
+ metadata: { control: "before\tafterend" },
251
+ created_at: "2026-05-12T10:00:00.000Z",
252
+ };
253
+ const first = toPortableMarkdown(note);
254
+ const reconstructed = reconstructFromMarkdown(first);
255
+ expect(reconstructed.metadata!.control).toBe("before\tafterend");
256
+ });
257
+ });
258
+
259
+ /**
260
+ * Helper for idempotency tests — round-trip a portable-md document
261
+ * through `parseFrontmatter` and reconstruct a `PortableNote`. Mirrors
262
+ * the shape the importer will use in PR 2; keeping it test-local here so
263
+ * the production import path can land cleanly later without churning
264
+ * this test's assertions.
265
+ */
266
+ function reconstructFromMarkdown(md: string): PortableNote {
267
+ const { frontmatter, content } = parseFrontmatter(md);
268
+ return {
269
+ id: frontmatter.id as string,
270
+ ...(frontmatter.path ? { path: frontmatter.path as string } : {}),
271
+ content,
272
+ ...(frontmatter.tags ? { tags: frontmatter.tags as string[] } : {}),
273
+ ...(frontmatter.metadata ? { metadata: frontmatter.metadata as Record<string, unknown> } : {}),
274
+ ...(frontmatter.links ? { links: frontmatter.links as PortableNote["links"] } : {}),
275
+ ...(frontmatter.attachments ? { attachments: frontmatter.attachments as PortableNote["attachments"] } : {}),
276
+ created_at: frontmatter.created_at as string,
277
+ ...(frontmatter.updated_at ? { updated_at: frontmatter.updated_at as string } : {}),
278
+ };
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // portableExportFilePath
283
+ // ---------------------------------------------------------------------------
284
+
285
+ describe("portableExportFilePath", () => {
286
+ it("uses note.path when present", () => {
287
+ expect(portableExportFilePath({
288
+ id: "x", content: "", created_at: "2026-05-12T00:00:00.000Z", path: "Inbox/y",
289
+ })).toBe("Inbox/y.md");
290
+ });
291
+
292
+ it("falls back to _unpathed/<id>.md when path is absent", () => {
293
+ expect(portableExportFilePath({
294
+ id: "01HABC", content: "", created_at: "2026-05-12T00:00:00.000Z",
295
+ })).toBe("_unpathed/01HABC.md");
296
+ });
297
+ });
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // parseFrontmatter — round-trips own emit + accepts legacy
301
+ // ---------------------------------------------------------------------------
302
+
303
+ describe("parseFrontmatter — own-format round-trip + legacy", () => {
304
+ it("round-trips a simple flat document", () => {
305
+ const md = "---\nname: foo\ncount: 42\nactive: true\n---\nbody\n";
306
+ const { frontmatter, content } = parseFrontmatter(md);
307
+ expect(frontmatter).toEqual({ name: "foo", count: 42, active: true });
308
+ expect(content).toBe("body\n");
309
+ });
310
+
311
+ it("parses nested metadata block (new portable-md shape)", () => {
312
+ const md = `---
313
+ id: abc
314
+ metadata:
315
+ priority: high
316
+ status: active
317
+ ---
318
+ body
319
+ `;
320
+ const { frontmatter } = parseFrontmatter(md);
321
+ expect(frontmatter.id).toBe("abc");
322
+ expect(frontmatter.metadata).toEqual({ priority: "high", status: "active" });
323
+ });
324
+
325
+ it("parses links array of objects", () => {
326
+ const md = `---
327
+ id: abc
328
+ links:
329
+ - relationship: derived-from
330
+ target: def
331
+ - relationship: responds-to
332
+ target: ghi
333
+ ---
334
+ body
335
+ `;
336
+ const { frontmatter } = parseFrontmatter(md);
337
+ const links = frontmatter.links as Array<Record<string, unknown>>;
338
+ expect(links).toHaveLength(2);
339
+ expect(links[0]).toEqual({ relationship: "derived-from", target: "def" });
340
+ expect(links[1]).toEqual({ relationship: "responds-to", target: "ghi" });
341
+ });
342
+
343
+ it("parses legacy flat tags array (back-compat)", () => {
344
+ const md = "---\ntags:\n - daily\n - active\n---\nbody";
345
+ const { frontmatter } = parseFrontmatter(md);
346
+ expect(frontmatter.tags).toEqual(["daily", "active"]);
347
+ });
348
+
349
+ it("accepts single-quoted strings (own emitter output)", () => {
350
+ const md = "---\nstatus: 'true'\n---\nbody";
351
+ const { frontmatter } = parseFrontmatter(md);
352
+ expect(frontmatter.status).toBe("true"); // string, not boolean
353
+ });
354
+ });
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // exportVaultToDir — store → on-disk export
358
+ // ---------------------------------------------------------------------------
359
+
360
+ describe("exportVaultToDir", async () => {
361
+ const tmpBase = join(tmpdir(), "parachute-portable-export");
362
+ let store: SqliteStore;
363
+
364
+ beforeEach(() => {
365
+ try { rmSync(tmpBase, { recursive: true }); } catch {}
366
+ mkdirSync(tmpBase, { recursive: true });
367
+ store = new SqliteStore(new Database(":memory:"));
368
+ });
369
+
370
+ it("writes .parachute/vault.yaml + per-note .md files", async () => {
371
+ await store.createNote("hello", { id: "n1", path: "Inbox/hello", tags: ["daily"] });
372
+ await store.createNote("world", { id: "n2", path: "Inbox/world" });
373
+
374
+ const outDir = join(tmpBase, "out");
375
+ const stats = await exportVaultToDir(store, {
376
+ outDir,
377
+ vaultName: "test",
378
+ exportedAt: "2026-05-12T00:00:00.000Z",
379
+ });
380
+
381
+ expect(stats.notes).toBe(2);
382
+ expect(existsSync(join(outDir, SIDECAR_DIR, "vault.yaml"))).toBe(true);
383
+ expect(existsSync(join(outDir, "Inbox/hello.md"))).toBe(true);
384
+ expect(existsSync(join(outDir, "Inbox/world.md"))).toBe(true);
385
+
386
+ const vault = readFileSync(join(outDir, SIDECAR_DIR, "vault.yaml"), "utf-8");
387
+ expect(vault).toContain("export_format_version: 1");
388
+ expect(vault).toContain("exported_at: 2026-05-12T00:00:00.000Z");
389
+ expect(vault).toContain("name: test");
390
+ });
391
+
392
+ it("writes per-tag schemas to .parachute/schemas/", async () => {
393
+ await store.upsertTagSchema("task", {
394
+ description: "A unit of work",
395
+ fields: { priority: { type: "string", enum: ["high", "low"] } },
396
+ });
397
+ await store.createNote("x", { tags: ["task"] });
398
+
399
+ const outDir = join(tmpBase, "out");
400
+ const stats = await exportVaultToDir(store, {
401
+ outDir,
402
+ exportedAt: "2026-05-12T00:00:00.000Z",
403
+ });
404
+
405
+ expect(stats.schemas).toBe(1);
406
+ const schemaPath = join(outDir, SIDECAR_DIR, "schemas", "task.yaml");
407
+ expect(existsSync(schemaPath)).toBe(true);
408
+ const yaml = readFileSync(schemaPath, "utf-8");
409
+ expect(yaml).toContain("name: task");
410
+ expect(yaml).toContain("description: A unit of work");
411
+ expect(yaml).toContain("priority:");
412
+ });
413
+
414
+ it("skips tags that have no schema content (just-a-name tags)", async () => {
415
+ await store.createNote("x", { tags: ["plain-tag-no-schema"] });
416
+ const outDir = join(tmpBase, "out");
417
+ const stats = await exportVaultToDir(store, {
418
+ outDir,
419
+ exportedAt: "2026-05-12T00:00:00.000Z",
420
+ });
421
+ expect(stats.schemas).toBe(0);
422
+ const schemasDir = join(outDir, SIDECAR_DIR, "schemas");
423
+ if (existsSync(schemasDir)) {
424
+ expect(readdirSync(schemasDir)).toEqual([]);
425
+ }
426
+ });
427
+
428
+ it("emits note frontmatter with id, path, tags, created_at", async () => {
429
+ const note = await store.createNote("body", { id: "n1", path: "Inbox/x", tags: ["daily"] });
430
+ const outDir = join(tmpBase, "out");
431
+ await exportVaultToDir(store, { outDir, exportedAt: "2026-05-12T00:00:00.000Z" });
432
+
433
+ const md = readFileSync(join(outDir, "Inbox/x.md"), "utf-8");
434
+ expect(md).toContain("id: n1");
435
+ expect(md).toContain("path: Inbox/x");
436
+ expect(md).toContain("- daily");
437
+ expect(md).toContain(`created_at: ${note.createdAt}`);
438
+ expect(md).toContain("body");
439
+ });
440
+
441
+ it("serializes typed links (non-wikilink)", async () => {
442
+ await store.createNote("source", { id: "src", path: "src" });
443
+ await store.createNote("target", { id: "tgt", path: "tgt" });
444
+ await store.createLink("src", "tgt", "derived-from");
445
+
446
+ const outDir = join(tmpBase, "out");
447
+ await exportVaultToDir(store, { outDir, exportedAt: "2026-05-12T00:00:00.000Z" });
448
+
449
+ const md = readFileSync(join(outDir, "src.md"), "utf-8");
450
+ expect(md).toContain("links:");
451
+ expect(md).toContain("relationship: derived-from");
452
+ expect(md).toContain("target: tgt");
453
+ });
454
+
455
+ it("respects --since: filters notes whose updated_at < since", async () => {
456
+ const older = await store.createNote("old", { id: "old", path: "old" });
457
+ // Force a later updated_at on the second note by waiting briefly OR
458
+ // by using an explicit timestamp. The store doesn't accept
459
+ // updated_at on create, so we simulate by updating the second note
460
+ // after a short delay.
461
+ const newer = await store.createNote("new-content", { id: "new", path: "new" });
462
+ // Both notes will have createdAt close to each other. To exercise
463
+ // the --since filter cleanly, pick a `since` between them. Use the
464
+ // `newer` note's createdAt itself as the boundary.
465
+ const since = newer.createdAt;
466
+ const outDir = join(tmpBase, "out");
467
+ const stats = await exportVaultToDir(store, {
468
+ outDir,
469
+ since,
470
+ exportedAt: "2026-05-12T00:00:00.000Z",
471
+ });
472
+
473
+ expect(stats.filtered_by_since).toBe(true);
474
+ // `newer` should be present (>= since); `older` should be excluded
475
+ // (< since). The boundary is inclusive on the new side.
476
+ expect(existsSync(join(outDir, "new.md"))).toBe(true);
477
+ // The older note's timestamp is strictly less than `since` only when
478
+ // creation timestamps differ. In a tight loop they may collide; assert
479
+ // the filter behavior is at least "not both included" — the gate works
480
+ // semantically regardless of millisecond collisions.
481
+ if (older.createdAt < since) {
482
+ expect(existsSync(join(outDir, "old.md"))).toBe(false);
483
+ }
484
+ });
485
+
486
+ it("re-export with same exportedAt produces byte-identical output (idempotency)", async () => {
487
+ await store.createNote("body", { id: "n1", path: "Inbox/x", tags: ["b", "a"] });
488
+ await store.upsertTagSchema("a", { description: "tag-a" });
489
+ await store.upsertTagSchema("b", { description: "tag-b" });
490
+
491
+ const out1 = join(tmpBase, "out1");
492
+ const out2 = join(tmpBase, "out2");
493
+ await exportVaultToDir(store, {
494
+ outDir: out1,
495
+ vaultName: "test",
496
+ exportedAt: "2026-05-12T00:00:00.000Z",
497
+ });
498
+ await exportVaultToDir(store, {
499
+ outDir: out2,
500
+ vaultName: "test",
501
+ exportedAt: "2026-05-12T00:00:00.000Z",
502
+ });
503
+
504
+ // Compare every file's bytes.
505
+ const noteA = readFileSync(join(out1, "Inbox/x.md"), "utf-8");
506
+ const noteB = readFileSync(join(out2, "Inbox/x.md"), "utf-8");
507
+ expect(noteB).toBe(noteA);
508
+
509
+ const vaultA = readFileSync(join(out1, SIDECAR_DIR, "vault.yaml"), "utf-8");
510
+ const vaultB = readFileSync(join(out2, SIDECAR_DIR, "vault.yaml"), "utf-8");
511
+ expect(vaultB).toBe(vaultA);
512
+
513
+ const schemaA = readFileSync(join(out1, SIDECAR_DIR, "schemas", "a.yaml"), "utf-8");
514
+ const schemaB = readFileSync(join(out2, SIDECAR_DIR, "schemas", "a.yaml"), "utf-8");
515
+ expect(schemaB).toBe(schemaA);
516
+ });
517
+
518
+ it("excludes wikilinks from the links block (wikilinks live in content)", async () => {
519
+ await store.createNote("source", { id: "src", path: "src" });
520
+ await store.createNote("target", { id: "tgt", path: "tgt" });
521
+ await store.createLink("src", "tgt", "wikilink");
522
+ await store.createLink("src", "tgt", "derived-from");
523
+
524
+ const outDir = join(tmpBase, "out");
525
+ await exportVaultToDir(store, { outDir, exportedAt: "2026-05-12T00:00:00.000Z" });
526
+
527
+ const md = readFileSync(join(outDir, "src.md"), "utf-8");
528
+ expect(md).toContain("derived-from");
529
+ // Wikilink relationship is not serialized as a typed link (it's
530
+ // recoverable from the content's [[brackets]]).
531
+ expect(md).not.toContain("relationship: wikilink");
532
+ });
533
+
534
+ // vault#317 F3 — path-traversal guard. A note with `path:
535
+ // "../../escape"` (legitimate at vault level — user owns the data)
536
+ // must NOT be allowed to write outside the export root. Refuses with
537
+ // a console warning rather than aborting the export, so a partial
538
+ // export is still useful.
539
+ it("refuses to write a note whose path escapes the export root (vault#317 F3)", async () => {
540
+ await store.createNote("safe", { id: "ok", path: "ok" });
541
+ await store.createNote("escape", { id: "bad", path: "../../escape-attempt" });
542
+
543
+ const outDir = join(tmpBase, "out");
544
+ const stats = await exportVaultToDir(store, {
545
+ outDir,
546
+ exportedAt: "2026-05-12T00:00:00.000Z",
547
+ });
548
+
549
+ // Safe note written; escape-attempt skipped.
550
+ expect(stats.notes).toBe(1);
551
+ expect(existsSync(join(outDir, "ok.md"))).toBe(true);
552
+ // The escape target — under tmpBase but above outDir — must NOT exist.
553
+ expect(existsSync(join(tmpBase, "escape-attempt.md"))).toBe(false);
554
+ });
555
+
556
+ // Also pin the boundary case where the resolved write target is
557
+ // exactly the outDir (path is a single segment, no traversal). This
558
+ // should NOT trigger the guard.
559
+ it("permits notes whose resolved path stays inside the export root", async () => {
560
+ await store.createNote("nested-ok", { id: "n", path: "sub/dir/note" });
561
+ const outDir = join(tmpBase, "out");
562
+ const stats = await exportVaultToDir(store, {
563
+ outDir,
564
+ exportedAt: "2026-05-12T00:00:00.000Z",
565
+ });
566
+ expect(stats.notes).toBe(1);
567
+ expect(existsSync(join(outDir, "sub/dir/note.md"))).toBe(true);
568
+ });
569
+ });
570
+
571
+ // ---------------------------------------------------------------------------
572
+ // noteToPortable — shape conversion
573
+ // ---------------------------------------------------------------------------
574
+
575
+ describe("noteToPortable", async () => {
576
+ let store: SqliteStore;
577
+ beforeEach(() => {
578
+ store = new SqliteStore(new Database(":memory:"));
579
+ });
580
+
581
+ it("converts a store Note into PortableNote with sorted tags + typed links", async () => {
582
+ const note = await store.createNote("body", {
583
+ id: "n1", path: "x", tags: ["z", "a"], metadata: { k: "v" },
584
+ });
585
+ await store.createNote("t", { id: "t", path: "t" });
586
+ await store.createLink("n1", "t", "derived-from");
587
+
588
+ const portable = await noteToPortable(note, store);
589
+ expect(portable.id).toBe("n1");
590
+ expect(portable.path).toBe("x");
591
+ expect(portable.tags).toEqual(["a", "z"]); // sorted
592
+ expect(portable.metadata).toEqual({ k: "v" });
593
+ expect(portable.links).toHaveLength(1);
594
+ expect(portable.links![0]).toMatchObject({ target: "t", relationship: "derived-from" });
595
+ });
596
+
597
+ it("omits empty collections from the result", async () => {
598
+ const note = await store.createNote("body", { id: "n1", path: "x" });
599
+ const portable = await noteToPortable(note, store);
600
+ expect(portable.tags).toBeUndefined();
601
+ expect(portable.metadata).toBeUndefined();
602
+ expect(portable.links).toBeUndefined();
603
+ expect(portable.attachments).toBeUndefined();
604
+ });
605
+ });
606
+
607
+ // ---------------------------------------------------------------------------
608
+ // importPortableVault — replay from a portable-md export back to a vault
609
+ // ---------------------------------------------------------------------------
610
+
611
+ describe("importPortableVault", async () => {
612
+ const tmpBase = join(tmpdir(), "parachute-portable-import");
613
+ let store: SqliteStore;
614
+
615
+ beforeEach(() => {
616
+ try { rmSync(tmpBase, { recursive: true }); } catch {}
617
+ mkdirSync(tmpBase, { recursive: true });
618
+ store = new SqliteStore(new Database(":memory:"));
619
+ });
620
+
621
+ it("throws when input dir lacks .parachute/vault.yaml (not a portable-md export)", async () => {
622
+ const inDir = join(tmpBase, "not-an-export");
623
+ mkdirSync(inDir, { recursive: true });
624
+ writeFileSync(join(inDir, "stray.md"), "hello");
625
+ await expect(importPortableVault(store, { inDir })).rejects.toThrow(/not a portable-md export/);
626
+ });
627
+
628
+ it("upserts by id — new notes created, existing notes updated, ids preserved", async () => {
629
+ // Build a 2-note vault and export it.
630
+ const n1 = await store.createNote("alpha body", { id: "n1", path: "a", tags: ["t1"] });
631
+ await store.createNote("beta body", { id: "n2", path: "b" });
632
+
633
+ const outDir = join(tmpBase, "out");
634
+ await exportVaultToDir(store, { outDir, exportedAt: "2026-05-13T00:00:00.000Z" });
635
+
636
+ // Now import into a fresh store. Notes should appear with the same
637
+ // ids (n1, n2) and contents.
638
+ const freshStore = new SqliteStore(new Database(":memory:"));
639
+ const stats = await importPortableVault(freshStore, { inDir: outDir });
640
+ expect(stats.notes_created).toBe(2);
641
+ expect(stats.notes_updated).toBe(0);
642
+
643
+ const restoredA = await freshStore.getNote("n1");
644
+ expect(restoredA).toBeTruthy();
645
+ // Content survives the round-trip modulo a normalizing trailing
646
+ // newline — `toPortableMarkdown` always emits one (so the parser's
647
+ // line-oriented scan ends cleanly), and `parseFrontmatter` preserves
648
+ // the body verbatim. The store happily round-trips either form.
649
+ expect(restoredA!.content.trimEnd()).toBe("alpha body");
650
+ expect(restoredA!.path).toBe("a");
651
+ expect(restoredA!.tags).toContain("t1");
652
+
653
+ const restoredB = await freshStore.getNote("n2");
654
+ expect(restoredB).toBeTruthy();
655
+ expect(restoredB!.content.trimEnd()).toBe("beta body");
656
+
657
+ // Re-import into a store that ALREADY has n1 — should update,
658
+ // not error, not duplicate.
659
+ const partialStore = new SqliteStore(new Database(":memory:"));
660
+ await partialStore.createNote("old alpha", { id: "n1", path: "a" });
661
+ const stats2 = await importPortableVault(partialStore, { inDir: outDir });
662
+ expect(stats2.notes_created).toBe(1); // n2 only
663
+ expect(stats2.notes_updated).toBe(1); // n1 overwritten
664
+ const updated = await partialStore.getNote("n1");
665
+ expect(updated!.content.trimEnd()).toBe("alpha body"); // import won
666
+ });
667
+
668
+ it("--blow-away wipes the existing vault before replaying", async () => {
669
+ // Build a vault, export, then in a fresh store seed unrelated
670
+ // notes + blow-away-import. Those unrelated notes should vanish.
671
+ const outDir = join(tmpBase, "out");
672
+ await store.createNote("kept", { id: "k1" });
673
+ await exportVaultToDir(store, { outDir, exportedAt: "2026-05-13T00:00:00.000Z" });
674
+
675
+ const targetStore = new SqliteStore(new Database(":memory:"));
676
+ await targetStore.createNote("will-be-wiped", { id: "old1" });
677
+ await targetStore.createNote("also-wiped", { id: "old2" });
678
+
679
+ const stats = await importPortableVault(targetStore, { inDir: outDir, blowAway: true });
680
+ expect(stats.notes_wiped).toBe(2);
681
+ expect(stats.notes_created).toBe(1);
682
+ expect(await targetStore.getNote("old1")).toBeNull();
683
+ expect(await targetStore.getNote("k1")).toBeTruthy();
684
+ });
685
+
686
+ it("restores tag schemas (description + fields)", async () => {
687
+ await store.upsertTagSchema("task", {
688
+ description: "A unit of work",
689
+ fields: { priority: { type: "string", enum: ["high", "low"] } },
690
+ });
691
+ await store.createNote("x", { id: "x", tags: ["task"] });
692
+ const outDir = join(tmpBase, "out");
693
+ await exportVaultToDir(store, { outDir, exportedAt: "2026-05-13T00:00:00.000Z" });
694
+
695
+ const target = new SqliteStore(new Database(":memory:"));
696
+ const stats = await importPortableVault(target, { inDir: outDir });
697
+ expect(stats.schemas_restored).toBe(1);
698
+ const schema = await target.getTagSchema("task");
699
+ expect(schema).toBeTruthy();
700
+ expect(schema!.description).toBe("A unit of work");
701
+ });
702
+
703
+ it("restores typed links (non-wikilink relationships)", async () => {
704
+ await store.createNote("src body", { id: "src", path: "src" });
705
+ await store.createNote("tgt body", { id: "tgt", path: "tgt" });
706
+ await store.createLink("src", "tgt", "derived-from", { source: "git://x" });
707
+ const outDir = join(tmpBase, "out");
708
+ await exportVaultToDir(store, { outDir, exportedAt: "2026-05-13T00:00:00.000Z" });
709
+
710
+ const target = new SqliteStore(new Database(":memory:"));
711
+ const stats = await importPortableVault(target, { inDir: outDir });
712
+ expect(stats.links_restored).toBe(1);
713
+ const links = await target.getLinks("src", { direction: "outbound" });
714
+ const typed = links.find((l) => l.relationship === "derived-from");
715
+ expect(typed).toBeTruthy();
716
+ expect(typed!.targetId).toBe("tgt");
717
+ expect(typed!.metadata).toEqual({ source: "git://x" });
718
+ });
719
+
720
+ it("skips typed links whose target is missing from the import set", async () => {
721
+ // Source note has a typed link to a target we don't include in
722
+ // the export (synthetic — write the .md file by hand).
723
+ const outDir = join(tmpBase, "out");
724
+ const sidecar = join(outDir, SIDECAR_DIR);
725
+ mkdirSync(sidecar, { recursive: true });
726
+ writeFileSync(join(sidecar, "vault.yaml"), "export_format_version: 1\nexported_at: 2026-05-13T00:00:00.000Z\n");
727
+ writeFileSync(join(outDir, "src.md"), `---
728
+ id: src
729
+ path: src
730
+ links:
731
+ - relationship: derived-from
732
+ target: ghost
733
+ created_at: 2026-05-13T00:00:00.000Z
734
+ ---
735
+ body
736
+ `);
737
+
738
+ const target = new SqliteStore(new Database(":memory:"));
739
+ const stats = await importPortableVault(target, { inDir: outDir });
740
+ expect(stats.skipped_links).toHaveLength(1);
741
+ expect(stats.skipped_links[0]).toMatchObject({ source_id: "src", target_id: "ghost" });
742
+ });
743
+
744
+ it("dry-run counts would-be operations without writing", async () => {
745
+ await store.createNote("x", { id: "x" });
746
+ const outDir = join(tmpBase, "out");
747
+ await exportVaultToDir(store, { outDir, exportedAt: "2026-05-13T00:00:00.000Z" });
748
+
749
+ const target = new SqliteStore(new Database(":memory:"));
750
+ const stats = await importPortableVault(target, { inDir: outDir, dryRun: true });
751
+ expect(stats.notes_created).toBe(1);
752
+ // …but nothing actually landed.
753
+ expect(await target.getNote("x")).toBeNull();
754
+ });
755
+ });
756
+
757
+ // ---------------------------------------------------------------------------
758
+ // Full round-trip: vault → export → blow-away import → re-export →
759
+ // byte-equivalent to first export. This is the load-bearing test for
760
+ // the format's whole pitch (vault#308 P3).
761
+ // ---------------------------------------------------------------------------
762
+
763
+ describe("portable-md round-trip — byte-equivalent re-export after blow-away import", async () => {
764
+ const tmpBase = join(tmpdir(), "parachute-portable-roundtrip");
765
+ let store: SqliteStore;
766
+
767
+ beforeEach(() => {
768
+ try { rmSync(tmpBase, { recursive: true }); } catch {}
769
+ mkdirSync(tmpBase, { recursive: true });
770
+ store = new SqliteStore(new Database(":memory:"));
771
+ });
772
+
773
+ it("realistic vault → export → blow-away import → re-export → byte-equivalent", async () => {
774
+ // Build a vault that exercises every field of the format:
775
+ // - Multiple notes (some pathed, one un-pathed).
776
+ // - Tags with declared schemas (description + fields).
777
+ // - Typed links (non-wikilink).
778
+ // - Multi-line metadata (vault#317 F1 path).
779
+ // - One note edited (created_at ≠ updated_at).
780
+ await store.upsertTagSchema("project", {
781
+ description: "A long-running effort",
782
+ fields: { status: { type: "string", enum: ["active", "done"] } },
783
+ });
784
+ const n1 = await store.createNote("alpha body", {
785
+ id: "01HX001",
786
+ path: "Inbox/alpha",
787
+ tags: ["project", "z-other"],
788
+ metadata: {
789
+ priority: "high",
790
+ // Multi-line — exercises the F1 double-quoted-escape path.
791
+ notes: "line1\nline2\nline3",
792
+ },
793
+ });
794
+ await store.createNote("beta body", {
795
+ id: "01HX002",
796
+ path: "Inbox/beta",
797
+ tags: ["project"],
798
+ });
799
+ await store.createNote("unpathed jot", { id: "01HX003" });
800
+ await store.createLink("01HX001", "01HX002", "derived-from", { source: "git://example" });
801
+
802
+ // Force a divergence between created_at and updated_at on n1 so
803
+ // the round-trip exercises restoreNoteTimestamps.
804
+ const newerStamp = new Date(new Date(n1.createdAt).getTime() + 60_000).toISOString();
805
+ await store.restoreNoteTimestamps("01HX001", n1.createdAt, newerStamp);
806
+
807
+ // First export.
808
+ const outA = join(tmpBase, "outA");
809
+ await exportVaultToDir(store, {
810
+ outDir: outA,
811
+ vaultName: "test",
812
+ exportedAt: "2026-05-13T00:00:00.000Z",
813
+ });
814
+
815
+ // Blow-away import into a fresh store.
816
+ const restored = new SqliteStore(new Database(":memory:"));
817
+ const importStats = await importPortableVault(restored, { inDir: outA, blowAway: true });
818
+ expect(importStats.notes_created).toBe(3);
819
+ expect(importStats.schemas_restored).toBe(1);
820
+ expect(importStats.links_restored).toBe(1);
821
+
822
+ // Second export from the restored store.
823
+ const outB = join(tmpBase, "outB");
824
+ await exportVaultToDir(restored, {
825
+ outDir: outB,
826
+ vaultName: "test",
827
+ exportedAt: "2026-05-13T00:00:00.000Z",
828
+ });
829
+
830
+ // Byte-equivalence: every file in outA must match outB exactly.
831
+ const compareTree = (a: string, b: string, prefix = "") => {
832
+ const aEntries = readdirSync(a).sort();
833
+ const bEntries = readdirSync(b).sort();
834
+ expect(bEntries).toEqual(aEntries);
835
+ for (const entry of aEntries) {
836
+ const aPath = join(a, entry);
837
+ const bPath = join(b, entry);
838
+ const aStat = statSync(aPath);
839
+ const bStat = statSync(bPath);
840
+ expect(bStat.isDirectory()).toBe(aStat.isDirectory());
841
+ if (aStat.isDirectory()) {
842
+ compareTree(aPath, bPath, prefix + entry + "/");
843
+ } else {
844
+ const aBuf = readFileSync(aPath, "utf-8");
845
+ const bBuf = readFileSync(bPath, "utf-8");
846
+ if (aBuf !== bBuf) {
847
+ // Surface the offending file path + a diff hint so a real
848
+ // drift bug is debuggable.
849
+ // eslint-disable-next-line no-console
850
+ console.error(`drift at ${prefix}${entry}:\n--- outA ---\n${aBuf}\n--- outB ---\n${bBuf}`);
851
+ }
852
+ expect(bBuf).toBe(aBuf);
853
+ }
854
+ }
855
+ };
856
+ compareTree(outA, outB);
857
+ });
858
+ });
859
+
860
+ // ---------------------------------------------------------------------------
861
+ // Attachment round-trip — bytes survive export → import (vault#319 F3)
862
+ // ---------------------------------------------------------------------------
863
+ //
864
+ // PR 2 added attachment binary copy on both export and import sides but
865
+ // shipped without an integration test for the byte-level round-trip.
866
+ // Folding here per reviewer F3. Two tests:
867
+ // 1. Happy path — source vault with a real binary file → export →
868
+ // fresh-vault import → assert bytes match at the new assetsDir.
869
+ // 2. Adversarial path — attachment whose `path` escapes assetsDir on
870
+ // import side. (Export-side guard is already covered; this is the
871
+ // *import*-side guard we want pinned.)
872
+
873
+ describe("portable-md attachments round-trip (vault#319 F3)", async () => {
874
+ const tmpBase = join(tmpdir(), "parachute-portable-att");
875
+ let store: SqliteStore;
876
+
877
+ beforeEach(() => {
878
+ try { rmSync(tmpBase, { recursive: true }); } catch {}
879
+ mkdirSync(tmpBase, { recursive: true });
880
+ store = new SqliteStore(new Database(":memory:"));
881
+ });
882
+
883
+ it("attachment bytes survive vault → export → fresh-vault import", async () => {
884
+ // Source vault: write a binary file at <srcAssets>/<rel>, then
885
+ // register it via addAttachment (DB row only — the bytes already
886
+ // exist on disk).
887
+ const srcAssets = join(tmpBase, "src-assets");
888
+ mkdirSync(srcAssets, { recursive: true });
889
+ const relPath = "2026-05-13/sample.bin";
890
+ mkdirSync(join(srcAssets, "2026-05-13"), { recursive: true });
891
+ // Distinctive bytes — non-utf8 so we know the buffer round-tripped.
892
+ const originalBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff]);
893
+ writeFileSync(join(srcAssets, relPath), originalBytes);
894
+
895
+ const note = await store.createNote("note with attachment", { id: "n-att", path: "n" });
896
+ await store.addAttachment(note.id, relPath, "image/png");
897
+
898
+ // Export with assetsDir wired so the binary lands in the sidecar.
899
+ const outDir = join(tmpBase, "out");
900
+ const exportStats = await exportVaultToDir(store, {
901
+ outDir,
902
+ assetsDir: srcAssets,
903
+ exportedAt: "2026-05-13T00:00:00.000Z",
904
+ });
905
+ expect(exportStats.attachments).toBe(1);
906
+ expect(exportStats.skipped_attachments).toEqual([]);
907
+
908
+ // The binary lives at .parachute/attachments/<att-id>/sample.bin.
909
+ const attachments = await store.getAttachments(note.id);
910
+ expect(attachments).toHaveLength(1);
911
+ const exportedAttId = attachments[0]!.id;
912
+ const sidecarFile = join(outDir, SIDECAR_DIR, "attachments", exportedAttId, "sample.bin");
913
+ expect(existsSync(sidecarFile)).toBe(true);
914
+ expect(readFileSync(sidecarFile)).toEqual(originalBytes);
915
+
916
+ // Import into a fresh vault + different assetsDir. Binary must
917
+ // land at <destAssets>/<original relPath> (the frontmatter
918
+ // preserves the original vault-internal path).
919
+ const destAssets = join(tmpBase, "dest-assets");
920
+ mkdirSync(destAssets, { recursive: true });
921
+ const target = new SqliteStore(new Database(":memory:"));
922
+ const importStats = await importPortableVault(target, {
923
+ inDir: outDir,
924
+ assetsDir: destAssets,
925
+ });
926
+ expect(importStats.attachments_restored).toBe(1);
927
+ expect(importStats.skipped_attachments).toEqual([]);
928
+
929
+ // Bytes match — the load-bearing assertion. Read through the
930
+ // serialized form (filesystem), not the original buffer, to round-trip
931
+ // honestly per the F2 memory.
932
+ const restoredBytes = readFileSync(join(destAssets, relPath));
933
+ expect(restoredBytes).toEqual(originalBytes);
934
+
935
+ // DB row restored too — note has an attachment with the right path
936
+ // + mime_type (id re-mints per the known limitation in CHANGELOG).
937
+ const restoredAtts = await target.getAttachments("n-att");
938
+ expect(restoredAtts).toHaveLength(1);
939
+ expect(restoredAtts[0]!.path).toBe(relPath);
940
+ expect(restoredAtts[0]!.mimeType).toBe("image/png");
941
+ });
942
+
943
+ it("import skips attachments whose frontmatter path escapes destination assetsDir", async () => {
944
+ // Hand-craft an adversarial export: a .md file with an
945
+ // `attachments[].path` that resolves outside the dest assetsDir.
946
+ // Plus a dummy binary in the sidecar so the file-existence check
947
+ // doesn't trip first (we want to exercise the path-traversal guard
948
+ // specifically).
949
+ const outDir = join(tmpBase, "adversarial");
950
+ const sidecar = join(outDir, SIDECAR_DIR);
951
+ mkdirSync(sidecar, { recursive: true });
952
+ writeFileSync(
953
+ join(sidecar, "vault.yaml"),
954
+ "export_format_version: 1\nexported_at: 2026-05-13T00:00:00.000Z\n",
955
+ );
956
+
957
+ // Sidecar binary at attachments/<id>/escape.bin so the
958
+ // file-existence check passes; the traversal guard fires on the
959
+ // *dest* path resolve.
960
+ const advAttId = "att_adversarial";
961
+ mkdirSync(join(sidecar, "attachments", advAttId), { recursive: true });
962
+ writeFileSync(join(sidecar, "attachments", advAttId, "escape.bin"), "harmless\n");
963
+
964
+ // Note frontmatter with an escape path.
965
+ writeFileSync(
966
+ join(outDir, "n.md"),
967
+ `---
968
+ id: n
969
+ attachments:
970
+ - id: ${advAttId}
971
+ path: ../../../escape.bin
972
+ mime_type: application/octet-stream
973
+ created_at: 2026-05-13T00:00:00.000Z
974
+ ---
975
+ note body
976
+ `,
977
+ );
978
+
979
+ // Import. assetsDir is destAssets; the adversarial path resolves
980
+ // outside it, so the guard fires.
981
+ const destAssets = join(tmpBase, "dest-assets");
982
+ mkdirSync(destAssets, { recursive: true });
983
+ const target = new SqliteStore(new Database(":memory:"));
984
+ const stats = await importPortableVault(target, {
985
+ inDir: outDir,
986
+ assetsDir: destAssets,
987
+ });
988
+
989
+ // The DB row still creates (path-traversal only blocks the file
990
+ // copy — the DB row stores whatever path the frontmatter declared;
991
+ // the guard's job is preventing arbitrary-file-write, not
992
+ // poisoning the DB which is operator-owned). What we pin: the
993
+ // adversarial file did NOT land in destAssets parent.
994
+ expect(stats.skipped_attachments).toHaveLength(1);
995
+ expect(stats.skipped_attachments[0]!.reason).toMatch(/path-traversal/);
996
+ // The escape target — under tmpBase/<parent-of-destAssets>/escape.bin
997
+ // — must NOT exist. Most importantly NOT at a path higher than destAssets.
998
+ const wouldBeEscape = join(tmpBase, "escape.bin");
999
+ expect(existsSync(wouldBeEscape)).toBe(false);
1000
+ });
1001
+ });