@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.
- package/core/src/core.test.ts +127 -60
- package/core/src/mcp.ts +6 -27
- package/core/src/notes.ts +8 -72
- package/core/src/portable-md.test.ts +23 -1
- package/package.json +1 -1
- package/src/cli.ts +17 -0
- package/src/import-daemon-busy.test.ts +109 -0
- package/src/routes.ts +23 -53
- package/src/vault.test.ts +119 -32
package/core/src/core.test.ts
CHANGED
|
@@ -189,15 +189,31 @@ describe("notes", async () => {
|
|
|
189
189
|
});
|
|
190
190
|
|
|
191
191
|
// -------------------------------------------------------------------------
|
|
192
|
-
// Empty
|
|
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
|
|
196
|
-
await
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
231
|
+
it("updateNote allows clearing both content and path", async () => {
|
|
216
232
|
const n = await store.createNote("body", { path: "p" });
|
|
217
|
-
await
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
242
|
+
it("updateNote allows clearing content when path is already null", async () => {
|
|
223
243
|
const n = await store.createNote("body");
|
|
224
|
-
await
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
2836
|
+
// ---- empty-note acceptance (vault#323) + batch-cap MCP ----
|
|
2815
2837
|
|
|
2816
|
-
it("create-note
|
|
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
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
expect(
|
|
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
|
|
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
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
expect(
|
|
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.
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
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,
|
|
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
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
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
|
|
240
|
-
//
|
|
241
|
-
//
|
|
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(
|
|
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
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.
|
|
665
|
-
//
|
|
666
|
-
//
|
|
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
|
|
1886
|
+
// Empty content is a valid state (vault#323)
|
|
1887
1887
|
// -------------------------------------------------------------------------
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
1939
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
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 () => {
|