@openparachute/vault 0.4.0 → 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.
- package/README.md +191 -2
- package/core/src/core.test.ts +1295 -526
- package/core/src/mcp.ts +129 -428
- package/core/src/notes.ts +405 -32
- package/core/src/obsidian.ts +55 -177
- package/core/src/portable-md.test.ts +1001 -0
- package/core/src/portable-md.ts +1409 -0
- package/core/src/schema-defaults.ts +233 -171
- package/core/src/schema.ts +104 -32
- package/core/src/store.ts +103 -78
- package/core/src/tag-hierarchy.ts +36 -2
- package/core/src/types.ts +52 -42
- package/core/src/vault-projection.ts +309 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +142 -13
- package/src/auth.ts +29 -0
- package/src/cli.ts +699 -141
- package/src/doctor.test.ts +7 -6
- package/src/hub-jwt.test.ts +16 -5
- package/src/hub-jwt.ts +9 -0
- package/src/mcp-http.ts +4 -2
- package/src/mcp-install-interactive.test.ts +883 -0
- package/src/mcp-install-interactive.ts +412 -0
- package/src/mcp-install.test.ts +957 -5
- package/src/mcp-install.ts +580 -13
- package/src/mcp-tools.ts +101 -90
- package/src/routes.ts +330 -207
- package/src/routing.test.ts +12 -12
- package/src/routing.ts +0 -2
- package/src/tokens-routes.test.ts +11 -4
- package/src/vault.test.ts +1052 -333
- package/core/src/note-schemas.ts +0 -232
|
@@ -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
|
+
});
|