@openparachute/vault 0.7.0-rc.7 → 0.7.0-rc.9

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.
@@ -597,6 +597,22 @@ describe("renameTag cascade (vault#240 + #247)", async () => {
597
597
  expect(fresh!.content).toContain("#other"); // untouched
598
598
  });
599
599
 
600
+ // vault#555 fix 2 — a rename cascade's inline content rewrite is a real
601
+ // persisted-state change; `updated_at` must move or a cursor/sync-poll
602
+ // loop never sees the rewritten note.
603
+ it("1b. content rewrite bumps updated_at (vault#555)", async () => {
604
+ const note = await store.createNote("Today's #task is important.", { tags: ["task"] });
605
+ const before = note.updatedAt;
606
+ await new Promise((r) => setTimeout(r, 5));
607
+
608
+ await store.renameTag("task", "todo");
609
+
610
+ const fresh = await store.getNote(note.id);
611
+ expect(fresh!.content).toContain("#todo");
612
+ expect(fresh!.updatedAt).not.toBe(before);
613
+ expect(new Date(fresh!.updatedAt) > new Date(before)).toBe(true);
614
+ });
615
+
600
616
  it("2. cascades sub-tags recursively (task → todo, task/work → todo/work, task/work/client → todo/work/client)", async () => {
601
617
  await store.createNote("a", { tags: ["task"] });
602
618
  await store.createNote("b", { tags: ["task/work"] });
@@ -2253,6 +2269,92 @@ describe("MCP tools", async () => {
2253
2269
  expect(links.some((l) => l.relationship === "mentions")).toBe(true);
2254
2270
  });
2255
2271
 
2272
+ // vault#555 — structured `links` used to resolve by exact path only (no
2273
+ // basename/title fallback, unlike [[wikilinks]]). "Bare Title" against a
2274
+ // note filed at "People/Alice" is a basename match, same as `[[Alice]]`.
2275
+ it("create-note with links resolves targets by BASENAME (title), same as [[wikilinks]]", async () => {
2276
+ const tools = generateMcpTools(store);
2277
+ const createNote = tools.find((t) => t.name === "create-note")!;
2278
+ await store.createNote("Target", { path: "People/Alice" });
2279
+ const result = await createNote.execute({
2280
+ content: "Links to Alice by bare title",
2281
+ links: [{ target: "Alice", relationship: "knows" }],
2282
+ }) as any;
2283
+ const links = await store.getLinks(result.id, { direction: "outbound" });
2284
+ expect(links).toHaveLength(1);
2285
+ expect(links[0]!.relationship).toBe("knows");
2286
+ });
2287
+
2288
+ // vault#555 — a link to a note created LATER in the same create-note
2289
+ // batch used to silently drop (item 0 resolved before item 1 existed).
2290
+ // The equivalent forward-ref via a [[wikilink]] already resolved; this
2291
+ // makes structured `links` match.
2292
+ it("create-note batch: a link to a note created LATER in the same batch resolves (forward-ref)", async () => {
2293
+ const tools = generateMcpTools(store);
2294
+ const createNote = tools.find((t) => t.name === "create-note")!;
2295
+ const result = await createNote.execute({
2296
+ notes: [
2297
+ { path: "A", content: "links to B", links: [{ target: "B", relationship: "knows" }] },
2298
+ { path: "B", content: "the target" },
2299
+ ],
2300
+ }) as any[];
2301
+ const a = result.find((n: any) => n.path === "A");
2302
+ const links = await store.getLinks(a.id, { direction: "outbound" });
2303
+ expect(links).toHaveLength(1);
2304
+ expect(links[0]!.relationship).toBe("knows");
2305
+ // No warning — it resolved within the batch.
2306
+ expect(a.warnings).toBeUndefined();
2307
+ });
2308
+
2309
+ // vault#555 — a target that genuinely can't resolve now must surface an
2310
+ // `unresolved_link` warning (never silent), AND lazily backfill the edge
2311
+ // the moment a matching note is created later — mirroring the
2312
+ // `unresolved_wikilinks` forward-ref contract.
2313
+ it("create-note with an unresolvable link target: warns now, backfills the edge when the target is created later", async () => {
2314
+ const tools = generateMcpTools(store);
2315
+ const createNote = tools.find((t) => t.name === "create-note")!;
2316
+ const source = await createNote.execute({
2317
+ content: "links to a note that doesn't exist yet",
2318
+ links: [{ target: "Nonexistent Target", relationship: "knows" }],
2319
+ }) as any;
2320
+
2321
+ expect(source.warnings).toBeDefined();
2322
+ expect(source.warnings).toHaveLength(1);
2323
+ expect(source.warnings[0].code).toBe("unresolved_link");
2324
+ expect(source.warnings[0].target).toBe("Nonexistent Target");
2325
+ expect(source.warnings[0].relationship).toBe("knows");
2326
+ expect(await store.getLinks(source.id, { direction: "outbound" })).toHaveLength(0);
2327
+
2328
+ // Creating the target note later backfills the edge automatically.
2329
+ const target = await store.createNote("here now", { path: "Nonexistent Target" });
2330
+ const links = await store.getLinks(source.id, { direction: "outbound" });
2331
+ expect(links).toHaveLength(1);
2332
+ expect(links[0]!.targetId).toBe(target.id);
2333
+ expect(links[0]!.relationship).toBe("knows");
2334
+ });
2335
+
2336
+ // vault#555 — structured-link forward-refs (any relationship) must
2337
+ // coexist with pending [[wikilink]] forward-refs on the SAME note without
2338
+ // one clobbering the other when the note's content is re-synced.
2339
+ it("pending structured-link forward-ref survives a wikilink content re-sync on the same note", async () => {
2340
+ const tools = generateMcpTools(store);
2341
+ const createNote = tools.find((t) => t.name === "create-note")!;
2342
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2343
+ const source = await createNote.execute({
2344
+ content: "no wikilinks yet",
2345
+ links: [{ target: "Future Note", relationship: "knows" }],
2346
+ }) as any;
2347
+ expect(source.warnings?.[0]?.code).toBe("unresolved_link");
2348
+
2349
+ // Editing content (which re-syncs [[wikilink]]-kind unresolved entries)
2350
+ // must not wipe the pending structured-link entry.
2351
+ await updateNote.execute({ id: source.id, content: "now with a [[Some Other Unresolved Note]]", force: true });
2352
+
2353
+ const target = await store.createNote("arrived", { path: "Future Note" });
2354
+ const links = await store.getLinks(source.id, { direction: "outbound" });
2355
+ expect(links.some((l) => l.targetId === target.id && l.relationship === "knows")).toBe(true);
2356
+ });
2357
+
2256
2358
  it("update-note tool updates created_at", async () => {
2257
2359
  const note = await store.createNote("Test");
2258
2360
  const tools = generateMcpTools(store);
@@ -2287,6 +2389,47 @@ describe("MCP tools", async () => {
2287
2389
  expect((await store.getNote(note.id))!.tags).toContain("daily");
2288
2390
  });
2289
2391
 
2392
+ // vault#555 fix 2 — a tag-only (or links-only) update-note call with
2393
+ // `force: true` (no `if_updated_at`) used to leave `updated_at` frozen:
2394
+ // the mcp.ts `updates` object had no core fields, so `store.updateNote`
2395
+ // was never even called. Breaks cursor polling (ORDER BY updated_at) and
2396
+ // any updated_at-based sync filter.
2397
+ it("update-note tags-only mutation with force:true bumps updated_at", async () => {
2398
+ const note = await store.createNote("Test");
2399
+ const before = note.updatedAt;
2400
+ const tools = generateMcpTools(store);
2401
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2402
+ await new Promise((r) => setTimeout(r, 5));
2403
+
2404
+ await updateNote.execute({ id: note.id, tags: { add: ["pinned"] }, force: true });
2405
+ const after1 = (await store.getNote(note.id))!.updatedAt;
2406
+ expect(after1).not.toBe(before);
2407
+ expect(new Date(after1) > new Date(before)).toBe(true);
2408
+
2409
+ await new Promise((r) => setTimeout(r, 5));
2410
+ await updateNote.execute({ id: note.id, tags: { remove: ["pinned"] }, force: true });
2411
+ const after2 = (await store.getNote(note.id))!.updatedAt;
2412
+ expect(new Date(after2) > new Date(after1)).toBe(true);
2413
+ });
2414
+
2415
+ it("update-note links-only mutation with force:true bumps updated_at", async () => {
2416
+ await store.createNote("A", { id: "a" });
2417
+ await store.createNote("B", { id: "b" });
2418
+ const before = (await store.getNote("a"))!.updatedAt;
2419
+ const tools = generateMcpTools(store);
2420
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2421
+ await new Promise((r) => setTimeout(r, 5));
2422
+
2423
+ await updateNote.execute({ id: "a", links: { add: [{ target: "b", relationship: "mentions" }] }, force: true });
2424
+ const after1 = (await store.getNote("a"))!.updatedAt;
2425
+ expect(new Date(after1) > new Date(before)).toBe(true);
2426
+
2427
+ await new Promise((r) => setTimeout(r, 5));
2428
+ await updateNote.execute({ id: "a", links: { remove: [{ target: "b", relationship: "mentions" }] }, force: true });
2429
+ const after2 = (await store.getNote("a"))!.updatedAt;
2430
+ expect(new Date(after2) > new Date(after1)).toBe(true);
2431
+ });
2432
+
2290
2433
  it("update-note links add/remove works", async () => {
2291
2434
  await store.createNote("A", { id: "a" });
2292
2435
  await store.createNote("B", { id: "b" });
@@ -2302,6 +2445,38 @@ describe("MCP tools", async () => {
2302
2445
  expect(await store.getLinks("a", { direction: "outbound" })).toHaveLength(0);
2303
2446
  });
2304
2447
 
2448
+ // vault#555 — update-note's links.add gets the same basename/title
2449
+ // resolution + lazy forward-ref queueing as create-note's.
2450
+ it("update-note links.add resolves by BASENAME and warns+queues when unresolvable", async () => {
2451
+ await store.createNote("A", { id: "a" });
2452
+ await store.createNote("Target", { path: "People/Bob" });
2453
+ const tools = generateMcpTools(store);
2454
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2455
+
2456
+ // Basename match
2457
+ const byTitle = await updateNote.execute({
2458
+ id: "a",
2459
+ links: { add: [{ target: "Bob", relationship: "knows" }] },
2460
+ force: true,
2461
+ }) as any;
2462
+ expect(byTitle.warnings).toBeUndefined();
2463
+ expect(await store.getLinks("a", { direction: "outbound" })).toHaveLength(1);
2464
+
2465
+ // Unresolvable target: warns, queues, backfills later.
2466
+ const unresolved = await updateNote.execute({
2467
+ id: "a",
2468
+ links: { add: [{ target: "Not Yet Real", relationship: "wants" }] },
2469
+ force: true,
2470
+ }) as any;
2471
+ expect(unresolved.warnings).toBeDefined();
2472
+ expect(unresolved.warnings[0].code).toBe("unresolved_link");
2473
+ expect(unresolved.warnings[0].target).toBe("Not Yet Real");
2474
+
2475
+ const target = await store.createNote("arrived", { path: "Not Yet Real" });
2476
+ const links = await store.getLinks("a", { direction: "outbound" });
2477
+ expect(links.some((l) => l.targetId === target.id && l.relationship === "wants")).toBe(true);
2478
+ });
2479
+
2305
2480
  // vault feedback #8 — echo hydrated links on the update response when the
2306
2481
  // request mutated links OR `include_links` is set, so callers don't re-query.
2307
2482
  it("update-note echoes hydrated links when the update mutates links", async () => {
@@ -3030,6 +3205,126 @@ describe("MCP tools", async () => {
3030
3205
  expect(err?.code).toBe("CONFLICT");
3031
3206
  });
3032
3207
 
3208
+ // vault#555 fix 7 (INVESTIGATE) — a tester reported update-note batch
3209
+ // AND parallel singles failing 7/8 with "old_text not found" when
3210
+ // multiple items share the SAME content_edit.old_text, though each
3211
+ // succeeded standalone. The orchestrator could NOT reproduce live (repro
3212
+ // hit shell artifacts). Investigated here with proper in-memory tests:
3213
+ //
3214
+ // - Batch AND parallel-singles, N items EACH TARGETING A DIFFERENT
3215
+ // note, all sharing the same old_text: every item succeeds. Each
3216
+ // item resolves its OWN note's content fresh (there's no shared
3217
+ // mutable state across items/calls — `resolveNote` reads straight
3218
+ // off the DB per item, and the content_edit search+replace is pure
3219
+ // synchronous JS with no `await` between the read and the write, so
3220
+ // two "parallel" async calls can't interleave mid-computation
3221
+ // either). Re-run 20x below with zero failures.
3222
+ // - The one scenario that DOES reproduce "1 succeeds, 7 fail
3223
+ // content_edit_not_found" is N calls sharing the same old_text
3224
+ // against the SAME note — and that's CORRECT behavior (the first
3225
+ // call consumes the only occurrence; the rest correctly report it's
3226
+ // gone), not a bug. This is the most plausible explanation for what
3227
+ // was actually observed: a test harness that accidentally pointed
3228
+ // every call at one note (a variable/loop bug — "shell artifacts")
3229
+ // rather than N distinct notes.
3230
+ //
3231
+ // VERDICT: not reproducible as a real bug against different notes. No
3232
+ // fix applied — these tests document the correct behavior.
3233
+ it("update-note BATCH: N items with the SAME content_edit.old_text on DIFFERENT notes all succeed", async () => {
3234
+ const tools = generateMcpTools(store);
3235
+ const updateNote = tools.find((t) => t.name === "update-note")!;
3236
+ const N = 8;
3237
+ const notes = [];
3238
+ for (let i = 0; i < N; i++) {
3239
+ notes.push(await store.createNote(`prefix TARGET_TEXT suffix-${i}`, { path: `batch-ce-${i}` }));
3240
+ }
3241
+
3242
+ const result = await updateNote.execute({
3243
+ notes: notes.map((n) => ({
3244
+ id: n.id,
3245
+ content_edit: { old_text: "TARGET_TEXT", new_text: "REPLACED" },
3246
+ force: true,
3247
+ })),
3248
+ }) as any[];
3249
+
3250
+ expect(result).toHaveLength(N);
3251
+ for (let i = 0; i < N; i++) {
3252
+ expect(result[i].content).toBe(`prefix REPLACED suffix-${i}`);
3253
+ }
3254
+ });
3255
+
3256
+ it("update-note PARALLEL SINGLES: N concurrent calls with the SAME content_edit.old_text on DIFFERENT notes all succeed", async () => {
3257
+ const tools = generateMcpTools(store);
3258
+ const updateNote = tools.find((t) => t.name === "update-note")!;
3259
+ const N = 8;
3260
+ const notes = [];
3261
+ for (let i = 0; i < N; i++) {
3262
+ notes.push(await store.createNote(`prefix TARGET_TEXT suffix-${i}`, { path: `parallel-ce-${i}` }));
3263
+ }
3264
+
3265
+ const results = await Promise.all(
3266
+ notes.map((n) =>
3267
+ updateNote.execute({
3268
+ id: n.id,
3269
+ content_edit: { old_text: "TARGET_TEXT", new_text: "REPLACED" },
3270
+ force: true,
3271
+ }),
3272
+ ),
3273
+ ) as any[];
3274
+
3275
+ for (let i = 0; i < N; i++) {
3276
+ expect(results[i].content).toBe(`prefix REPLACED suffix-${i}`);
3277
+ }
3278
+ });
3279
+
3280
+ it("update-note PARALLEL SINGLES with shared old_text: 20 repeated trials, zero failures (flake hunt)", async () => {
3281
+ for (let trial = 0; trial < 20; trial++) {
3282
+ const trialDb = new Database(":memory:");
3283
+ const trialStore = new SqliteStore(trialDb);
3284
+ const tools = generateMcpTools(trialStore);
3285
+ const updateNote = tools.find((t) => t.name === "update-note")!;
3286
+ const N = 8;
3287
+ const notes = [];
3288
+ for (let i = 0; i < N; i++) {
3289
+ notes.push(await trialStore.createNote(`prefix TARGET_TEXT suffix-${i}`, { path: `flake-${i}` }));
3290
+ }
3291
+ const outcomes = await Promise.allSettled(
3292
+ notes.map((n) =>
3293
+ updateNote.execute({
3294
+ id: n.id,
3295
+ content_edit: { old_text: "TARGET_TEXT", new_text: "REPLACED" },
3296
+ force: true,
3297
+ }),
3298
+ ),
3299
+ );
3300
+ const failures = outcomes.filter((o) => o.status === "rejected");
3301
+ expect(failures.length).toBe(0);
3302
+ }
3303
+ });
3304
+
3305
+ it("update-note with shared old_text against the SAME note: 1 succeeds, 7 correctly fail (documents the LIKELY source of the original report — not a bug)", async () => {
3306
+ const tools = generateMcpTools(store);
3307
+ const updateNote = tools.find((t) => t.name === "update-note")!;
3308
+ const note = await store.createNote("prefix TARGET_TEXT suffix", { path: "shared-ce" });
3309
+
3310
+ const outcomes = await Promise.allSettled(
3311
+ Array.from({ length: 8 }, () =>
3312
+ updateNote.execute({
3313
+ id: note.id,
3314
+ content_edit: { old_text: "TARGET_TEXT", new_text: "REPLACED" },
3315
+ force: true,
3316
+ }),
3317
+ ),
3318
+ );
3319
+ const succeeded = outcomes.filter((o) => o.status === "fulfilled").length;
3320
+ const failed = outcomes.filter((o) => o.status === "rejected");
3321
+ expect(succeeded).toBe(1);
3322
+ expect(failed).toHaveLength(7);
3323
+ for (const f of failed) {
3324
+ expect((f as PromiseRejectedResult).reason.error_type).toBe("content_edit_not_found");
3325
+ }
3326
+ });
3327
+
3033
3328
  it("query-notes single note by id", async () => {
3034
3329
  const note = await store.createNote("Hello", { path: "test/note" });
3035
3330
  const tools = generateMcpTools(store);
@@ -3081,6 +3376,93 @@ describe("MCP tools", async () => {
3081
3376
  expect(result.map((n) => n.content)).toEqual(["Orphan"]);
3082
3377
  });
3083
3378
 
3379
+ // ---- has_broken_links / include_broken_links (vault#555) ----
3380
+
3381
+ it("query-notes has_broken_links=true surfaces only notes with a dangling wikilink", async () => {
3382
+ await store.createNote("[[Nonexistent Target]]", { id: "mq-broken", path: "mq-broken" });
3383
+ await store.createNote("no links here", { id: "mq-clean", path: "mq-clean" });
3384
+
3385
+ const tools = generateMcpTools(store);
3386
+ const query = tools.find((t) => t.name === "query-notes")!;
3387
+ const result = await query.execute({ has_broken_links: true, include_content: true }) as any[];
3388
+ expect(result.map((n) => n.path)).toEqual(["mq-broken"]);
3389
+ });
3390
+
3391
+ it("query-notes has_broken_links=false excludes notes with a dangling link", async () => {
3392
+ await store.createNote("[[Nonexistent Target]]", { id: "mq-broken2", path: "mq-broken2" });
3393
+ await store.createNote("no links here", { id: "mq-clean2", path: "mq-clean2" });
3394
+
3395
+ const tools = generateMcpTools(store);
3396
+ const query = tools.find((t) => t.name === "query-notes")!;
3397
+ const result = await query.execute({ has_broken_links: false, include_content: true }) as any[];
3398
+ expect(result.map((n) => n.path).sort()).toEqual(["mq-clean2"]);
3399
+ });
3400
+
3401
+ it("query-notes has_broken_links is safe on a vault where no link has ever gone unresolved", async () => {
3402
+ // Fresh store, beforeEach — the unresolved_wikilinks table has never
3403
+ // been created. true should match nothing (not throw); false should
3404
+ // be a no-op (matches everything).
3405
+ await store.createNote("plain note", { id: "mq-nolinks", path: "mq-nolinks" });
3406
+ const tools = generateMcpTools(store);
3407
+ const query = tools.find((t) => t.name === "query-notes")!;
3408
+ const truthy = await query.execute({ has_broken_links: true, include_content: true }) as any[];
3409
+ expect(truthy).toEqual([]);
3410
+ const falsy = await query.execute({ has_broken_links: false, include_content: true }) as any[];
3411
+ expect(falsy.map((n) => n.path)).toEqual(["mq-nolinks"]);
3412
+ });
3413
+
3414
+ it("a target created later resolves the wikilink and the note drops out of has_broken_links:true", async () => {
3415
+ await store.createNote("[[Later Target]]", { id: "mq-fixable", path: "mq-fixable" });
3416
+ const tools = generateMcpTools(store);
3417
+ const query = tools.find((t) => t.name === "query-notes")!;
3418
+ expect((await query.execute({ has_broken_links: true }) as any[]).length).toBe(1);
3419
+
3420
+ await store.createNote("here now", { path: "Later Target" });
3421
+ expect((await query.execute({ has_broken_links: true }) as any[]).length).toBe(0);
3422
+ });
3423
+
3424
+ it("query-notes include_broken_links surfaces {target, relationship} for a single note", async () => {
3425
+ await store.createNote("[[Missing Note]]", { id: "mq-single-broken", path: "mq-single-broken" });
3426
+ const tools = generateMcpTools(store);
3427
+ const query = tools.find((t) => t.name === "query-notes")!;
3428
+ const result = await query.execute({ id: "mq-single-broken", include_broken_links: true }) as any;
3429
+ expect(result.broken_links).toEqual([{ target: "Missing Note", relationship: "wikilink" }]);
3430
+ });
3431
+
3432
+ it("query-notes include_broken_links surfaces a structured link's unresolved_link entry too", async () => {
3433
+ const created = await generateMcpTools(store).find((t) => t.name === "create-note")!.execute({
3434
+ content: "body",
3435
+ path: "mq-structured-broken",
3436
+ links: [{ target: "Does Not Exist", relationship: "depends-on" }],
3437
+ }) as any;
3438
+ const tools = generateMcpTools(store);
3439
+ const query = tools.find((t) => t.name === "query-notes")!;
3440
+ const result = await query.execute({ id: created.id, include_broken_links: true }) as any;
3441
+ expect(result.broken_links).toEqual([{ target: "Does Not Exist", relationship: "depends-on" }]);
3442
+ });
3443
+
3444
+ it("query-notes include_broken_links is [] for a note with no dangling links", async () => {
3445
+ await store.createNote("clean", { id: "mq-clean-single", path: "mq-clean-single" });
3446
+ const tools = generateMcpTools(store);
3447
+ const query = tools.find((t) => t.name === "query-notes")!;
3448
+ const result = await query.execute({ id: "mq-clean-single", include_broken_links: true }) as any;
3449
+ expect(result.broken_links).toEqual([]);
3450
+ });
3451
+
3452
+ it("query-notes include_broken_links works in list mode, batched across the page", async () => {
3453
+ await store.createNote("[[Ghost A]]", { id: "mq-list-a", path: "mq-list-a" });
3454
+ await store.createNote("[[Ghost B]]", { id: "mq-list-b", path: "mq-list-b" });
3455
+ await store.createNote("clean", { id: "mq-list-c", path: "mq-list-c" });
3456
+
3457
+ const tools = generateMcpTools(store);
3458
+ const query = tools.find((t) => t.name === "query-notes")!;
3459
+ const result = await query.execute({ include_broken_links: true, include_content: true }) as any[];
3460
+ const byId = new Map(result.map((n: any) => [n.id, n.broken_links]));
3461
+ expect(byId.get("mq-list-a")).toEqual([{ target: "Ghost A", relationship: "wikilink" }]);
3462
+ expect(byId.get("mq-list-b")).toEqual([{ target: "Ghost B", relationship: "wikilink" }]);
3463
+ expect(byId.get("mq-list-c")).toEqual([]);
3464
+ });
3465
+
3084
3466
  it("query-notes metadata operator query routes through the indexed column", async () => {
3085
3467
  const { declareField } = await import("./indexed-fields.js");
3086
3468
  declareField(db, "priority", "INTEGER", "project");
@@ -3433,6 +3815,72 @@ describe("MCP tools", async () => {
3433
3815
  expect(result.fields.age.type).toBe("integer");
3434
3816
  });
3435
3817
 
3818
+ // vault#555 fix 4 — a non-indexed field's `type` was never validated
3819
+ // anywhere; a genuinely unrecognized type string was accepted and
3820
+ // persisted verbatim, no error.
3821
+ it("update-tag rejects an unrecognized field type (invalid_type)", async () => {
3822
+ const tools = generateMcpTools(store);
3823
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
3824
+ let caught: any;
3825
+ try {
3826
+ await updateTag.execute({ tag: "widget", fields: { weird: { type: "frobnicator" } } });
3827
+ } catch (e) {
3828
+ caught = e;
3829
+ }
3830
+ expect(caught).toBeDefined();
3831
+ expect(caught.error_type).toBe("tag_field_conflict");
3832
+ expect(caught.violations).toHaveLength(1);
3833
+ expect(caught.violations[0].field).toBe("weird");
3834
+ expect(caught.violations[0].reason).toBe("invalid_type");
3835
+
3836
+ // Nothing persisted.
3837
+ const record = await store.getTagRecord("widget");
3838
+ expect(record?.fields ?? null).toBeFalsy();
3839
+ });
3840
+
3841
+ // vault#555 fix 5 — one call declaring TWO bad fields (an unrecognized
3842
+ // type AND a non-conforming default) reports BOTH violations together,
3843
+ // not just the first one encountered.
3844
+ it("update-tag reports an unrecognized type AND a bad default together in one call", async () => {
3845
+ const tools = generateMcpTools(store);
3846
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
3847
+ let caught: any;
3848
+ try {
3849
+ await updateTag.execute({
3850
+ tag: "widget",
3851
+ fields: {
3852
+ weird: { type: "frobnicator" },
3853
+ bad_default: { type: "string", enum: ["a", "b"], default: "zzz" },
3854
+ },
3855
+ });
3856
+ } catch (e) {
3857
+ caught = e;
3858
+ }
3859
+ expect(caught).toBeDefined();
3860
+ expect(caught.violations).toHaveLength(2);
3861
+ const byField = new Map(caught.violations.map((v: any) => [v.field, v.reason]));
3862
+ expect(byField.get("weird")).toBe("invalid_type");
3863
+ expect(byField.get("bad_default")).toBe("invalid_default");
3864
+ });
3865
+
3866
+ it("update-tag accepts every recognized field type", async () => {
3867
+ const tools = generateMcpTools(store);
3868
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
3869
+ const result = await updateTag.execute({
3870
+ tag: "widget",
3871
+ fields: {
3872
+ a: { type: "string" },
3873
+ b: { type: "number" },
3874
+ c: { type: "integer" },
3875
+ d: { type: "boolean" },
3876
+ e: { type: "array" },
3877
+ f: { type: "object" },
3878
+ },
3879
+ }) as any;
3880
+ expect(result.fields.a.type).toBe("string");
3881
+ expect(result.fields.f.type).toBe("object");
3882
+ });
3883
+
3436
3884
  it("find-path works with ID/path resolution", async () => {
3437
3885
  await store.createNote("A", { id: "a", path: "People/Alice" });
3438
3886
  await store.createNote("B", { id: "b" });
@@ -4343,6 +4791,58 @@ describe("schema validation (tags.fields)", async () => {
4343
4791
  expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
4344
4792
  });
4345
4793
 
4794
+ // vault#555 fix 3 — an enum-membership violation on an INDEXED
4795
+ // (non-strict) field is stored, findable via `eq` (indexed⇒strict is
4796
+ // TYPE-only, not enum-domain — a ratified policy, not a bug), but the
4797
+ // advisory `enum_mismatch` warning used to be visible ONLY on the
4798
+ // one-time create/update response — a caller reading the note back via
4799
+ // query-notes (the natural way to re-check a stored value) saw nothing,
4800
+ // contradicting "advisory violations surface as warnings."
4801
+ it("query-notes surfaces validation_status (enum_mismatch on an indexed field) — single-id fetch", async () => {
4802
+ const tools = generateMcpTools(store);
4803
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4804
+ const create = tools.find((t) => t.name === "create-note")!;
4805
+ const query = tools.find((t) => t.name === "query-notes")!;
4806
+ await updateTag.execute({
4807
+ tag: "widget",
4808
+ fields: { status: { type: "string", enum: ["a", "b"], indexed: true } },
4809
+ });
4810
+ const created = await create.execute({ content: "x", tags: ["widget"], metadata: { status: "bogus" } }) as any;
4811
+
4812
+ // Findable via eq — indexed⇒strict is TYPE-only, enum stays advisory.
4813
+ const found = await query.execute({ metadata: { status: { eq: "bogus" } }, include_content: true }) as any[];
4814
+ expect(found).toHaveLength(1);
4815
+ expect(found[0].validation_status?.warnings?.[0]?.reason).toBe("enum_mismatch");
4816
+
4817
+ // Single-id fetch also carries it.
4818
+ const single = await query.execute({ id: created.id }) as any;
4819
+ expect(single.validation_status.warnings[0].reason).toBe("enum_mismatch");
4820
+ expect(single.validation_status.warnings[0].field).toBe("status");
4821
+ });
4822
+
4823
+ it("query-notes surfaces validation_status on the default list path (lean NoteIndex shape)", async () => {
4824
+ await store.upsertTagSchema("task", {
4825
+ fields: { priority: { type: "string", enum: ["high", "low"] } },
4826
+ });
4827
+ const tools = generateMcpTools(store);
4828
+ const create = tools.find((t) => t.name === "create-note")!;
4829
+ const query = tools.find((t) => t.name === "query-notes")!;
4830
+ await create.execute({ content: "x", tags: ["task"], metadata: { priority: "ULTRA" } });
4831
+
4832
+ // Default list mode (include_content omitted → lean NoteIndex).
4833
+ const list = await query.execute({ tag: "task" }) as any[];
4834
+ expect(list).toHaveLength(1);
4835
+ expect(list[0].validation_status?.warnings?.[0]?.reason).toBe("enum_mismatch");
4836
+ });
4837
+
4838
+ it("query-notes omits validation_status entirely when no tag declares fields (no behavior change)", async () => {
4839
+ await store.createNote("plain note", { tags: ["untagged-schema"] });
4840
+ const tools = generateMcpTools(store);
4841
+ const query = tools.find((t) => t.name === "query-notes")!;
4842
+ const list = await query.execute({ tag: "untagged-schema" }) as any[];
4843
+ expect(list[0].validation_status).toBeUndefined();
4844
+ });
4845
+
4346
4846
  it("validation never blocks the write — note exists with warnings attached", async () => {
4347
4847
  await store.upsertTagSchema("task", {
4348
4848
  fields: { priority: { type: "boolean" } },
@@ -4775,6 +5275,401 @@ describe("update-note if_missing=create (vault#309)", async () => {
4775
5275
  });
4776
5276
  });
4777
5277
 
5278
+ // ---------------------------------------------------------------------------
5279
+ // create-note `if_exists` — idempotent upsert on a path conflict (vault#555)
5280
+ // ---------------------------------------------------------------------------
5281
+
5282
+ describe("create-note if_exists (vault#555)", async () => {
5283
+ let store: SqliteStore;
5284
+ beforeEach(() => {
5285
+ store = new SqliteStore(new Database(":memory:"));
5286
+ });
5287
+
5288
+ it("default (no if_exists) still throws path_conflict — back-compat", async () => {
5289
+ const tools = generateMcpTools(store);
5290
+ const create = tools.find((t) => t.name === "create-note")!;
5291
+ await create.execute({ content: "first", path: "Inbox/dup" });
5292
+ await expect(create.execute({ content: "second", path: "Inbox/dup" }))
5293
+ .rejects.toThrow(/path_conflict/);
5294
+ });
5295
+
5296
+ it('if_exists:"error" behaves identically to omitting it', async () => {
5297
+ const tools = generateMcpTools(store);
5298
+ const create = tools.find((t) => t.name === "create-note")!;
5299
+ await create.execute({ content: "first", path: "Inbox/dup-explicit" });
5300
+ await expect(create.execute({ content: "second", path: "Inbox/dup-explicit", if_exists: "error" }))
5301
+ .rejects.toThrow(/path_conflict/);
5302
+ });
5303
+
5304
+ it('if_exists:"ignore" returns the existing note untouched, no mutation', async () => {
5305
+ const tools = generateMcpTools(store);
5306
+ const create = tools.find((t) => t.name === "create-note")!;
5307
+ const first = await create.execute({
5308
+ content: "original",
5309
+ path: "Inbox/ignore-me",
5310
+ tags: ["daily"],
5311
+ metadata: { v: 1 },
5312
+ }) as any;
5313
+
5314
+ const second = await create.execute({
5315
+ content: "attempted overwrite",
5316
+ path: "Inbox/ignore-me",
5317
+ tags: ["other"],
5318
+ metadata: { v: 2 },
5319
+ if_exists: "ignore",
5320
+ }) as any;
5321
+
5322
+ expect(second.existed).toBe(true);
5323
+ expect(second.id).toBe(first.id);
5324
+ expect(second.content).toBe("original"); // untouched
5325
+ expect(second.metadata?.v).toBe(1); // untouched
5326
+ expect(second.tags).toEqual(["daily"]); // "other" was never applied
5327
+
5328
+ // Confirm on disk too — no side effects at all.
5329
+ const onDisk = await store.getNoteByPath("Inbox/ignore-me");
5330
+ expect(onDisk!.content).toBe("original");
5331
+ expect(onDisk!.tags).toEqual(["daily"]);
5332
+ });
5333
+
5334
+ it('if_exists:"ignore" does not run schema-default backfill (genuinely zero mutation)', async () => {
5335
+ const tools = generateMcpTools(store);
5336
+ const create = tools.find((t) => t.name === "create-note")!;
5337
+ // Create the note BEFORE the schema declares a default, so the
5338
+ // existing row is missing the field the schema would now backfill.
5339
+ const first = await create.execute({ content: "x", path: "Inbox/predates-schema", tags: ["task"] }) as any;
5340
+ expect(first.metadata?.priority).toBeUndefined();
5341
+
5342
+ await store.upsertTagSchema("task", {
5343
+ fields: { priority: { type: "string", enum: ["high", "low"], default: "high" } },
5344
+ });
5345
+
5346
+ const second = await create.execute({
5347
+ content: "y",
5348
+ path: "Inbox/predates-schema",
5349
+ tags: ["task"],
5350
+ if_exists: "ignore",
5351
+ }) as any;
5352
+ expect(second.existed).toBe(true);
5353
+ expect(second.metadata?.priority).toBeUndefined(); // no backfill — zero mutation promise
5354
+ });
5355
+
5356
+ it('if_exists:"update" full-replaces content, RFC-7386-merges metadata, and unions tags/links', async () => {
5357
+ await store.createNote("target body", { id: "t-upd-target", path: "Targets/upd" });
5358
+
5359
+ const tools = generateMcpTools(store);
5360
+ const create = tools.find((t) => t.name === "create-note")!;
5361
+ const first = await create.execute({
5362
+ content: "v1",
5363
+ path: "Inbox/update-me",
5364
+ tags: ["daily"],
5365
+ metadata: { a: 1, b: 2 },
5366
+ }) as any;
5367
+
5368
+ const second = await create.execute({
5369
+ content: "v2",
5370
+ path: "Inbox/update-me",
5371
+ tags: ["extra"],
5372
+ metadata: { b: null, c: 3 }, // b deleted (RFC 7386), c added, a untouched
5373
+ links: [{ target: "t-upd-target", relationship: "relates-to" }],
5374
+ if_exists: "update",
5375
+ }) as any;
5376
+
5377
+ expect(second.existed).toBe(true);
5378
+ expect(second.id).toBe(first.id);
5379
+ expect(second.content).toBe("v2"); // full replace
5380
+ expect(second.metadata).toEqual({ a: 1, c: 3 }); // merged, b deleted
5381
+ expect(second.tags?.sort()).toEqual(["daily", "extra"]); // union, nothing removed
5382
+
5383
+ const links = await store.getLinks(second.id, { direction: "outbound" });
5384
+ expect(links.find((l) => l.relationship === "relates-to")?.targetId).toBe("t-upd-target");
5385
+ });
5386
+
5387
+ it('if_exists:"update" with an omitted field leaves it untouched', async () => {
5388
+ const tools = generateMcpTools(store);
5389
+ const create = tools.find((t) => t.name === "create-note")!;
5390
+ await create.execute({ content: "v1", path: "Inbox/partial-update", metadata: { keep: "me" } });
5391
+
5392
+ const second = await create.execute({
5393
+ path: "Inbox/partial-update", // no `content` — should stay "v1"
5394
+ metadata: { added: true },
5395
+ if_exists: "update",
5396
+ }) as any;
5397
+ expect(second.content).toBe("v1");
5398
+ expect(second.metadata).toEqual({ keep: "me", added: true });
5399
+ });
5400
+
5401
+ it('if_exists:"replace" wholesale-overwrites content + metadata but keeps id/createdAt and unions tags', async () => {
5402
+ const tools = generateMcpTools(store);
5403
+ const create = tools.find((t) => t.name === "create-note")!;
5404
+ const first = await create.execute({
5405
+ content: "v1",
5406
+ path: "Inbox/replace-me",
5407
+ tags: ["daily"],
5408
+ metadata: { a: 1, b: 2 },
5409
+ }) as any;
5410
+
5411
+ const second = await create.execute({
5412
+ content: "v2",
5413
+ path: "Inbox/replace-me",
5414
+ tags: ["extra"],
5415
+ metadata: { c: 3 }, // a and b are NOT merged in — wholesale replace
5416
+ if_exists: "replace",
5417
+ }) as any;
5418
+
5419
+ expect(second.existed).toBe(true);
5420
+ expect(second.id).toBe(first.id);
5421
+ expect(second.createdAt).toBe(first.createdAt);
5422
+ expect(second.content).toBe("v2");
5423
+ expect(second.metadata).toEqual({ c: 3 }); // a/b dropped, not merged
5424
+ expect(second.tags?.sort()).toEqual(["daily", "extra"]); // tags stay additive
5425
+ });
5426
+
5427
+ it('if_exists:"replace" with omitted content/metadata clears them to empty defaults', async () => {
5428
+ const tools = generateMcpTools(store);
5429
+ const create = tools.find((t) => t.name === "create-note")!;
5430
+ await create.execute({ content: "v1", path: "Inbox/replace-blank", metadata: { a: 1 } });
5431
+
5432
+ const second = await create.execute({
5433
+ path: "Inbox/replace-blank",
5434
+ if_exists: "replace",
5435
+ }) as any;
5436
+ expect(second.content).toBe("");
5437
+ // An empty `{}` metadata collapses to `undefined` on read — the same
5438
+ // convention every note with no metadata follows (rowToNote), not
5439
+ // something special about `replace`.
5440
+ expect(second.metadata).toBeUndefined();
5441
+ });
5442
+
5443
+ it("if_exists is a no-op without a path — a pathless create can never conflict", async () => {
5444
+ const tools = generateMcpTools(store);
5445
+ const create = tools.find((t) => t.name === "create-note")!;
5446
+ const a = await create.execute({ content: "one", if_exists: "ignore" }) as any;
5447
+ const b = await create.execute({ content: "two", if_exists: "ignore" }) as any;
5448
+ expect(a.id).not.toBe(b.id);
5449
+ // `if_exists` was still passed, so `existed` is attached (there was
5450
+ // simply nothing to conflict with) — `false`, not absent.
5451
+ expect(a.existed).toBe(false);
5452
+ expect(b.existed).toBe(false);
5453
+ });
5454
+
5455
+ it("plain create-note calls (no if_exists) see zero response-shape change", async () => {
5456
+ const tools = generateMcpTools(store);
5457
+ const create = tools.find((t) => t.name === "create-note")!;
5458
+ const result = await create.execute({ content: "plain" }) as any;
5459
+ expect("existed" in result).toBe(false);
5460
+ });
5461
+
5462
+ it("existed:false when if_exists is set but no conflict occurs (fresh insert)", async () => {
5463
+ const tools = generateMcpTools(store);
5464
+ const create = tools.find((t) => t.name === "create-note")!;
5465
+ const result = await create.execute({ content: "fresh", path: "Inbox/fresh-one", if_exists: "update" }) as any;
5466
+ expect(result.existed).toBe(false);
5467
+ });
5468
+
5469
+ it("respects a per-item extension when disambiguating the conflict (vault#328)", async () => {
5470
+ const tools = generateMcpTools(store);
5471
+ const create = tools.find((t) => t.name === "create-note")!;
5472
+ // Two notes at the same path, different extensions — legal per vault#328.
5473
+ await create.execute({ content: "csv body", path: "Reports/q1", extension: "csv" });
5474
+ await create.execute({ content: "md body", path: "Reports/q1", extension: "md" });
5475
+
5476
+ // ignore against the CSV one specifically.
5477
+ const result = await create.execute({
5478
+ content: "attempted",
5479
+ path: "Reports/q1",
5480
+ extension: "csv",
5481
+ if_exists: "ignore",
5482
+ }) as any;
5483
+ expect(result.existed).toBe(true);
5484
+ expect(result.content).toBe("csv body");
5485
+ expect(result.extension).toBe("csv");
5486
+ });
5487
+
5488
+ it("batch: if_exists is per-item, not inherited from a top-level default", async () => {
5489
+ const tools = generateMcpTools(store);
5490
+ const create = tools.find((t) => t.name === "create-note")!;
5491
+ await create.execute({ content: "existing", path: "Inbox/batch-conflict" });
5492
+
5493
+ // Top-level if_exists is NOT merged into batch items (matches
5494
+ // if_missing's contract on update-note) — the batch item below has no
5495
+ // if_exists of its own, so it hits the default "error" path and the
5496
+ // WHOLE batch rolls back, even though a top-level if_exists was passed.
5497
+ await expect(create.execute({
5498
+ if_exists: "ignore",
5499
+ notes: [{ content: "conflict", path: "Inbox/batch-conflict" }],
5500
+ })).rejects.toThrow(/path_conflict/);
5501
+ });
5502
+
5503
+ it("batch: mixes fresh creates and if_exists hits in the same call, in order", async () => {
5504
+ const tools = generateMcpTools(store);
5505
+ const create = tools.find((t) => t.name === "create-note")!;
5506
+ await create.execute({ content: "pre-existing", path: "Inbox/batch-b" });
5507
+
5508
+ const result = await create.execute({
5509
+ notes: [
5510
+ { content: "a", path: "Inbox/batch-a", if_exists: "ignore" },
5511
+ { content: "b-new", path: "Inbox/batch-b", if_exists: "ignore" },
5512
+ { content: "c", path: "Inbox/batch-c", if_exists: "ignore" },
5513
+ ],
5514
+ }) as any[];
5515
+
5516
+ expect(result).toHaveLength(3);
5517
+ expect(result[0].existed).toBe(false);
5518
+ expect(result[1].existed).toBe(true);
5519
+ expect(result[1].content).toBe("pre-existing"); // untouched
5520
+ expect(result[2].existed).toBe(false);
5521
+ });
5522
+
5523
+ it("a genuine schema violation on if_exists:update still rejects and mutates nothing", async () => {
5524
+ await store.upsertTagSchema("task", {
5525
+ fields: { status: { type: "string", enum: ["open", "done"], strict: true, required: true } },
5526
+ });
5527
+ const tools = generateMcpTools(store);
5528
+ const create = tools.find((t) => t.name === "create-note")!;
5529
+ await create.execute({ content: "v1", path: "Inbox/strict-target", tags: ["task"], metadata: { status: "open" } });
5530
+
5531
+ await expect(create.execute({
5532
+ content: "v2",
5533
+ path: "Inbox/strict-target",
5534
+ metadata: { status: "NOT-A-VALID-ENUM" },
5535
+ if_exists: "update",
5536
+ })).rejects.toThrow();
5537
+
5538
+ const onDisk = await store.getNoteByPath("Inbox/strict-target");
5539
+ expect(onDisk!.content).toBe("v1"); // untouched — rejected before any write
5540
+ });
5541
+
5542
+ it("if_exists:update re-validates against the CURRENT schema without re-supplying satisfied required fields", async () => {
5543
+ await store.upsertTagSchema("task", {
5544
+ fields: { status: { type: "string", enum: ["open", "done"], strict: true, required: true } },
5545
+ });
5546
+ const tools = generateMcpTools(store);
5547
+ const create = tools.find((t) => t.name === "create-note")!;
5548
+ await create.execute({ content: "v1", path: "Inbox/status-set", tags: ["task"], metadata: { status: "open" } });
5549
+
5550
+ // Only touching content — `status` is already satisfied by the
5551
+ // EXISTING note, so re-validating the merged/projected shape (not the
5552
+ // raw incoming item) must NOT demand it again.
5553
+ const result = await create.execute({
5554
+ content: "v2",
5555
+ path: "Inbox/status-set",
5556
+ if_exists: "update",
5557
+ }) as any;
5558
+ expect(result.content).toBe("v2");
5559
+ expect(result.metadata?.status).toBe("open");
5560
+ });
5561
+
5562
+ // vault#555 CRITICAL 2 (W8 fix-2 bug class): a tags-ONLY if_exists:"update"
5563
+ // (no content/metadata) must still bump updated_at — store.tagNote genuinely
5564
+ // mutates the note, and a frozen updated_at breaks cursor polling + sync.
5565
+ it('if_exists:"update" with ONLY tags added still advances updated_at', async () => {
5566
+ const tools = generateMcpTools(store);
5567
+ const create = tools.find((t) => t.name === "create-note")!;
5568
+ const first = await create.execute({ content: "body", path: "Inbox/tagonly", tags: ["alpha"] }) as any;
5569
+ const before = (await store.getNoteByPath("Inbox/tagonly"))!.updatedAt!;
5570
+
5571
+ // Ensure a strictly-later wall-clock so the bump is observable even if the
5572
+ // two writes land in the same millisecond.
5573
+ await new Promise((r) => setTimeout(r, 5));
5574
+
5575
+ const second = await create.execute({
5576
+ path: "Inbox/tagonly",
5577
+ tags: ["beta"], // ONLY a tag change — no content, no metadata
5578
+ if_exists: "update",
5579
+ }) as any;
5580
+ expect(second.existed).toBe(true);
5581
+ expect(second.tags?.sort()).toEqual(["alpha", "beta"]); // tag actually added
5582
+ const after = (await store.getNoteByPath("Inbox/tagonly"))!.updatedAt!;
5583
+ expect(after > before).toBe(true); // updated_at advanced
5584
+ expect(second.id).toBe(first.id);
5585
+ });
5586
+
5587
+ it('if_exists:"update" with ONLY links added still advances updated_at', async () => {
5588
+ await store.createNote("target", { id: "tgt-linkonly", path: "Targets/linkonly" });
5589
+ const tools = generateMcpTools(store);
5590
+ const create = tools.find((t) => t.name === "create-note")!;
5591
+ await create.execute({ content: "body", path: "Inbox/linkonly", tags: ["alpha"] });
5592
+ const before = (await store.getNoteByPath("Inbox/linkonly"))!.updatedAt!;
5593
+ await new Promise((r) => setTimeout(r, 5));
5594
+
5595
+ await create.execute({
5596
+ path: "Inbox/linkonly",
5597
+ links: [{ target: "tgt-linkonly", relationship: "relates-to" }],
5598
+ if_exists: "update",
5599
+ });
5600
+ const after = (await store.getNoteByPath("Inbox/linkonly"))!.updatedAt!;
5601
+ expect(after > before).toBe(true);
5602
+ });
5603
+ });
5604
+
5605
+ // ---------------------------------------------------------------------------
5606
+ // create-note `summary` — compact batch response shape (vault#555)
5607
+ // ---------------------------------------------------------------------------
5608
+
5609
+ describe("create-note summary (vault#555)", async () => {
5610
+ let store: SqliteStore;
5611
+ beforeEach(() => {
5612
+ store = new SqliteStore(new Database(":memory:"));
5613
+ });
5614
+
5615
+ it("summary:true returns {created, ids, failed} instead of N full note objects", async () => {
5616
+ const tools = generateMcpTools(store);
5617
+ const create = tools.find((t) => t.name === "create-note")!;
5618
+ const result = await create.execute({
5619
+ summary: true,
5620
+ notes: [
5621
+ { content: "a", path: "Inbox/sum-a" },
5622
+ { content: "b", path: "Inbox/sum-b" },
5623
+ { content: "c", path: "Inbox/sum-c" },
5624
+ ],
5625
+ }) as any;
5626
+ expect(result.created).toBe(3);
5627
+ expect(result.ids).toHaveLength(3);
5628
+ expect(result.failed).toEqual([]);
5629
+ // Not the full-object shape.
5630
+ expect(result.notes).toBeUndefined();
5631
+ expect(Array.isArray(result)).toBe(false);
5632
+
5633
+ // The ids really do resolve to the notes just created.
5634
+ const onDisk = await store.getNoteByPath("Inbox/sum-a");
5635
+ expect(result.ids).toContain(onDisk!.id);
5636
+ });
5637
+
5638
+ it("summary:true `created` excludes if_exists hits — only counts brand-new inserts", async () => {
5639
+ const tools = generateMcpTools(store);
5640
+ const create = tools.find((t) => t.name === "create-note")!;
5641
+ await create.execute({ content: "pre-existing", path: "Inbox/sum-existing" });
5642
+
5643
+ const result = await create.execute({
5644
+ summary: true,
5645
+ notes: [
5646
+ { content: "fresh", path: "Inbox/sum-fresh", if_exists: "ignore" },
5647
+ { content: "ignored", path: "Inbox/sum-existing", if_exists: "ignore" },
5648
+ ],
5649
+ }) as any;
5650
+ expect(result.created).toBe(1); // only the fresh one
5651
+ expect(result.ids).toHaveLength(2); // both ids are still reported
5652
+ });
5653
+
5654
+ it("summary is ignored on a single-note (non-batch) call", async () => {
5655
+ const tools = generateMcpTools(store);
5656
+ const create = tools.find((t) => t.name === "create-note")!;
5657
+ const result = await create.execute({ content: "solo", summary: true }) as any;
5658
+ expect(result.content).toBe("solo"); // full note object, not a summary
5659
+ expect(result.created).toBeUndefined();
5660
+ });
5661
+
5662
+ it("without summary, batch still returns full note objects (back-compat)", async () => {
5663
+ const tools = generateMcpTools(store);
5664
+ const create = tools.find((t) => t.name === "create-note")!;
5665
+ const result = await create.execute({
5666
+ notes: [{ content: "a", path: "Inbox/sum-nosummary" }],
5667
+ }) as any[];
5668
+ expect(Array.isArray(result)).toBe(true);
5669
+ expect(result[0].content).toBe("a");
5670
+ });
5671
+ });
5672
+
4778
5673
  // ---------------------------------------------------------------------------
4779
5674
  // Schema inheritance via parent_names + `_default` universal parent — vault#270
4780
5675
  // ---------------------------------------------------------------------------