@openparachute/vault 0.4.4-rc.12 → 0.4.4-rc.14

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.
@@ -189,15 +189,31 @@ describe("notes", async () => {
189
189
  });
190
190
 
191
191
  // -------------------------------------------------------------------------
192
- // Empty-note invariant at the Store boundary (#213)
192
+ // Empty content is a valid state (vault#323)
193
193
  // -------------------------------------------------------------------------
194
+ // Skeleton notes, drafts saved before content, organizing-only notes,
195
+ // capture-then-fill flows. The earlier #213 guard rejected `content +
196
+ // path both absent` — we no longer enforce it because real vaults
197
+ // legitimately carry such rows and the round-trip import has to accept
198
+ // them.
199
+
200
+ it("createNote accepts empty content with no path", async () => {
201
+ const n = await store.createNote("");
202
+ expect(n.content).toBe("");
203
+ expect(n.path).toBeUndefined();
204
+ });
194
205
 
195
- it("createNote rejects content+path both absent → EmptyNoteError", async () => {
196
- await expect(store.createNote("")).rejects.toMatchObject({ code: "EMPTY_NOTE" });
197
- await expect(store.createNote(" ")).rejects.toMatchObject({ code: "EMPTY_NOTE" });
198
- await expect(store.createNote("", { metadata: { x: 1 } })).rejects.toMatchObject({
199
- code: "EMPTY_NOTE",
200
- });
206
+ it("createNote accepts whitespace-only content with no path", async () => {
207
+ const n = await store.createNote(" ");
208
+ expect(n.content).toBe(" ");
209
+ });
210
+
211
+ it("createNote empty-content note is queryable + survives round-trip", async () => {
212
+ const created = await store.createNote("", { metadata: { kind: "skeleton" } });
213
+ const fetched = await store.getNote(created.id);
214
+ expect(fetched).not.toBeNull();
215
+ expect(fetched!.content).toBe("");
216
+ expect(fetched!.metadata).toMatchObject({ kind: "skeleton" });
201
217
  });
202
218
 
203
219
  it("createNote accepts content-only (un-pathed jot)", async () => {
@@ -212,18 +228,24 @@ describe("notes", async () => {
212
228
  expect(n.path).toBe("wiki/placeholder");
213
229
  });
214
230
 
215
- it("updateNote rejects clearing both content and path → EmptyNoteError", async () => {
231
+ it("updateNote allows clearing both content and path", async () => {
216
232
  const n = await store.createNote("body", { path: "p" });
217
- await expect(
218
- store.updateNote(n.id, { content: "", path: "", if_updated_at: n.createdAt }),
219
- ).rejects.toMatchObject({ code: "EMPTY_NOTE", note_id: n.id });
233
+ const updated = await store.updateNote(n.id, {
234
+ content: "",
235
+ path: "",
236
+ if_updated_at: n.createdAt,
237
+ });
238
+ expect(updated.content).toBe("");
239
+ expect(updated.path).toBeUndefined();
220
240
  });
221
241
 
222
- it("updateNote rejects clearing content when path is already null", async () => {
242
+ it("updateNote allows clearing content when path is already null", async () => {
223
243
  const n = await store.createNote("body");
224
- await expect(
225
- store.updateNote(n.id, { content: "", if_updated_at: n.createdAt }),
226
- ).rejects.toMatchObject({ code: "EMPTY_NOTE" });
244
+ const updated = await store.updateNote(n.id, {
245
+ content: "",
246
+ if_updated_at: n.createdAt,
247
+ });
248
+ expect(updated.content).toBe("");
227
249
  });
228
250
 
229
251
  it("updateNote allows clearing content when path is set (or being set)", async () => {
@@ -2811,58 +2833,32 @@ describe("MCP tools", async () => {
2811
2833
  expect(r2.map((n) => n.content).sort()).toEqual(["a"]);
2812
2834
  });
2813
2835
 
2814
- // ---- empty-note + batch-cap MCP regressions (#213) ----
2836
+ // ---- empty-note acceptance (vault#323) + batch-cap MCP ----
2815
2837
 
2816
- it("create-note rejects bare empty content with no path (EMPTY_NOTE)", async () => {
2838
+ it("create-note accepts bare empty content with no path", async () => {
2817
2839
  const tools = generateMcpTools(store);
2818
2840
  const createNote = tools.find((t) => t.name === "create-note")!;
2819
- let err: any;
2820
- try {
2821
- await createNote.execute({ content: "" });
2822
- } catch (e) {
2823
- err = e;
2824
- }
2825
- expect(err?.code).toBe("EMPTY_NOTE");
2826
- });
2827
-
2828
- it("create-note batch rejects when any entry is empty content + no path (atomic, with item_index)", async () => {
2829
- const tools = generateMcpTools(store);
2830
- const createNote = tools.find((t) => t.name === "create-note")!;
2831
- const beforeCount = (await store.queryNotes({ search: "atomic-marker" })).length;
2832
- let err: any;
2833
- try {
2834
- await createNote.execute({
2835
- notes: [
2836
- { content: "atomic-marker first" },
2837
- { content: "" },
2838
- ],
2839
- });
2840
- } catch (e) {
2841
- err = e;
2842
- }
2843
- expect(err?.code).toBe("EMPTY_NOTE");
2844
- // The first item must NOT have been created — pre-validation rolls
2845
- // the whole batch back atomically. Partial-create would leak prefixes
2846
- // on every runaway-client burst (#213).
2847
- const afterCount = (await store.queryNotes({ search: "atomic-marker" })).length;
2848
- expect(afterCount).toBe(beforeCount);
2849
- // Parity with HTTP route: MCP callers with multi-item batches need to
2850
- // know which entry triggered the rejection. The bad entry is at index 1.
2851
- expect(err.item_index).toBe(1);
2841
+ const result = await createNote.execute({ content: "" }) as any;
2842
+ expect(result).toBeTruthy();
2843
+ const note = Array.isArray(result) ? result[0] : result;
2844
+ expect(note.content).toBe("");
2845
+ const fetched = await store.getNote(note.id);
2846
+ expect(fetched).not.toBeNull();
2847
+ expect(fetched!.content).toBe("");
2852
2848
  });
2853
2849
 
2854
- it("create-note single empty has null item_index (not a batch position)", async () => {
2850
+ it("create-note batch with mixed empty + content entries succeeds end-to-end", async () => {
2855
2851
  const tools = generateMcpTools(store);
2856
2852
  const createNote = tools.find((t) => t.name === "create-note")!;
2857
- let err: any;
2858
- try {
2859
- await createNote.execute({ content: "" });
2860
- } catch (e) {
2861
- err = e;
2862
- }
2863
- expect(err?.code).toBe("EMPTY_NOTE");
2864
- // Single-call (no `notes` array) — there's no batch position to report.
2865
- expect(err.item_index).toBeNull();
2853
+ const result = await createNote.execute({
2854
+ notes: [
2855
+ { content: "first" },
2856
+ { content: "" },
2857
+ { content: "third" },
2858
+ ],
2859
+ }) as any[];
2860
+ expect(result).toHaveLength(3);
2861
+ expect(result.map((n) => n.content)).toEqual(["first", "", "third"]);
2866
2862
  });
2867
2863
 
2868
2864
  it("create-note batch over MAX_BATCH_SIZE rejects with BATCH_TOO_LARGE", async () => {
@@ -3910,6 +3906,77 @@ describe("update-note if_missing=create (vault#309)", async () => {
3910
3906
  const all = await store.queryNotes({ limit: 100 });
3911
3907
  expect(all.filter((n) => n.path === "Inbox/sync-target")).toHaveLength(1);
3912
3908
  });
3909
+
3910
+ // vault#321 F3 — schema-conflict warning surfaces on the create
3911
+ // branch. The branch reuses `attachValidationStatus` so the
3912
+ // conflict-detection logic should fire, but pre-fold it wasn't
3913
+ // pinned. Two tags directly applied to the new note, each
3914
+ // declaring the same field with conflicting specs → schema_conflict
3915
+ // warning (the existing `resolveNoteSchemas` walk produces this for
3916
+ // both inheritance chains AND multi-tag direct application).
3917
+ it("schema-conflict warning surfaces on create branch (vault#321 F3)", async () => {
3918
+ await store.upsertTagSchema("kpi", {
3919
+ fields: { count: { type: "integer" } },
3920
+ });
3921
+ await store.upsertTagSchema("metric", {
3922
+ fields: { count: { type: "string" } }, // conflicting spec
3923
+ });
3924
+ const tools = generateMcpTools(store);
3925
+ const update = tools.find((t) => t.name === "update-note")!;
3926
+ const result = await update.execute({
3927
+ id: "Inbox/conflicting-tags",
3928
+ content: "x",
3929
+ tags: ["kpi", "metric"],
3930
+ metadata: { count: 5 },
3931
+ if_missing: "create",
3932
+ }) as any;
3933
+ expect(result.created).toBe(true);
3934
+ const conflict = result.validation_status.warnings.find(
3935
+ (w: any) => w.reason === "schema_conflict",
3936
+ );
3937
+ expect(conflict).toBeDefined();
3938
+ expect(conflict.field).toBe("count");
3939
+ // First-tag-wins precedence (kpi → integer). The loser_schema
3940
+ // field names metric.
3941
+ expect(conflict.schema).toBe("kpi");
3942
+ expect(conflict.loser_schema).toBe("metric");
3943
+ });
3944
+
3945
+ // vault#321 F4 — links.add on the create branch is applied. The
3946
+ // implementation at mcp.ts:644-650 was present pre-fold; this
3947
+ // test pins it so a future regression breaking Gitcoin's
3948
+ // upsert-with-typed-links workflow goes red.
3949
+ it("links.add applied on create branch (vault#321 F4)", async () => {
3950
+ // Two pre-existing target notes the new source links to.
3951
+ await store.createNote("A", { id: "t-mcp-a-321", path: "Targets/A-mcp" });
3952
+ await store.createNote("B", { id: "t-mcp-b-321", path: "Targets/B-mcp" });
3953
+
3954
+ const tools = generateMcpTools(store);
3955
+ const update = tools.find((t) => t.name === "update-note")!;
3956
+ const result = await update.execute({
3957
+ id: "Inbox/mcp-source-321",
3958
+ content: "source body",
3959
+ if_missing: "create",
3960
+ links: {
3961
+ add: [
3962
+ { target: "t-mcp-a-321", relationship: "derived-from" },
3963
+ { target: "Targets/B-mcp", relationship: "responds-to", metadata: { weight: 5 } },
3964
+ ],
3965
+ },
3966
+ }) as any;
3967
+ expect(result.created).toBe(true);
3968
+
3969
+ const sourceId = result.id as string;
3970
+ const outboundLinks = await store.getLinks(sourceId, { direction: "outbound" });
3971
+ const derivedFrom = outboundLinks.find((l) => l.relationship === "derived-from");
3972
+ expect(derivedFrom).toBeDefined();
3973
+ expect(derivedFrom!.targetId).toBe("t-mcp-a-321");
3974
+
3975
+ const respondsTo = outboundLinks.find((l) => l.relationship === "responds-to");
3976
+ expect(respondsTo).toBeDefined();
3977
+ expect(respondsTo!.targetId).toBe("t-mcp-b-321");
3978
+ expect(respondsTo!.metadata).toEqual({ weight: 5 });
3979
+ });
3913
3980
  });
3914
3981
 
3915
3982
  // ---------------------------------------------------------------------------
package/core/src/mcp.ts CHANGED
@@ -381,34 +381,13 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
381
381
  throw new BatchTooLargeError(items.length);
382
382
  }
383
383
 
384
- // Empty-note pre-validation (#213): make mixed batches atomic for
385
- // the empty-note case. The Store will throw EmptyNoteError on the
386
- // empty entry, but in a sequential batch loop the prefix would have
387
- // already committed before we hit it. Pre-walk so the whole call
388
- // either creates everything or nothing. The error carries
389
- // `item_index` so MCP callers with multi-item batches can pinpoint
390
- // the bad entry — parity with the HTTP route's response shape.
391
- // TODO: tighten batch input type — `items[i] as any` mirrors the
392
- // top-of-call cast at `params.notes as any[]`. A typed McpCreateNoteInput
393
- // would let us drop both casts.
394
- for (let i = 0; i < items.length; i++) {
395
- const item = items[i] as any;
396
- const content = ((item?.content as string | undefined) ?? "").toString();
397
- const rawPath = item?.path;
398
- const pathEmpty = rawPath === undefined || rawPath === null
399
- || (typeof rawPath === "string" && rawPath.trim() === "");
400
- if (!content.trim() && pathEmpty) {
401
- throw new noteOps.EmptyNoteError(null, batch ? i : null);
402
- }
403
- }
404
-
405
384
  const created: Note[] = [];
406
385
  // Wrap multi-item batches in a SQLite transaction so a mid-batch
407
- // failure rolls back every prior insert — see #236. The pre-walk
408
- // above catches empty-note cases; this guards anything thrown from
409
- // store.createNote / createLink (path conflict, etc.). Single-item
410
- // calls skip the wrap to avoid colliding with concurrent callers
411
- // on the shared bun:sqlite connection.
386
+ // failure rolls back every prior insert — see #236. This guards
387
+ // anything thrown from store.createNote / createLink (path
388
+ // conflict, etc.). Single-item calls skip the wrap to avoid
389
+ // colliding with concurrent callers on the shared bun:sqlite
390
+ // connection.
412
391
  const batched = items.length > 1;
413
392
  if (batched) db.exec("BEGIN");
414
393
  try {
@@ -1255,7 +1234,7 @@ function normalizeTags(tag: unknown): string[] | undefined {
1255
1234
 
1256
1235
  // Re-exported for backward compat; defined in notes.ts alongside the
1257
1236
  // conditional-UPDATE implementation that raises it.
1258
- export { ConflictError, PathConflictError, EmptyNoteError, MAX_BATCH_SIZE } from "./notes.js";
1237
+ export { ConflictError, PathConflictError, MAX_BATCH_SIZE } from "./notes.js";
1259
1238
 
1260
1239
  /**
1261
1240
  * Thrown by the `update-note` MCP tool (and the REST PATCH handler) when a
package/core/src/notes.ts CHANGED
@@ -36,15 +36,11 @@ export function createNote(
36
36
  const metadata = opts?.metadata ? JSON.stringify(opts.metadata) : "{}";
37
37
  const path = normalizePath(opts?.path);
38
38
 
39
- // Empty-note invariant (#213): reject `content+path both absent`. Three
40
- // legit shapes content-only, path-only, both — only the empty+empty
41
- // combo is the runaway-client signature that flooded a deployment with
42
- // 7,453 pathless empty notes in one MCP burst. `content` only is a
43
- // legitimate un-pathed jot; `path` only is a wikilink placeholder or
44
- // `_schemas/*` config note.
45
- if (!content.trim() && path === null) {
46
- throw new EmptyNoteError();
47
- }
39
+ // Empty content is a valid state (vault#323): skeleton notes, drafts
40
+ // saved before content, organizing-only notes, capture-then-fill flows.
41
+ // The earlier #213 guard rejected `content + path both absent`; we no
42
+ // longer enforce it because real vaults legitimately carry such rows
43
+ // and the round-trip import has to accept them.
48
44
 
49
45
  // `updated_at` is set to `created_at` on insert so a client whose optimistic
50
46
  // concurrency check falls back to `createdAt` on a never-updated note
@@ -156,39 +152,6 @@ export class PathConflictError extends Error {
156
152
  */
157
153
  export const MAX_BATCH_SIZE = 500;
158
154
 
159
- /**
160
- * Thrown by `createNote` / `updateNote` when the proposed note state has
161
- * neither content nor path. The vault accepts un-pathed jots (content only)
162
- * and path-only placeholders (wikilink stubs, `_schemas/*`), but a note
163
- * with neither is the runaway-client signature flagged in #213 — one MCP
164
- * burst flooded a deployment with 7,453 empty pathless rows. Surfaces as
165
- * 400 at the HTTP layer.
166
- */
167
- export class EmptyNoteError extends Error {
168
- code = "EMPTY_NOTE" as const;
169
- note_id: string | null;
170
- /**
171
- * Zero-based position in a batch call when the empty entry is rejected via
172
- * the transport-layer pre-validation pass (HTTP `POST /api/notes` or MCP
173
- * `create-note` with `notes: [...]`). `null` for single-update rejections
174
- * and for Store-level throws that don't know their batch context.
175
- */
176
- item_index: number | null;
177
-
178
- constructor(noteId: string | null = null, itemIndex: number | null = null) {
179
- super(
180
- noteId
181
- ? `empty_note: update would leave note "${noteId}" with neither content nor path`
182
- : itemIndex !== null
183
- ? `empty_note: a note must have either content or a path (item index ${itemIndex})`
184
- : `empty_note: a note must have either content or a path`,
185
- );
186
- this.name = "EmptyNoteError";
187
- this.note_id = noteId;
188
- this.item_index = itemIndex;
189
- }
190
- }
191
-
192
155
  /**
193
156
  * Match bun:sqlite's UNIQUE-constraint error on the notes.path index. The
194
157
  * error class is `SQLiteError` but matching on the message is sufficient
@@ -236,36 +199,9 @@ export function updateNote(
236
199
  );
237
200
  }
238
201
 
239
- // Empty-note invariant (#213): when this update touches content or path,
240
- // reject if the post-state would be empty content + null path. We only
241
- // enforce on transitions that actually touch the relevant fields, so
242
- // metadata-only or tag-only updates against legacy empty rows still pass.
243
- // Hook-style writes (skipUpdatedAt) are exempted — they're machine-level
244
- // marker writes that legitimately may run against any shape of row.
245
- const touchesContent = updates.content !== undefined
246
- || updates.append !== undefined
247
- || updates.prepend !== undefined;
248
- const touchesPath = updates.path !== undefined;
249
- if ((touchesContent || touchesPath) && !updates.skipUpdatedAt) {
250
- const current = getNote(db, id);
251
- if (current) {
252
- let finalContent: string;
253
- if (updates.content !== undefined) {
254
- finalContent = updates.content;
255
- } else if (touchesContent) {
256
- finalContent = (updates.prepend ?? "") + current.content + (updates.append ?? "");
257
- } else {
258
- finalContent = current.content;
259
- }
260
- const finalPath = touchesPath ? normalizePath(updates.path) : (current.path ?? null);
261
- if (!finalContent.trim() && !finalPath) {
262
- throw new EmptyNoteError(id);
263
- }
264
- }
265
- // If `current` is null we fall through — existing code paths handle the
266
- // missing-row case downstream (the conditional UPDATE returns 0 rows;
267
- // OC throws ConflictError; non-OC returns silently).
268
- }
202
+ // Empty content is a valid state (vault#323) see createNote. The
203
+ // matching guard that used to reject updates clearing both content
204
+ // and path has been removed.
269
205
 
270
206
  const sets: string[] = [];
271
207
  const values: (string | null)[] = [];
@@ -777,6 +777,10 @@ describe("portable-md round-trip — byte-equivalent re-export after blow-away i
777
777
  // - Typed links (non-wikilink).
778
778
  // - Multi-line metadata (vault#317 F1 path).
779
779
  // - One note edited (created_at ≠ updated_at).
780
+ // - Empty-content note (vault#323) — skeleton/draft shape that real
781
+ // vaults legitimately carry. Pins the round-trip regression that
782
+ // blocked stable promotion when the EMPTY_NOTE guard was still
783
+ // rejecting these on import.
780
784
  await store.upsertTagSchema("project", {
781
785
  description: "A long-running effort",
782
786
  fields: { status: { type: "string", enum: ["active", "done"] } },
@@ -797,6 +801,14 @@ describe("portable-md round-trip — byte-equivalent re-export after blow-away i
797
801
  tags: ["project"],
798
802
  });
799
803
  await store.createNote("unpathed jot", { id: "01HX003" });
804
+ // Empty-content note with a path — the skeleton/organizing-only
805
+ // shape from vault#323. Export emits it as frontmatter + empty body;
806
+ // import must accept it.
807
+ await store.createNote("", {
808
+ id: "01HX004",
809
+ path: "Inbox/skeleton",
810
+ tags: ["project"],
811
+ });
800
812
  await store.createLink("01HX001", "01HX002", "derived-from", { source: "git://example" });
801
813
 
802
814
  // Force a divergence between created_at and updated_at on n1 so
@@ -815,10 +827,20 @@ describe("portable-md round-trip — byte-equivalent re-export after blow-away i
815
827
  // Blow-away import into a fresh store.
816
828
  const restored = new SqliteStore(new Database(":memory:"));
817
829
  const importStats = await importPortableVault(restored, { inDir: outA, blowAway: true });
818
- expect(importStats.notes_created).toBe(3);
830
+ expect(importStats.notes_created).toBe(4);
819
831
  expect(importStats.schemas_restored).toBe(1);
820
832
  expect(importStats.links_restored).toBe(1);
821
833
 
834
+ // Empty-content note survives the round-trip — content is empty,
835
+ // path + tags persist. This is the load-bearing assertion for
836
+ // vault#323: prior to the EMPTY_NOTE drop, importPortableVault
837
+ // threw on the createNote("") call here.
838
+ const skeleton = await restored.getNote("01HX004");
839
+ expect(skeleton).not.toBeNull();
840
+ expect(skeleton!.content).toBe("");
841
+ expect(skeleton!.path).toBe("Inbox/skeleton");
842
+ expect(skeleton!.tags).toContain("project");
843
+
822
844
  // Second export from the restored store.
823
845
  const outB = join(tmpBase, "outB");
824
846
  await exportVaultToDir(restored, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.4.4-rc.12",
3
+ "version": "0.4.4-rc.14",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -2558,6 +2558,23 @@ async function cmdImport(args: string[]) {
2558
2558
  process.exit(1);
2559
2559
  }
2560
2560
 
2561
+ // Daemon-busy guard (vault#323): the import opens its own bun:sqlite
2562
+ // connection, but a running daemon already holds the writer lock. The
2563
+ // first createNote then trips SQLITE_BUSY mid-stream and leaves a
2564
+ // partially-replayed vault. Refuse with a clear error rather than
2565
+ // attempt-and-fail. WAL/concurrent-writer is a separate follow-up.
2566
+ const globalConfig = readGlobalConfig();
2567
+ const port = globalConfig.port || DEFAULT_PORT;
2568
+ const health = await checkHealth(port);
2569
+ if (health.status === "healthy" || health.status === "unhealthy") {
2570
+ console.error(
2571
+ `error: vault daemon is running on port ${port}; stop it first with:\n` +
2572
+ ` parachute stop vault\n` +
2573
+ `the import requires exclusive write access to the SQLite database.`,
2574
+ );
2575
+ process.exit(1);
2576
+ }
2577
+
2561
2578
  // Autodetect: portable-md export → `.parachute/vault.yaml` present.
2562
2579
  const isPortableMd = existsSync(join(fullPath, ".parachute", "vault.yaml"));
2563
2580
 
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Integration test for vault#323: `parachute-vault import` refuses to run
3
+ * when a daemon is bound to the configured port. The import opens its
4
+ * own bun:sqlite connection; concurrent writers would otherwise trip
5
+ * SQLITE_BUSY mid-replay and leave a partially-imported vault.
6
+ *
7
+ * Pre-flights checkHealth(port) before any DB write; "healthy" or
8
+ * "unhealthy" (port bound, any HTTP response) means the daemon owns the
9
+ * writer lock, so we exit 1 with a clear error pointing at
10
+ * `parachute stop vault`.
11
+ */
12
+
13
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
14
+ import { resolve } from "path";
15
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "fs";
16
+ import { tmpdir } from "os";
17
+ import { join } from "path";
18
+
19
+ const CLI = resolve(import.meta.dir, "cli.ts");
20
+
21
+ async function runCli(
22
+ args: string[],
23
+ env: Record<string, string>,
24
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
25
+ // Async spawn — the daemon-busy probe inside the CLI fetches the test
26
+ // process's stub server, so the parent event loop must keep servicing
27
+ // requests while the child runs. Bun.spawnSync blocks the event loop
28
+ // and the in-test server can't answer, which makes every probe time
29
+ // out into the "not listening" branch.
30
+ const proc = Bun.spawn({
31
+ cmd: ["bun", CLI, ...args],
32
+ stdout: "pipe",
33
+ stderr: "pipe",
34
+ env: { ...process.env, ...env },
35
+ });
36
+ const [stdout, stderr, exitCode] = await Promise.all([
37
+ new Response(proc.stdout).text(),
38
+ new Response(proc.stderr).text(),
39
+ proc.exited,
40
+ ]);
41
+ return { exitCode: exitCode ?? -1, stdout, stderr };
42
+ }
43
+
44
+ let home: string;
45
+ let srcDir: string;
46
+
47
+ beforeEach(() => {
48
+ home = mkdtempSync(join(tmpdir(), "import-daemon-busy-"));
49
+ srcDir = mkdtempSync(join(tmpdir(), "import-daemon-busy-src-"));
50
+ // Minimal portable-md export shape so autodetect picks the portable path.
51
+ mkdirSync(join(srcDir, ".parachute"), { recursive: true });
52
+ writeFileSync(
53
+ join(srcDir, ".parachute", "vault.yaml"),
54
+ "name: default\nexport_format_version: 1\n",
55
+ );
56
+ // Minimal vault tree so the "vault not found" guard doesn't short-circuit.
57
+ // PARACHUTE_HOME → <root>/vault/{config.yaml, data/<name>/vault.yaml}.
58
+ mkdirSync(join(home, "vault", "data", "default"), { recursive: true });
59
+ writeFileSync(
60
+ join(home, "vault", "data", "default", "vault.yaml"),
61
+ "name: default\n",
62
+ );
63
+ });
64
+
65
+ afterEach(() => {
66
+ rmSync(home, { recursive: true, force: true });
67
+ rmSync(srcDir, { recursive: true, force: true });
68
+ });
69
+
70
+ describe("import daemon-busy guard (vault#323)", () => {
71
+ test("refuses with clear error + exit 1 when a server is bound to the configured port", async () => {
72
+ // Stand up a stub server that responds on /health so checkHealth
73
+ // returns either `healthy` or `unhealthy` — both signal that the
74
+ // port is owned by something. Either case triggers the guard.
75
+ // Bind to 127.0.0.1 explicitly so the subprocess's checkHealth
76
+ // (which probes 127.0.0.1:<port>) hits the same interface.
77
+ const server = Bun.serve({
78
+ port: 0,
79
+ hostname: "127.0.0.1",
80
+ fetch: () => new Response(JSON.stringify({ status: "ok" })),
81
+ });
82
+ try {
83
+ mkdirSync(join(home, "vault"), { recursive: true });
84
+ writeFileSync(join(home, "vault", "config.yaml"), `port: ${server.port}\n`);
85
+ const res = await runCli(
86
+ ["import", srcDir, "--vault", "default", "--yes"],
87
+ { PARACHUTE_HOME: home },
88
+ );
89
+ expect(res.exitCode).toBe(1);
90
+ expect(res.stderr).toMatch(/vault daemon is running on port/);
91
+ expect(res.stderr).toMatch(/parachute stop vault/);
92
+ } finally {
93
+ server.stop(true);
94
+ }
95
+ });
96
+
97
+ test("proceeds past the guard when nothing is listening on the configured port", async () => {
98
+ // Pick a port nothing's bound to. The daemon-busy message must NOT
99
+ // surface — that's the regression we're pinning.
100
+ const freePort = 1; // privileged, nothing should be listening here from this process
101
+ mkdirSync(join(home, "vault"), { recursive: true });
102
+ writeFileSync(join(home, "vault", "config.yaml"), `port: ${freePort}\n`);
103
+ const res = await runCli(
104
+ ["import", srcDir, "--vault", "default", "--yes"],
105
+ { PARACHUTE_HOME: home },
106
+ );
107
+ expect(res.stderr).not.toMatch(/vault daemon is running on port/);
108
+ });
109
+ });
package/src/routes.ts CHANGED
@@ -636,35 +636,10 @@ export async function handleNotes(
636
636
  );
637
637
  }
638
638
 
639
- // Empty-note pre-validation (#213): walk the batch first and reject the
640
- // whole request if any item would be content+path empty. This makes
641
- // mixed batches atomic for the empty-note case — no caller gets a
642
- // half-applied batch where the prefix landed and the empty entry
643
- // surfaced the 400. Mirrors the Store-level invariant exactly.
644
- for (let i = 0; i < items.length; i++) {
645
- const item = items[i];
646
- const content = (item?.content ?? "").toString();
647
- const rawPath = item?.path;
648
- const pathEmpty = rawPath === undefined || rawPath === null
649
- || (typeof rawPath === "string" && rawPath.trim() === "");
650
- if (!content.trim() && pathEmpty) {
651
- return json(
652
- {
653
- error_type: "empty_note",
654
- error: "EmptyNoteError",
655
- message: `empty_note: a note must have either content or a path (item index ${i})`,
656
- item_index: i,
657
- },
658
- 400,
659
- );
660
- }
661
- }
662
-
663
639
  // Tag-scope pre-validation: every new note in the batch must carry at
664
- // least one tag inside the token's allowlist. Same atomic-batch
665
- // discipline as the empty-note check reject the whole request before
666
- // any DB write so a tag-scoped token can't accidentally land a partial
667
- // batch with an in-scope prefix.
640
+ // least one tag inside the token's allowlist. Reject the whole request
641
+ // before any DB write so a tag-scoped token can't accidentally land a
642
+ // partial batch with an in-scope prefix.
668
643
  if (tagScope.allowed) {
669
644
  for (let i = 0; i < items.length; i++) {
670
645
  if (!tagsWithinScope(items[i]?.tags, tagScope.allowed, tagScope.raw)) {
@@ -713,17 +688,6 @@ export async function handleNotes(
713
688
  409,
714
689
  );
715
690
  }
716
- if (e && e.code === "EMPTY_NOTE") {
717
- return json(
718
- {
719
- error_type: "empty_note",
720
- error: "EmptyNoteError",
721
- message: e.message,
722
- item_index: e.item_index ?? null,
723
- },
724
- 400,
725
- );
726
- }
727
691
  throw e;
728
692
  }
729
693
 
@@ -893,6 +857,26 @@ export async function handleNotes(
893
857
  if (tagsArr.length > 0) {
894
858
  await applySchemaDefaults(store, db, [created.id], tagsArr);
895
859
  }
860
+ // vault#321 F2 — apply `links.add` on the create branch.
861
+ // MCP's create-on-missing branch already did this
862
+ // (`core/src/mcp.ts` if_missing=create block); the REST side
863
+ // was missing it, producing a cross-surface inconsistency
864
+ // operators (Gitcoin's drift sync) would trip on. Mirror the
865
+ // MCP recipe exactly:
866
+ // - `links.add` IS applied — drift sync can declare typed
867
+ // links at upsert time and have them materialize.
868
+ // - `links.remove` is ignored (nothing to remove on a
869
+ // fresh note).
870
+ // - Missing target notes skip silently (mirrors MCP).
871
+ const linksAdd = (body.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
872
+ if (linksAdd) {
873
+ for (const link of linksAdd) {
874
+ const target = await resolveNote(store, link.target);
875
+ if (target) {
876
+ await store.createLink(created.id, target.id, link.relationship, link.metadata);
877
+ }
878
+ }
879
+ }
896
880
  const final = await store.getNote(created.id);
897
881
  if (!final) return json({ error: "Note disappeared" }, 500);
898
882
  const validated = attachValidationStatus(store, db, final);
@@ -1124,20 +1108,6 @@ export async function handleNotes(
1124
1108
  409,
1125
1109
  );
1126
1110
  }
1127
- // Empty-note guard from the Store boundary (#213) — the proposed update
1128
- // would clear both content AND path. Surface as 400 so callers can fix
1129
- // the request without retrying.
1130
- if (e && e.code === "EMPTY_NOTE") {
1131
- return json(
1132
- {
1133
- error_type: "empty_note",
1134
- error: "EmptyNoteError",
1135
- message: e.message,
1136
- note_id: e.note_id ?? null,
1137
- },
1138
- 400,
1139
- );
1140
- }
1141
1111
  throw e;
1142
1112
  }
1143
1113
  }
package/src/vault.test.ts CHANGED
@@ -1883,36 +1883,30 @@ describe("HTTP /notes", async () => {
1883
1883
  });
1884
1884
 
1885
1885
  // -------------------------------------------------------------------------
1886
- // Empty-note guard + batch cap (#213) — runaway-client protection
1886
+ // Empty content is a valid state (vault#323)
1887
1887
  // -------------------------------------------------------------------------
1888
-
1889
- describe("empty-note guard (#213)", async () => {
1890
- test("POST bare {} body 400 EmptyNoteError", async () => {
1888
+ // Skeleton notes, drafts, organizing-only notes, and capture-then-fill
1889
+ // flows all legitimately produce empty-content rows. The earlier #213
1890
+ // guard rejected `content + path both absent` we no longer enforce
1891
+ // it because real vaults carry such rows and the round-trip import
1892
+ // has to accept them.
1893
+
1894
+ describe("empty content is valid (vault#323)", async () => {
1895
+ test("POST bare {} body → 201", async () => {
1891
1896
  const res = await handleNotes(mkReq("POST", "/notes", {}), store, "");
1892
- expect(res.status).toBe(400);
1893
- const body = await res.json() as any;
1894
- expect(body.error_type).toBe("empty_note");
1895
- expect(body.error).toBe("EmptyNoteError");
1897
+ expect(res.status).toBe(201);
1896
1898
  });
1897
1899
 
1898
- test("POST batch with one empty entry400 EmptyNoteError, NOTHING created (atomic)", async () => {
1899
- // Pre-validate the batch before any DB writes so a mixed batch with one
1900
- // bad entry rolls back the whole call. The runaway-client signature
1901
- // (#213) is "thousands of empties" — partial-create semantics would
1902
- // still leak the prefix on every burst. Atomic is the only safe shape.
1900
+ test("POST batch with mixed empty + content entries 201, all created", async () => {
1903
1901
  const beforeCount = (await store.queryNotes({ path: "ok-1" })).length;
1904
1902
  const res = await handleNotes(
1905
- mkReq("POST", "/notes", { notes: [{ path: "ok-1" }, {}] }),
1903
+ mkReq("POST", "/notes", { notes: [{ path: "ok-1" }, {}, { content: "third" }] }),
1906
1904
  store,
1907
1905
  "",
1908
1906
  );
1909
- expect(res.status).toBe(400);
1910
- const body = await res.json() as any;
1911
- expect(body.error_type).toBe("empty_note");
1912
- expect(body.item_index).toBe(1);
1913
- // ok-1 must NOT have been created — atomic rollback.
1907
+ expect(res.status).toBe(201);
1914
1908
  const afterCount = (await store.queryNotes({ path: "ok-1" })).length;
1915
- expect(afterCount).toBe(beforeCount);
1909
+ expect(afterCount).toBe(beforeCount + 1);
1916
1910
  });
1917
1911
 
1918
1912
  test("POST single content-only (path absent) → 201", async () => {
@@ -1924,9 +1918,7 @@ describe("HTTP /notes", async () => {
1924
1918
  expect(res.status).toBe(201);
1925
1919
  });
1926
1920
 
1927
- test("POST single path-only (content absent) → 201, no warning log", async () => {
1928
- // Path-only is a wikilink placeholder / `_schemas/*` shape — must
1929
- // remain accepted (per #223 design Q3).
1921
+ test("POST single path-only (content absent) → 201", async () => {
1930
1922
  const res = await handleNotes(
1931
1923
  mkReq("POST", "/notes", { path: "wiki/placeholder" }),
1932
1924
  store,
@@ -1935,8 +1927,8 @@ describe("HTTP /notes", async () => {
1935
1927
  expect(res.status).toBe(201);
1936
1928
  });
1937
1929
 
1938
- test("PATCH that would clear both content and path → 400 EmptyNoteError", async () => {
1939
- const note = await store.createNote("starts with content", { id: "ep1" });
1930
+ test("PATCH that clears both content and path → 200", async () => {
1931
+ await store.createNote("starts with content", { id: "ep1" });
1940
1932
  const updated = await store.getNote("ep1");
1941
1933
  const res = await handleNotes(
1942
1934
  mkReq("PATCH", "/notes/ep1", {
@@ -1947,14 +1939,11 @@ describe("HTTP /notes", async () => {
1947
1939
  store,
1948
1940
  "/ep1",
1949
1941
  );
1950
- expect(res.status).toBe(400);
1951
- const body = await res.json() as any;
1952
- expect(body.error_type).toBe("empty_note");
1953
- expect(body.note_id).toBe("ep1");
1942
+ expect(res.status).toBe(200);
1954
1943
  });
1955
1944
 
1956
1945
  test("PATCH that clears content but preserves path → 200", async () => {
1957
- const note = await store.createNote("body", { id: "ep2", path: "p2" });
1946
+ await store.createNote("body", { id: "ep2", path: "p2" });
1958
1947
  const updated = await store.getNote("ep2");
1959
1948
  const res = await handleNotes(
1960
1949
  mkReq("PATCH", "/notes/ep2", {
@@ -1970,8 +1959,7 @@ describe("HTTP /notes", async () => {
1970
1959
 
1971
1960
  describe("batch atomicity (#236)", async () => {
1972
1961
  test("POST batch where mid-item triggers PATH_CONFLICT → 409, NOTHING created", async () => {
1973
- // The empty-note pre-walk catches `{}` before any DB write (#213); a
1974
- // path-conflict only surfaces on the actual INSERT, mid-loop. Without
1962
+ // A path-conflict only surfaces on the actual INSERT, mid-loop. Without
1975
1963
  // the BEGIN/COMMIT wrap the prefix would have already landed by then.
1976
1964
  await store.createNote("existing", { path: "taken" });
1977
1965
  const beforeIds = (await store.queryNotes({})).map((n) => n.id).sort();
@@ -2998,6 +2986,105 @@ describe("HTTP PATCH /notes/:idOrPath if_missing=create (vault#309)", async () =
2998
2986
  const body = await res.json() as any;
2999
2987
  expect(body.created).toBe(false);
3000
2988
  });
2989
+
2990
+ // vault#321 F2 — REST create-on-missing branch applies links.add.
2991
+ // MCP's create branch already did; REST was missing the
2992
+ // link-creation pass entirely. Cross-surface inconsistency Gitcoin
2993
+ // would trip on if they migrated from MCP to REST. The new pass
2994
+ // mirrors MCP exactly (links.add applied, links.remove ignored,
2995
+ // missing targets skip silently).
2996
+ test("if_missing=create + links.add creates typed-link rows (vault#321 F2)", async () => {
2997
+ // Two pre-existing target notes (different ids + paths) so the
2998
+ // source can fan out to both.
2999
+ await store.createNote("target A", { id: "t-a-321", path: "Targets/A" });
3000
+ await store.createNote("target B", { id: "t-b-321", path: "Targets/B" });
3001
+
3002
+ const res = await handleNotes(
3003
+ mkReq("PATCH", `/notes/${encodeURIComponent("Inbox/source-321")}`, {
3004
+ content: "source body",
3005
+ if_missing: "create",
3006
+ links: {
3007
+ add: [
3008
+ { target: "t-a-321", relationship: "derived-from" },
3009
+ { target: "Targets/B", relationship: "responds-to", metadata: { weight: 5 } },
3010
+ ],
3011
+ },
3012
+ }),
3013
+ store,
3014
+ `/${encodeURIComponent("Inbox/source-321")}`,
3015
+ );
3016
+ expect(res.status).toBe(200);
3017
+ const body = await res.json() as any;
3018
+ expect(body.created).toBe(true);
3019
+
3020
+ // Link rows exist + resolved targets correctly. We look up by the
3021
+ // source's note id (the body returned by the create branch).
3022
+ const sourceId = body.id as string;
3023
+ const outboundLinks = await store.getLinks(sourceId, { direction: "outbound" });
3024
+ const derivedFrom = outboundLinks.find((l) => l.relationship === "derived-from");
3025
+ expect(derivedFrom).toBeDefined();
3026
+ expect(derivedFrom!.targetId).toBe("t-a-321");
3027
+
3028
+ const respondsTo = outboundLinks.find((l) => l.relationship === "responds-to");
3029
+ expect(respondsTo).toBeDefined();
3030
+ expect(respondsTo!.targetId).toBe("t-b-321");
3031
+ expect(respondsTo!.metadata).toEqual({ weight: 5 });
3032
+ });
3033
+
3034
+ test("if_missing=create + links.add silently skips when target does not exist (vault#321 F2)", async () => {
3035
+ // Mirrors MCP: missing target → silent skip, no error. Sync loops
3036
+ // that declare links to not-yet-imported notes shouldn't abort
3037
+ // the whole upsert.
3038
+ const res = await handleNotes(
3039
+ mkReq("PATCH", `/notes/${encodeURIComponent("Inbox/source-missing-321")}`, {
3040
+ content: "x",
3041
+ if_missing: "create",
3042
+ links: { add: [{ target: "does-not-exist", relationship: "derived-from" }] },
3043
+ }),
3044
+ store,
3045
+ `/${encodeURIComponent("Inbox/source-missing-321")}`,
3046
+ );
3047
+ expect(res.status).toBe(200);
3048
+ const body = await res.json() as any;
3049
+ expect(body.created).toBe(true);
3050
+ // The note is created. No links resolved.
3051
+ const links = await store.getLinks(body.id, { direction: "outbound" });
3052
+ expect(links.filter((l) => l.relationship === "derived-from")).toHaveLength(0);
3053
+ });
3054
+
3055
+ // vault#321 F3 — schema-conflict warning on REST create branch
3056
+ // (mirror of the MCP test in core.test.ts). Same conflict-detection
3057
+ // path runs on both surfaces via attachValidationStatus, but we pin
3058
+ // both ends explicitly so a regression on either side surfaces
3059
+ // immediately.
3060
+ test("schema-conflict warning surfaces on REST create branch (vault#321 F3)", async () => {
3061
+ await store.upsertTagSchema("kpi-rest-321", {
3062
+ fields: { count: { type: "integer" } },
3063
+ });
3064
+ await store.upsertTagSchema("metric-rest-321", {
3065
+ fields: { count: { type: "string" } }, // conflicting
3066
+ });
3067
+ const res = await handleNotes(
3068
+ mkReq("PATCH", `/notes/${encodeURIComponent("Inbox/conflict-321")}`, {
3069
+ content: "x",
3070
+ if_missing: "create",
3071
+ tags: ["kpi-rest-321", "metric-rest-321"],
3072
+ metadata: { count: 5 },
3073
+ }),
3074
+ store,
3075
+ `/${encodeURIComponent("Inbox/conflict-321")}`,
3076
+ );
3077
+ expect(res.status).toBe(200);
3078
+ const body = await res.json() as any;
3079
+ expect(body.created).toBe(true);
3080
+ const conflict = body.validation_status.warnings.find(
3081
+ (w: any) => w.reason === "schema_conflict",
3082
+ );
3083
+ expect(conflict).toBeDefined();
3084
+ expect(conflict.field).toBe("count");
3085
+ expect(conflict.schema).toBe("kpi-rest-321");
3086
+ expect(conflict.loser_schema).toBe("metric-rest-321");
3087
+ });
3001
3088
  });
3002
3089
 
3003
3090
  describe("HTTP /tags", async () => {