@openparachute/vault 0.7.9-rc.5 → 0.7.9-rc.6

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,91 @@
1
+ import { test, expect, beforeEach, afterEach, spyOn } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { generateKeyPair, exportJWK, SignJWT } from "jose";
6
+ import { handleMcp } from "./mcp-http.ts";
7
+ import { route } from "./routing.ts";
8
+ import { resetJwksCache, resetRevocationCache } from "./hub-jwt.ts";
9
+ import { writeVaultConfig, readVaultConfig, vaultConfigPath } from "./config.ts";
10
+ import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
11
+ import { generateScopedMcpTools } from "./mcp-tools.ts";
12
+ import { BunSqliteStore } from "../core/src/store.ts";
13
+ import * as history from "../core/src/history.ts";
14
+
15
+ let taskDir: string, savedHome: string | undefined, savedOrigin: string | undefined, savedJwks: string | undefined, origin: string;
16
+ let store: ReturnType<typeof getVaultStore>, server: ReturnType<typeof Bun.serve>, privateKey: CryptoKey;
17
+ beforeEach(async () => {
18
+ savedHome = process.env.PARACHUTE_HOME;
19
+ savedOrigin = process.env.PARACHUTE_HUB_ORIGIN;
20
+ savedJwks = process.env.PARACHUTE_HUB_JWKS_ORIGIN;
21
+ taskDir = mkdtempSync(join(tmpdir(), "vault-compact-"));
22
+ process.env.PARACHUTE_HOME = taskDir;
23
+ writeVaultConfig({ name: "compact", api_keys: [], created_at: new Date().toISOString() });
24
+ store = getVaultStore("compact");
25
+ const keys = await generateKeyPair("RS256", { extractable: true });
26
+ privateKey = keys.privateKey;
27
+ const jwk = await exportJWK(keys.publicKey);
28
+ server = Bun.serve({ port: 0, fetch(req) { return new URL(req.url).pathname === "/.well-known/jwks.json" ? Response.json({ keys: [{ ...jwk, kid: "compact-test", alg: "RS256", use: "sig" }] }) : Response.json({ generated_at: new Date().toISOString(), jtis: [] }); } });
29
+ origin = `http://127.0.0.1:${server.port}`;
30
+ process.env.PARACHUTE_HUB_ORIGIN = origin;
31
+ process.env.PARACHUTE_HUB_JWKS_ORIGIN = origin;
32
+ resetJwksCache();
33
+ resetRevocationCache();
34
+ });
35
+ afterEach(() => { server.stop(true); clearVaultStoreCache(); rmSync(taskDir, { recursive: true, force: true }); for (const [key, value] of [["PARACHUTE_HOME", savedHome], ["PARACHUTE_HUB_ORIGIN", savedOrigin], ["PARACHUTE_HUB_JWKS_ORIGIN", savedJwks]] as const) {
36
+ if (value === undefined)
37
+ delete process.env[key];
38
+ else
39
+ process.env[key] = value;
40
+ } resetJwksCache(); resetRevocationCache(); });
41
+ async function call(path: string, method = "GET", body?: unknown, verb = "admin", scoped = false) {
42
+ const token = await new SignJWT({ scope: `vault:compact:${verb}`, client_id: "compact-test", ...(scoped ? { permissions: { scoped_tags: ["journal"] } } : {}) }).setProtectedHeader({ alg: "RS256", kid: "compact-test" }).setIssuer(origin).setSubject("tester").setAudience("vault.compact").setIssuedAt().setExpirationTime("5m").setJti(crypto.randomUUID()).sign(privateKey);
43
+ const full = `/vault/compact/api${path}`, response = await route(new Request(`http://localhost${full}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }), new URL(`http://localhost${full}`).pathname);
44
+ return { status: response.status, body: await response.json() as any };
45
+ }
46
+
47
+ import { applyImportedNote, beginImportRun, importTargetDigest } from "../core/src/history-import.ts";
48
+ test("imported REST references restore by ID or encoded path and preserve native keys", async () => {
49
+ const note = await store.createNote("native", { path: "folder/imported", tags: ["journal"] });
50
+ await store.updateNote(note.id, { content: "current" });
51
+ const run = { run_id: "rest", source_fingerprint: "source", tip: "tip", options_digest: "options" };
52
+ beginImportRun(store.db, run);
53
+ applyImportedNote(store.db, { run, noteId: note.id, targetDigest: importTargetDigest(store.db, note.id), policy: history.resolveHistoryPolicy({ min_versions: 20 }), now: Date.now(), observations: [{ content: "archive", path: "old/path", metadata: { old: true }, extension: "md", created_at: null, observed_at: new Date().toISOString(), commit: "commit", blob: "blob" }] });
54
+ for (const ref of [note.id, encodeURIComponent("folder/imported")]) {
55
+ const got = await call(`/notes/${ref}/imports/0`);
56
+ expect(got.status).toBe(200);
57
+ expect(got.body.content).toBe("archive");
58
+ expect(got.body.origin).toBe("git-import");
59
+ expect(got.body.import_ix).toBe(0);
60
+ expect(got.body.version_ix).toBeUndefined();
61
+ }
62
+ const native = await call(`/notes/${note.id}/versions/0`);
63
+ expect(native.body.version_ix).toBe(0);
64
+ expect(native.body.origin).toBeUndefined();
65
+ const list = await call(`/notes/${note.id}/versions`);
66
+ expect(list.body.versions.map((v: any) => v.version_ix ?? v.origin)).toEqual([0, "git-import"]);
67
+ for (const body of [{ origin: "git-import" }, { import_ix: 0 }, { origin: "git-import", import_ix: 0, version_ix: 0 }, { origin: "git-import", import_ix: -1 }]) expect((await call(`/notes/${note.id}/restore`, "POST", body)).status).toBe(400);
68
+ expect((await call(`/notes/${note.id}/versions/-1`)).status).toBe(400);
69
+ const restored = await call(`/notes/${note.id}/restore`, "POST", { origin: "git-import", import_ix: 0 });
70
+ expect(restored.status).toBe(200);
71
+ expect(restored.body.restored_from).toEqual({ origin: "git-import", import_ix: 0 });
72
+ expect((await store.getNote(note.id))!.content).toBe("archive");
73
+ expect((await store.getNote(note.id))!.path).toBe("folder/imported");
74
+ const missing = await call(`/notes/${note.id}/imports/99`);
75
+ expect(missing.status).toBe(404);
76
+ expect(missing.body).toMatchObject({ origin: "git-import", import_ix: 99 });
77
+ await store.deleteNote(note.id);
78
+ const recreated = await call(`/notes/${note.id}/restore`, "POST", { origin: "git-import", import_ix: 0 });
79
+ expect(recreated.status).toBe(200);
80
+ expect(recreated.body.recreated).toBe(true);
81
+ expect((await store.getNote(note.id))!.content).toBe("archive");
82
+ const hash = store.db.query("SELECT content_hash FROM note_versions WHERE note_id=? AND version_ix=-1").get(note.id) as { content_hash: string };
83
+ store.db.query("UPDATE note_blobs SET encoding='unknown' WHERE hash=?").run(hash.content_hash);
84
+ const broken = await call(`/notes/${note.id}/imports/0`);
85
+ expect(broken.status).toBe(409);
86
+ expect(broken.body).toMatchObject({ origin: "git-import", import_ix: 0, error_type: "history_unrecoverable" });
87
+ expect(JSON.stringify(broken.body)).not.toContain("version_ix");
88
+ expect(JSON.stringify(broken.body)).not.toContain("@-1");
89
+ const privateNote = await store.createNote("private", { tags: ["secret"] });
90
+ expect((await call(`/notes/${privateNote.id}/imports/0`, "GET", undefined, "read", true)).status).toBe(404);
91
+ });
@@ -0,0 +1,318 @@
1
+ import { test, expect } from "bun:test";
2
+ import { createHash } from "node:crypto";
3
+ import { Database } from "bun:sqlite";
4
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import { spawnSync } from "node:child_process";
8
+ import { stageArchive, runHistoryImport, type ImportManifest, normalizeHistorySelections, type HistorySelections } from "./history-import.ts";
9
+ import { writeVaultConfig, writeGlobalConfig, vaultDbPath } from "./config.ts";
10
+ import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
11
+ import { getImportedVersion } from "../core/src/history-import.ts";
12
+ import { historyMirrorStatePath, readHistoryMirrorPhase, mirrorConfigPath } from "./mirror-config.ts";
13
+ const noteId = "01M2KT1VQBHNZ99V5KSGXP6VSE";
14
+ function git(dir: string, ...args: string[]) {
15
+ const p = spawnSync("git", ["-C", dir, ...args], { encoding: "utf8", env: { ...process.env, GIT_AUTHOR_NAME: "test", GIT_AUTHOR_EMAIL: "test@example.test", GIT_COMMITTER_NAME: "test", GIT_COMMITTER_EMAIL: "test@example.test" } });
16
+ if (p.status !== 0) throw new Error(p.stderr); return p.stdout.trim();
17
+ }
18
+ function repoAt(dir: string) { mkdirSync(dir); git(dir, "init", "-b", "main"); return dir; }
19
+ function commit(dir: string, content: string, id = noteId, path = "entry.md") {
20
+ writeFileSync(join(dir, path), `---\nid: ${id}\npath: entry\ncreated_at: 2026-01-01T00:00:00Z\n---\n${content}`);
21
+ git(dir, "add", "."); git(dir, "commit", "-m", "observation"); return git(dir, "rev-parse", "HEAD");
22
+ }
23
+ test("git staging preserves exported CRLF bodies and A-B-A while quarantining duplicate IDs", () => {
24
+ const dir = mkdtempSync(join(tmpdir(), "import-stage-"));
25
+ const db = new Database(":memory:");
26
+ try {
27
+ const repo = repoAt(join(dir, "repo"));
28
+ commit(repo, "A\r\n"); commit(repo, "B\r\n"); const tip = commit(repo, "A\r\n");
29
+ const result = stageArchive(repo, tip, db);
30
+ expect(result.commits).toBe(3);
31
+ const rows = db.query("SELECT row_json FROM observations ORDER BY seq").all() as { row_json: string }[];
32
+ expect(rows.map(r => JSON.parse(r.row_json).content)).toEqual(["A\r\n", "B\r\n", "A\r\n"]);
33
+ const duplicate = commit(repo, "different", noteId, "duplicate.md");
34
+ const second = new Database(":memory:");
35
+ try { stageArchive(repo, duplicate, second); expect(second.query("SELECT reason FROM quarantine").all()).toEqual([{ reason: "duplicate_id" }]); expect(second.query("SELECT * FROM observations").all()).toEqual([]); } finally { second.close(); }
36
+ } finally { db.close(); rmSync(dir, { recursive: true, force: true }); }
37
+ });
38
+ test("offline prepare applies verified bundle, retries without resurrection, then retires", async () => {
39
+ const dir = mkdtempSync(join(tmpdir(), "import-cutover-")), saved = process.env.PARACHUTE_HOME;
40
+ process.env.PARACHUTE_HOME = join(dir, "home");
41
+ try {
42
+ // Reserve then release a random loopback port so the guard cannot hit production.
43
+ const probe = Bun.serve({ port: 0, fetch: () => new Response() }); const port = probe.port!; probe.stop(true);
44
+ writeGlobalConfig({ port });
45
+ writeVaultConfig({ name: "test", api_keys: [], created_at: new Date().toISOString() });
46
+ const store = getVaultStore("test"); const note = await store.createNote("native"); clearVaultStoreCache();
47
+ const repo = repoAt(join(dir, "repo")); commit(repo, "old", note.id); const tip = commit(repo, "recent", note.id);
48
+ const diagnostic = join(dir, "plan.json"), manifest = join(dir, "final.json"), archive = join(dir, "archive.bundle");
49
+ const oldSchema = new Database(vaultDbPath("test"));
50
+ oldSchema.exec("DROP TABLE history_import_refs; DROP TABLE history_import_receipts; DROP TABLE history_import_runs; UPDATE schema_version SET version=30");
51
+ oldSchema.close();
52
+ const before = readFileSync(vaultDbPath("test"));
53
+ await runHistoryImport(["plan", "--vault", "test", "--source", repo, "--through", tip, "--output", diagnostic]);
54
+ expect(readFileSync(vaultDbPath("test"))).toEqual(before);
55
+ await runHistoryImport(["prepare", "--vault", "test", "--source", repo, "--through", tip, "--archive", archive, "--output", manifest]);
56
+ expect(readHistoryMirrorPhase("test")).toBe("paused");
57
+ expect(() => getVaultStore("test")).toThrow("paused");
58
+ expect(readFileSync(mirrorConfigPath("test"), "utf8")).toContain("enabled: false");
59
+ const m = JSON.parse(readFileSync(manifest, "utf8")) as ImportManifest;
60
+ expect(m.notes).toHaveLength(1);
61
+ commit(repo, "changed after bundle", note.id);
62
+ const savedGitDir = process.env.GIT_DIR;
63
+ process.env.GIT_DIR = join(dir, "not-the-archive");
64
+ try { await runHistoryImport(["apply", "--vault", "test", "--manifest", manifest, "--archive", archive]); }
65
+ finally { if (savedGitDir === undefined) delete process.env.GIT_DIR; else process.env.GIT_DIR = savedGitDir; }
66
+ const db = new Database(vaultDbPath("test"));
67
+ try {
68
+ expect(getImportedVersion(db, note.id, 0)?.content).toBe("recent");
69
+ expect(db.query("SELECT content FROM notes WHERE id=?").get(note.id)).toEqual({ content: "native" });
70
+ db.exec("DELETE FROM note_versions");
71
+ } finally { db.close(); }
72
+ await runHistoryImport(["apply", "--vault", "test", "--manifest", manifest, "--archive", archive]);
73
+ const check = new Database(vaultDbPath("test"), { readonly: true });
74
+ try { expect(check.query("SELECT count(*) n FROM note_versions").get()).toEqual({ n: 0 }); } finally { check.close(); }
75
+ await expect(runHistoryImport(["cancel", "--vault", "test"])).rejects.toThrow("Receipts exist");
76
+ await runHistoryImport(["retire", "--vault", "test", "--manifest", manifest]);
77
+ expect(readHistoryMirrorPhase("test")).toBe("retired");
78
+ expect(await runHistoryImport(["retire", "--vault", "test", "--manifest", manifest])).toMatchObject({ retired: true });
79
+ expect(getVaultStore("test")).toBeDefined();
80
+ } finally { clearVaultStoreCache(); if (saved === undefined) delete process.env.PARACHUTE_HOME; else process.env.PARACHUTE_HOME = saved; rmSync(dir, { recursive: true, force: true }); }
81
+ }, 30_000);
82
+
83
+ for (const priorConfig of [null, "mirror:\n enabled: true\n location: internal\n"]) test(`prepare cancellation restores ${priorConfig === null ? "absent" : "present"} config and explicit active marker`, async () => {
84
+ const dir = mkdtempSync(join(tmpdir(), "import-cancel-")), saved = process.env.PARACHUTE_HOME;
85
+ process.env.PARACHUTE_HOME = join(dir, "home");
86
+ try {
87
+ const probe = Bun.serve({ port: 0, fetch: () => new Response() }); const port = probe.port!; probe.stop(true); writeGlobalConfig({ port });
88
+ writeVaultConfig({ name: "test", api_keys: [], created_at: new Date().toISOString() });
89
+ const store = getVaultStore("test"), note = await store.createNote("native"); clearVaultStoreCache();
90
+ if (priorConfig !== null) writeFileSync(mirrorConfigPath("test"), priorConfig);
91
+ const repo = repoAt(join(dir, "repo")), tip = commit(repo, "old", note.id);
92
+ await runHistoryImport(["prepare", "--vault", "test", "--source", repo, "--through", tip, "--archive", join(dir, "archive.bundle"), "--output", join(dir, "final.json")]);
93
+ if (priorConfig === null) {
94
+ // Interrupted cancellation after restoring config/marker but before archiving its journal.
95
+ rmSync(mirrorConfigPath("test"));
96
+ writeFileSync(historyMirrorStatePath("test"), JSON.stringify({ phase: "active" }));
97
+ }
98
+ await runHistoryImport(["cancel", "--vault", "test"]);
99
+ if (priorConfig === null) expect(() => readFileSync(mirrorConfigPath("test"))).toThrow();
100
+ else expect(readFileSync(mirrorConfigPath("test"), "utf8")).toBe(priorConfig);
101
+ expect(JSON.parse(readFileSync(historyMirrorStatePath("test"), "utf8"))).toEqual({ phase: "active" });
102
+ } finally { clearVaultStoreCache(); if (saved === undefined) delete process.env.PARACHUTE_HOME; else process.env.PARACHUTE_HOME = saved; rmSync(dir, { recursive: true, force: true }); }
103
+ });
104
+
105
+ test("ambiguous sidecars quarantine both identities; malformed YAML diagnostics contain no source text", () => {
106
+ const dir = mkdtempSync(join(tmpdir(), "import-sidecar-")), stage = new Database(":memory:");
107
+ try {
108
+ const repo = repoAt(join(dir, "repo"));
109
+ mkdirSync(join(repo, ".parachute/notes-meta"), { recursive: true });
110
+ const other = "01M2KT4GXMQDNB8ZFXY05MHT6B";
111
+ for (const id of [noteId, other]) writeFileSync(join(repo, `.parachute/notes-meta/${id}.yaml`), `id: ${id}\npath: shared\nextension: json\n`);
112
+ writeFileSync(join(repo, "shared.json"), '{"value":1}');
113
+ writeFileSync(join(repo, "bad.md"), '---\nid: [PRIVATE_SENTINEL\n---\nbody');
114
+ git(repo, "add", "."); git(repo, "commit", "-m", "ambiguous");
115
+ stageArchive(repo, git(repo, "rev-parse", "HEAD"), stage);
116
+ expect(stage.query("SELECT note_id FROM quarantine ORDER BY note_id").all()).toEqual([noteId, other].sort().map(note_id => ({ note_id })));
117
+ expect(stage.query("SELECT * FROM observations").all()).toEqual([]);
118
+ expect(JSON.stringify(stage.query("SELECT * FROM issues").all())).not.toContain("PRIVATE_SENTINEL");
119
+ } finally { stage.close(); rmSync(dir, { recursive: true, force: true }); }
120
+ });
121
+
122
+ test("running daemon refuses prepare before creating a pause marker or backup", async () => {
123
+ const dir = mkdtempSync(join(tmpdir(), "import-busy-")), saved = process.env.PARACHUTE_HOME;
124
+ process.env.PARACHUTE_HOME = dir;
125
+ const server = Bun.serve({ port: 0, fetch: () => Response.json({ status: "ok" }) });
126
+ try {
127
+ writeGlobalConfig({ port: server.port! });
128
+ writeVaultConfig({ name: "test", api_keys: [], created_at: new Date().toISOString() });
129
+ await expect(runHistoryImport(["prepare", "--vault", "test"])).rejects.toThrow("Stop the vault daemon");
130
+ expect(() => readFileSync(historyMirrorStatePath("test"))).toThrow();
131
+ } finally { server.stop(true); if (saved === undefined) delete process.env.PARACHUTE_HOME; else process.env.PARACHUTE_HOME = saved; rmSync(dir, { recursive: true, force: true }); }
132
+ });
133
+
134
+ test("oversized revision quarantines its whole note while other identities remain staged", () => {
135
+ const dir = mkdtempSync(join(tmpdir(), "import-overflow-")), db = new Database(":memory:");
136
+ try {
137
+ const repo = repoAt(join(dir, "repo")); commit(repo, "small");
138
+ commit(repo, "x".repeat(2_000_001));
139
+ const other = "01M2KT4GXMQDNB8ZFXY05MHT6B", tip = commit(repo, "healthy", other, "healthy.md");
140
+ stageArchive(repo, tip, db);
141
+ expect(db.query("SELECT * FROM quarantine").all()).toEqual([{ note_id: noteId, reason: "oversized_body" }]);
142
+ expect(db.query("SELECT DISTINCT note_id FROM observations").all()).toEqual([{ note_id: other }]);
143
+ } finally { db.close(); rmSync(dir, { recursive: true, force: true }); }
144
+ });
145
+
146
+ test("first-parent import sees side-branch notes only in the merge tree", () => {
147
+ const dir = mkdtempSync(join(tmpdir(), "import-merge-")), db = new Database(":memory:");
148
+ try {
149
+ const repo = repoAt(join(dir, "repo")); commit(repo, "base");
150
+ git(repo, "checkout", "-b", "side");
151
+ const other = "01M2KT4GXMQDNB8ZFXY05MHT6B"; commit(repo, "side", other, "side.md");
152
+ git(repo, "checkout", "main"); commit(repo, "main");
153
+ git(repo, "merge", "--no-ff", "side", "-m", "merge result"); const tip = git(repo, "rev-parse", "HEAD");
154
+ expect(stageArchive(repo, tip, db).commits).toBe(3);
155
+ const side = db.query("SELECT row_json FROM observations WHERE note_id=?").get(other) as { row_json: string };
156
+ expect(JSON.parse(side.row_json).commit).toBe(tip);
157
+ } finally { db.close(); rmSync(dir, { recursive: true, force: true }); }
158
+ });
159
+
160
+ function selectionFile(repo: string, tip: string, entries: HistorySelections["selections"]): HistorySelections {
161
+ const walk = git(repo, "log", "--first-parent", "--reverse", "--format=%H %T", tip) + "\n";
162
+ return { format: 1, tip, source_fingerprint: createHash("sha256").update(walk).digest("hex"), selections: entries };
163
+ }
164
+ function pin(repo: string, commitId: string, path: string) { return { git_path: path, blob: git(repo, "rev-parse", `${commitId}:${path}`) }; }
165
+ function selection(repo: string, commitId: string, selected: string, rejected: string, id = noteId): HistorySelections["selections"][number] {
166
+ return { note_id: id, commit: commitId, selected: pin(repo, commitId, selected), rejected: [{ ...pin(repo, commitId, rejected), reason: "Retained stale export" }], reason: "Reviewed explicit fixture path" };
167
+ }
168
+ function selectedStage(repo: string, tip: string, selectors?: HistorySelections) {
169
+ const db = new Database(":memory:");
170
+ try {
171
+ stageArchive(repo, tip, db, selectors);
172
+ return {
173
+ rows: (db.query("SELECT row_json FROM observations ORDER BY note_id,seq").all() as { row_json: string }[]).map(r => JSON.parse(r.row_json)),
174
+ quarantine: db.query("SELECT * FROM quarantine ORDER BY note_id").all(),
175
+ failures: db.query("SELECT * FROM selection_failures ORDER BY note_id,commit_id").all(),
176
+ };
177
+ } finally { db.close(); }
178
+ }
179
+
180
+ test("exact selections resolve duplicate trees regardless of Git path order; same bodies still require a selection", () => {
181
+ const dir = mkdtempSync(join(tmpdir(), "selection-order-"));
182
+ try {
183
+ for (const selectedFirst of [true, false]) {
184
+ const repo = repoAt(join(dir, String(selectedFirst))), oldPath = selectedFirst ? "z-old.md" : "a-old.md", newPath = selectedFirst ? "a-new.md" : "z-new.md";
185
+ commit(repo, "old", noteId, oldPath); const tip = commit(repo, "selected", noteId, newPath);
186
+ const selectors = selectionFile(repo, tip, [selection(repo, tip, newPath, oldPath)]);
187
+ expect(selectedStage(repo, tip).quarantine).toEqual([{ note_id: noteId, reason: "duplicate_id" }]);
188
+ const result = selectedStage(repo, tip, selectors);
189
+ expect(result.quarantine).toEqual([]); expect(result.rows.map(r => r.content)).toEqual(["old", "selected"]);
190
+ expect(result.rows[1].path).toBe("entry"); // Comes from frontmatter, not the Git path.
191
+ }
192
+ const repo = repoAt(join(dir, "same"));
193
+ commit(repo, "same", noteId, "old.md");
194
+ writeFileSync(join(repo, "new.md"), `---\nid: ${noteId}\npath: renamed\n---\nsame`);
195
+ git(repo, "add", "."); git(repo, "commit", "-m", "same body, new path"); const tip = git(repo, "rev-parse", "HEAD");
196
+ expect(selectedStage(repo, tip).quarantine).toHaveLength(1);
197
+ const result = selectedStage(repo, tip, selectionFile(repo, tip, [selection(repo, tip, "new.md", "old.md")]));
198
+ expect(result.rows.map(r => r.path)).toEqual(["entry", "renamed"]); expect(result.quarantine).toEqual([]);
199
+ } finally { rmSync(dir, { recursive: true, force: true }); }
200
+ }, 30_000);
201
+
202
+ test("stale selectors, candidate mismatches, rejected divergence and unlisted duplicate trees quarantine the whole note", () => {
203
+ const dir = mkdtempSync(join(tmpdir(), "selection-refusal-"));
204
+ try {
205
+ const repo = repoAt(join(dir, "repo")); const first = commit(repo, "old", noteId, "old.md"), tip = commit(repo, "new", noteId, "new.md");
206
+ const good = selectionFile(repo, tip, [selection(repo, tip, "new.md", "old.md")]);
207
+ const bad: HistorySelections[] = [];
208
+ for (const field of ["blob", "git_path"] as const) {
209
+ const copy = structuredClone(good); copy.selections[0]!.selected[field] = field === "blob" ? "0".repeat(40) : "absent.md"; bad.push(copy);
210
+ const rejected = structuredClone(good); rejected.selections[0]!.rejected[0]![field] = field === "blob" ? "0".repeat(40) : "absent.md"; bad.push(rejected);
211
+ }
212
+ const extra = structuredClone(good); extra.selections[0]!.rejected.push({ git_path: "third.md", blob: good.selections[0]!.selected.blob, reason: "Not present" }); bad.push(extra);
213
+ const singleton = structuredClone(good); singleton.selections.push({ ...selection(repo, tip, "old.md", "new.md"), commit: first }); bad.push(singleton);
214
+ const missing = structuredClone(good); missing.selections = []; bad.push(missing);
215
+ const absent = structuredClone(good); absent.selections[0]!.note_id = "01M2KT4GXMQDNB8ZFXY05MHT6B"; bad.push(absent);
216
+ for (const selectors of bad) {
217
+ const result = selectedStage(repo, tip, selectors);
218
+ expect(result.rows).toEqual([]); expect(result.quarantine).toContainEqual({ note_id: noteId, reason: "duplicate_id" }); expect(result.failures.length).toBeGreaterThan(0);
219
+ }
220
+ const changed = commit(repo, "rejected diverged", noteId, "old.md");
221
+ const divergent = selectionFile(repo, changed, [...good.selections, { ...good.selections[0]!, commit: changed }]);
222
+ expect(selectedStage(repo, changed, divergent).failures).toContainEqual({ note_id: noteId, commit_id: changed, reason: "candidate_set_mismatch" });
223
+ const third = commit(repo, "third candidate", noteId, "third.md");
224
+ const unlisted = selectionFile(repo, third, [...good.selections, selection(repo, changed, "new.md", "old.md")]);
225
+ expect(selectedStage(repo, third, unlisted).rows).toEqual([]);
226
+ expect(selectedStage(repo, third, { ...unlisted, selections: [...unlisted.selections, selection(repo, third, "new.md", "old.md")] }).failures).toContainEqual({ note_id: noteId, commit_id: third, reason: "candidate_set_mismatch" });
227
+ expect(() => selectedStage(repo, tip, { ...good, source_fingerprint: "0".repeat(64) })).toThrow("fingerprint");
228
+ expect(() => selectedStage(repo, tip, { ...good, tip: first })).toThrow("tip");
229
+ const foreign = structuredClone(good); foreign.selections[0]!.commit = "0".repeat(40);
230
+ expect(() => selectedStage(repo, tip, foreign)).toThrow("first-parent");
231
+ } finally { rmSync(dir, { recursive: true, force: true }); }
232
+ }, 30_000);
233
+
234
+ test("selections cannot clear ambiguous sidecars or oversized observations", () => {
235
+ const dir = mkdtempSync(join(tmpdir(), "selection-causes-"));
236
+ try {
237
+ const repo = repoAt(join(dir, "sidecars")), other = "01M2KT4GXMQDNB8ZFXY05MHT6B";
238
+ mkdirSync(join(repo, ".parachute/notes-meta"), { recursive: true });
239
+ for (const id of [noteId, other]) writeFileSync(join(repo, `.parachute/notes-meta/${id}.yaml`), `id: ${id}\npath: shared\nextension: json\n`);
240
+ writeFileSync(join(repo, "shared.json"), "{}"); const tip = commit(repo, "inline selected", noteId, "inline.md");
241
+ const item = selection(repo, tip, "inline.md", "shared.json");
242
+ item.rejected[0]!.sidecar = pin(repo, tip, `.parachute/notes-meta/${noteId}.yaml`);
243
+ const result = selectedStage(repo, tip, selectionFile(repo, tip, [item]));
244
+ expect(result.failures).toEqual([]); // The exact successful set matches; another cause remains authoritative.
245
+ expect(result.quarantine).toContainEqual({ note_id: noteId, reason: "ambiguous_sidecar" }); expect(result.rows).toEqual([]);
246
+ const large = repoAt(join(dir, "large")); commit(large, "x".repeat(2_000_001), noteId, "bad.md");
247
+ commit(large, "old", noteId, "old.md"); const end = commit(large, "new", noteId, "new.md");
248
+ const kept = selectedStage(large, end, selectionFile(large, end, [selection(large, end, "new.md", "old.md")]));
249
+ expect(kept.failures).toEqual([]); expect(kept.quarantine).toEqual([{ note_id: noteId, reason: "oversized_body" }]); expect(kept.rows).toEqual([]);
250
+ } finally { rmSync(dir, { recursive: true, force: true }); }
251
+ }, 30_000);
252
+
253
+ test("selection normalization rejects malformed pins and canonicalizes array order", () => {
254
+ const ref = { git_path: "a.md", blob: "a".repeat(40) }, other = { git_path: "b.md", blob: "b".repeat(40), reason: "kept" };
255
+ const input: HistorySelections = { format: 1, tip: "c".repeat(40), source_fingerprint: "d".repeat(64), selections: [{ note_id: noteId, commit: "c".repeat(40), selected: ref, rejected: [other, { ...other, git_path: "c.md" }], reason: "audit" }, { note_id: noteId, commit: "e".repeat(40), selected: ref, rejected: [other], reason: "audit" }] };
256
+ const reversed = structuredClone(input); reversed.selections.reverse(); for (const s of reversed.selections) s.rejected.reverse();
257
+ expect(normalizeHistorySelections(input)).toEqual(normalizeHistorySelections(reversed));
258
+ expect(() => normalizeHistorySelections({ ...input, body: "not permitted" })).toThrow();
259
+ expect(() => normalizeHistorySelections({ ...input, selections: [input.selections[0], input.selections[0]] })).toThrow("Duplicate selection");
260
+ expect(() => normalizeHistorySelections({ ...input, selections: [{ ...input.selections[0], reason: "" }] })).toThrow("reasons");
261
+ expect(() => normalizeHistorySelections({ ...input, selections: [{ ...input.selections[0], selected: { ...ref, git_path: "../a.md" } }] })).toThrow("reference");
262
+ });
263
+
264
+ test("partial selections block apply; complete selections are embedded and restage to the projected digest", async () => {
265
+ const dir = mkdtempSync(join(tmpdir(), "selection-cutover-")), saved = process.env.PARACHUTE_HOME;
266
+ process.env.PARACHUTE_HOME = join(dir, "home");
267
+ try {
268
+ const probe = Bun.serve({ port: 0, fetch: () => new Response() }); const port = probe.port!; probe.stop(true); writeGlobalConfig({ port });
269
+ writeVaultConfig({ name: "test", api_keys: [], created_at: new Date().toISOString() });
270
+ const store = getVaultStore("test"), a = await store.createNote("native A"), b = await store.createNote("native B"); clearVaultStoreCache();
271
+ const repo = repoAt(join(dir, "repo")); commit(repo, "a old", a.id, "a-old.md"); const t1 = commit(repo, "a new", a.id, "a-new.md");
272
+ const t2 = commit(repo, "b old", b.id, "b-old.md"), tip = commit(repo, "b new", b.id, "b-new.md");
273
+ const partial = selectionFile(repo, tip, [t1,t2,tip].map(c => selection(repo, c, "a-new.md", "a-old.md", a.id)));
274
+ const complete = selectionFile(repo, tip, [...partial.selections, selection(repo, tip, "b-new.md", "b-old.md", b.id)]);
275
+ const file = join(dir, "selections.json"); writeFileSync(file, JSON.stringify(partial));
276
+ for (const command of ["apply", "retire", "cancel"]) await expect(runHistoryImport([command, "--selections", file])).rejects.toThrow("only valid for plan/prepare");
277
+ const prepared = join(dir, "partial.json"), archive = join(dir, "partial.bundle");
278
+ const p = await runHistoryImport(["prepare", "--vault", "test", "--source", repo, "--through", tip, "--selections", file, "--archive", archive, "--output", prepared]) as ImportManifest;
279
+ expect(p.quarantined).toEqual([{ note_id: b.id, reason: "duplicate_id" }]);
280
+ await expect(runHistoryImport(["apply", "--vault", "test", "--manifest", prepared, "--archive", archive])).rejects.toThrow("Quarantined");
281
+ await runHistoryImport(["cancel", "--vault", "test"]);
282
+ writeFileSync(file, JSON.stringify(complete));
283
+ const finalPath = join(dir, "final.json"), bundle = join(dir, "final.bundle");
284
+ const final = await runHistoryImport(["prepare", "--vault", "test", "--source", repo, "--through", tip, "--selections", file, "--archive", bundle, "--output", finalPath]) as ImportManifest;
285
+ expect(final.quarantined).toEqual([]); expect(final.options_digest).not.toBe(p.options_digest); expect(final.selections_digest).not.toBe(p.selections_digest);
286
+ expect(final.selections).toEqual(normalizeHistorySelections(complete)); expect(final.notes).toHaveLength(2);
287
+ expect(final.notes.every(n => n.native_drops.length === 0)).toBe(true);
288
+ rmSync(file); // Apply cannot consult the original selectors file.
289
+ await runHistoryImport(["apply", "--vault", "test", "--manifest", finalPath, "--archive", bundle]);
290
+ const db = new Database(vaultDbPath("test"), { readonly: true });
291
+ try {
292
+ expect(getImportedVersion(db, a.id, 0)?.content).toBe("a new"); expect(getImportedVersion(db, b.id, 0)?.content).toBe("b new");
293
+ for (const note of final.notes) expect(db.query("SELECT state_digest FROM history_import_receipts WHERE note_id=?").get(note.id)).toEqual({ state_digest: note.state_digest });
294
+ expect(db.query("SELECT content FROM notes ORDER BY content").all()).toEqual([{ content: "native A" }, { content: "native B" }]);
295
+ } finally { db.close(); }
296
+ } finally { clearVaultStoreCache(); if (saved === undefined) delete process.env.PARACHUTE_HOME; else process.env.PARACHUTE_HOME = saved; rmSync(dir, { recursive: true, force: true }); }
297
+ }, 30_000);
298
+
299
+
300
+ test("valid sidecar candidate selection binds both content and metadata blobs", () => {
301
+ const dir = mkdtempSync(join(tmpdir(), "selection-sidecar-valid-"));
302
+ try {
303
+ const repo = repoAt(join(dir, "repo")); mkdirSync(join(repo, ".parachute/notes-meta"), { recursive: true });
304
+ for (const path of ["old", "new"]) {
305
+ writeFileSync(join(repo, `${path}.json`), JSON.stringify({ value: path }));
306
+ writeFileSync(join(repo, `.parachute/notes-meta/${path}.yaml`), `id: ${noteId}\npath: ${path}\nextension: json\n`);
307
+ }
308
+ git(repo, "add", "."); git(repo, "commit", "-m", "sidecar duplicates"); const tip = git(repo, "rev-parse", "HEAD");
309
+ const item = selection(repo, tip, "new.json", "old.json");
310
+ item.selected.sidecar = pin(repo, tip, ".parachute/notes-meta/new.yaml");
311
+ item.rejected[0]!.sidecar = pin(repo, tip, ".parachute/notes-meta/old.yaml");
312
+ const selectors = selectionFile(repo, tip, [item]), result = selectedStage(repo, tip, selectors);
313
+ expect(result.quarantine).toEqual([]); expect(result.rows).toHaveLength(1);
314
+ expect(result.rows[0]).toMatchObject({ path: "new", extension: "json", content: '{"value":"new"}' });
315
+ const stale = structuredClone(selectors); stale.selections[0]!.selected.sidecar!.blob = "0".repeat(40);
316
+ expect(selectedStage(repo, tip, stale).quarantine).toEqual([{ note_id: noteId, reason: "duplicate_id" }]);
317
+ } finally { rmSync(dir, { recursive: true, force: true }); }
318
+ }, 30_000);