@openparachute/vault 0.7.0-rc.8 → 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.
@@ -3376,6 +3376,93 @@ describe("MCP tools", async () => {
3376
3376
  expect(result.map((n) => n.content)).toEqual(["Orphan"]);
3377
3377
  });
3378
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
+
3379
3466
  it("query-notes metadata operator query routes through the indexed column", async () => {
3380
3467
  const { declareField } = await import("./indexed-fields.js");
3381
3468
  declareField(db, "priority", "INTEGER", "project");
@@ -5188,6 +5275,401 @@ describe("update-note if_missing=create (vault#309)", async () => {
5188
5275
  });
5189
5276
  });
5190
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
+
5191
5673
  // ---------------------------------------------------------------------------
5192
5674
  // Schema inheritance via parent_names + `_default` universal parent — vault#270
5193
5675
  // ---------------------------------------------------------------------------
@@ -173,6 +173,7 @@ export interface QueryHashInputs {
173
173
  excludeTags?: string[];
174
174
  hasTags?: boolean;
175
175
  hasLinks?: boolean;
176
+ hasBrokenLinks?: boolean;
176
177
  path?: string;
177
178
  pathPrefix?: string;
178
179
  extension?: string | string[];