@openparachute/vault 0.3.3 → 0.4.3

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.
Files changed (80) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +133 -0
  3. package/core/src/core.test.ts +2990 -92
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +413 -68
  6. package/core/src/notes.ts +693 -42
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +331 -0
  11. package/core/src/schema.ts +467 -11
  12. package/core/src/store.ts +262 -8
  13. package/core/src/tag-hierarchy.ts +171 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +96 -7
  16. package/core/src/vault-projection.ts +309 -0
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +360 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +173 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +322 -57
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +307 -0
  30. package/src/hub-jwt.ts +88 -0
  31. package/src/init.test.ts +216 -0
  32. package/src/mcp-http.ts +33 -29
  33. package/src/mcp-install.ts +1 -1
  34. package/src/mcp-tools.ts +318 -19
  35. package/src/module-config.ts +1 -1
  36. package/src/oauth.test.ts +345 -0
  37. package/src/oauth.ts +85 -14
  38. package/src/owner-auth.ts +57 -1
  39. package/src/prompt.ts +6 -5
  40. package/src/routes.ts +796 -61
  41. package/src/routing.test.ts +466 -1
  42. package/src/routing.ts +106 -24
  43. package/src/scopes.test.ts +66 -8
  44. package/src/scopes.ts +163 -37
  45. package/src/server.ts +24 -2
  46. package/src/services-manifest.test.ts +20 -0
  47. package/src/services-manifest.ts +9 -2
  48. package/src/stop-signal.test.ts +85 -0
  49. package/src/storage.test.ts +92 -0
  50. package/src/tag-scope.ts +118 -0
  51. package/src/token-store.test.ts +47 -0
  52. package/src/token-store.ts +128 -13
  53. package/src/tokens-routes.test.ts +727 -0
  54. package/src/tokens-routes.ts +392 -0
  55. package/src/transcription-worker.test.ts +5 -0
  56. package/src/triggers.ts +1 -1
  57. package/src/two-factor.ts +2 -2
  58. package/src/vault-create.test.ts +193 -0
  59. package/src/vault-name.test.ts +123 -0
  60. package/src/vault-name.ts +80 -0
  61. package/src/vault.test.ts +1626 -183
  62. package/tsconfig.json +8 -1
  63. package/.claude/settings.local.json +0 -8
  64. package/.dockerignore +0 -8
  65. package/.env.example +0 -9
  66. package/CHANGELOG.md +0 -175
  67. package/CLAUDE.md +0 -125
  68. package/Caddyfile +0 -3
  69. package/Dockerfile +0 -22
  70. package/bun.lock +0 -219
  71. package/bunfig.toml +0 -2
  72. package/deploy/parachute-vault.service +0 -20
  73. package/docker-compose.yml +0 -50
  74. package/docs/HTTP_API.md +0 -434
  75. package/docs/auth-model.md +0 -340
  76. package/fly.toml +0 -24
  77. package/package/package.json +0 -32
  78. package/railway.json +0 -14
  79. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  80. package/scripts/migrate-audio-to-opus.ts +0 -499
@@ -136,6 +136,117 @@ describe("notes", async () => {
136
136
  await store.deleteNote("a");
137
137
  expect(await store.getLinks("b")).toHaveLength(0);
138
138
  });
139
+
140
+ // ---- PathConflictError: typed 409 on duplicate path (#126) ----
141
+
142
+ it("createNote throws PathConflictError when path is taken (#126)", async () => {
143
+ await store.createNote("First", { path: "Inbox/note" });
144
+ let caught: any;
145
+ try {
146
+ await store.createNote("Second", { path: "Inbox/note" });
147
+ } catch (e) {
148
+ caught = e;
149
+ }
150
+ expect(caught).toBeTruthy();
151
+ expect(caught.code).toBe("PATH_CONFLICT");
152
+ expect(caught.path).toBe("Inbox/note");
153
+ });
154
+
155
+ it("createNote on path collision does not insert the second note (#126)", async () => {
156
+ await store.createNote("First", { id: "a", path: "Inbox/note" });
157
+ try {
158
+ await store.createNote("Second", { id: "b", path: "Inbox/note" });
159
+ } catch {}
160
+ expect(await store.getNote("b")).toBeNull();
161
+ });
162
+
163
+ it("updateNote throws PathConflictError when renaming onto an existing path (#126)", async () => {
164
+ const a = await store.createNote("First", { path: "a" });
165
+ await store.createNote("Second", { path: "b" });
166
+ let caught: any;
167
+ try {
168
+ await store.updateNote(a.id, { path: "b", if_updated_at: a.createdAt });
169
+ } catch (e) {
170
+ caught = e;
171
+ }
172
+ expect(caught).toBeTruthy();
173
+ expect(caught.code).toBe("PATH_CONFLICT");
174
+ expect(caught.path).toBe("b");
175
+ });
176
+
177
+ it("updateNote with no path collision still succeeds (#126 — no false positives)", async () => {
178
+ const a = await store.createNote("First", { path: "a" });
179
+ await store.createNote("Second", { path: "b" });
180
+ const updated = await store.updateNote(a.id, { path: "c", if_updated_at: a.createdAt });
181
+ expect(updated.path).toBe("c");
182
+ });
183
+
184
+ it("updateNote with no path field is unaffected by the path-conflict guard (#126)", async () => {
185
+ const a = await store.createNote("First", { path: "a" });
186
+ const updated = await store.updateNote(a.id, { content: "edited", if_updated_at: a.createdAt });
187
+ expect(updated.content).toBe("edited");
188
+ expect(updated.path).toBe("a");
189
+ });
190
+
191
+ // -------------------------------------------------------------------------
192
+ // Empty-note invariant at the Store boundary (#213)
193
+ // -------------------------------------------------------------------------
194
+
195
+ it("createNote rejects content+path both absent → EmptyNoteError", async () => {
196
+ await expect(store.createNote("")).rejects.toMatchObject({ code: "EMPTY_NOTE" });
197
+ await expect(store.createNote(" ")).rejects.toMatchObject({ code: "EMPTY_NOTE" });
198
+ await expect(store.createNote("", { metadata: { x: 1 } })).rejects.toMatchObject({
199
+ code: "EMPTY_NOTE",
200
+ });
201
+ });
202
+
203
+ it("createNote accepts content-only (un-pathed jot)", async () => {
204
+ const n = await store.createNote("just a jot");
205
+ expect(n.content).toBe("just a jot");
206
+ expect(n.path).toBeUndefined();
207
+ });
208
+
209
+ it("createNote accepts path-only (wikilink placeholder / _schemas/* shape)", async () => {
210
+ const n = await store.createNote("", { path: "wiki/placeholder" });
211
+ expect(n.content).toBe("");
212
+ expect(n.path).toBe("wiki/placeholder");
213
+ });
214
+
215
+ it("updateNote rejects clearing both content and path → EmptyNoteError", async () => {
216
+ const n = await store.createNote("body", { path: "p" });
217
+ await expect(
218
+ store.updateNote(n.id, { content: "", path: "", if_updated_at: n.createdAt }),
219
+ ).rejects.toMatchObject({ code: "EMPTY_NOTE", note_id: n.id });
220
+ });
221
+
222
+ it("updateNote rejects clearing content when path is already null", async () => {
223
+ const n = await store.createNote("body");
224
+ await expect(
225
+ store.updateNote(n.id, { content: "", if_updated_at: n.createdAt }),
226
+ ).rejects.toMatchObject({ code: "EMPTY_NOTE" });
227
+ });
228
+
229
+ it("updateNote allows clearing content when path is set (or being set)", async () => {
230
+ const n = await store.createNote("body", { path: "p" });
231
+ const updated = await store.updateNote(n.id, { content: "", if_updated_at: n.createdAt });
232
+ expect(updated.content).toBe("");
233
+ expect(updated.path).toBe("p");
234
+ });
235
+
236
+ it("updateNote with metadata-only update against a (legacy) empty row passes", async () => {
237
+ // Tag/metadata-only updates don't touch content or path, so they don't
238
+ // trigger the new guard — important so any pre-existing empty rows
239
+ // (from before #213) can still be cleaned up via metadata operations.
240
+ const n = await store.createNote("seed", { path: "x" });
241
+ // Simulate a legacy row by directly clearing content via SQL (bypasses
242
+ // the guard); this mirrors what an old data row could look like.
243
+ db.prepare("UPDATE notes SET content = '', path = NULL WHERE id = ?").run(n.id);
244
+ const updated = await store.updateNote(n.id, {
245
+ metadata: { tag: "cleanup" },
246
+ if_updated_at: n.createdAt,
247
+ });
248
+ expect(updated.metadata).toMatchObject({ tag: "cleanup" });
249
+ });
139
250
  });
140
251
 
141
252
  // ---- Backfill migration: legacy rows with NULL updated_at ----
@@ -246,7 +357,7 @@ describe("renameTag", async () => {
246
357
  const n2 = await store.createNote("B", { tags: ["voice", "keeper"] });
247
358
 
248
359
  const result = await store.renameTag("voice", "memo");
249
- expect(result).toEqual({ renamed: 2 });
360
+ expect(result).toMatchObject({ renamed: 2, sub_tags_renamed: 0 });
250
361
 
251
362
  expect((await store.getNote(n1.id))!.tags).toEqual(["memo"]);
252
363
  expect((await store.getNote(n2.id))!.tags?.sort()).toEqual(["keeper", "memo"]);
@@ -275,7 +386,7 @@ describe("renameTag", async () => {
275
386
  await store.untagNote((await store.queryNotes({}))[0].id, ["doomed"]);
276
387
 
277
388
  const result = await store.renameTag("doomed", "archived");
278
- expect(result).toEqual({ renamed: 0 });
389
+ expect(result).toMatchObject({ renamed: 0, sub_tags_renamed: 0 });
279
390
  const tags = await store.listTags();
280
391
  expect(tags.some((t) => t.name === "doomed")).toBe(false);
281
392
  expect(tags.some((t) => t.name === "archived")).toBe(true);
@@ -286,7 +397,7 @@ describe("renameTag", async () => {
286
397
  await store.createNote("B", { tags: ["new"] });
287
398
 
288
399
  const result = await store.renameTag("old", "new");
289
- expect(result).toEqual({ error: "target_exists" });
400
+ expect(result).toMatchObject({ error: "target_exists", conflicting: ["new"] });
290
401
 
291
402
  // No bleed — both tags still present with their original counts.
292
403
  const tags = await store.listTags();
@@ -302,11 +413,325 @@ describe("renameTag", async () => {
302
413
  it("same-name rename is a no-op on an existing tag", async () => {
303
414
  await store.createNote("A", { tags: ["voice"] });
304
415
  const result = await store.renameTag("voice", "voice");
305
- expect(result).toEqual({ renamed: 0 });
416
+ expect(result).toMatchObject({ renamed: 0, sub_tags_renamed: 0 });
306
417
  expect((await store.listTags()).find((t) => t.name === "voice")!.count).toBe(1);
307
418
  });
308
419
  });
309
420
 
421
+ // ---- Tag rename cascade (vault#240 + #247) ----
422
+
423
+ describe("renameTag cascade (vault#240 + #247)", async () => {
424
+ it("1. rewrites note bodies with #tag references", async () => {
425
+ const note = await store.createNote(
426
+ "Today's #task is important. Also see #task/work and the #other tag.",
427
+ { tags: ["task"] },
428
+ );
429
+
430
+ const result = await store.renameTag("task", "todo");
431
+ expect(result).toMatchObject({ renamed: 1, notes_rewritten: 1 });
432
+
433
+ const fresh = await store.getNote(note.id);
434
+ expect(fresh!.content).toContain("#todo");
435
+ expect(fresh!.content).not.toContain("#task ");
436
+ expect(fresh!.content).toContain("#other"); // untouched
437
+ });
438
+
439
+ it("2. cascades sub-tags recursively (task → todo, task/work → todo/work, task/work/client → todo/work/client)", async () => {
440
+ await store.createNote("a", { tags: ["task"] });
441
+ await store.createNote("b", { tags: ["task/work"] });
442
+ await store.createNote("c", { tags: ["task/work/client"] });
443
+
444
+ const result = await store.renameTag("task", "todo");
445
+ expect(result).toMatchObject({ renamed: 3, sub_tags_renamed: 2 });
446
+
447
+ const tags = (await store.listTags()).map((t) => t.name).sort();
448
+ expect(tags).toContain("todo");
449
+ expect(tags).toContain("todo/work");
450
+ expect(tags).toContain("todo/work/client");
451
+ expect(tags.some((t) => t.startsWith("task"))).toBe(false);
452
+ });
453
+
454
+ it("3. rewrites parent_names refs in OTHER tag rows (closes #247)", async () => {
455
+ await store.upsertTagRecord("task", { description: "tasks" });
456
+ await store.upsertTagRecord("voice", {
457
+ parent_names: ["manual", "task"],
458
+ });
459
+
460
+ const result = await store.renameTag("task", "todo");
461
+ expect(result).toMatchObject({ renamed: 0, parent_refs_updated: 1 });
462
+
463
+ const voice = await store.getTagRecord("voice");
464
+ expect(voice?.parent_names).toEqual(["manual", "todo"]);
465
+ });
466
+
467
+ it("4. rewrites tokens.scoped_tags JSON arrays", async () => {
468
+ await store.upsertTagRecord("task", {});
469
+ await store.upsertTagRecord("project", {});
470
+
471
+ const now = new Date().toISOString();
472
+ db.prepare(
473
+ `INSERT INTO tokens (token_hash, label, permission, scopes, scoped_tags, created_at)
474
+ VALUES (?, ?, ?, ?, ?, ?)`,
475
+ ).run("h_one", "tok-1", "full", "vault:read", JSON.stringify(["task"]), now);
476
+ db.prepare(
477
+ `INSERT INTO tokens (token_hash, label, permission, scopes, scoped_tags, created_at)
478
+ VALUES (?, ?, ?, ?, ?, ?)`,
479
+ ).run("h_two", "tok-2", "full", "vault:read", JSON.stringify(["project"]), now);
480
+
481
+ const result = await store.renameTag("task", "todo");
482
+ expect(result).toMatchObject({ tokens_updated: 1 });
483
+
484
+ const refreshed = db
485
+ .prepare("SELECT token_hash, scoped_tags FROM tokens ORDER BY token_hash")
486
+ .all() as { token_hash: string; scoped_tags: string }[];
487
+ expect(JSON.parse(refreshed[0]!.scoped_tags)).toEqual(["todo"]);
488
+ // Untouched.
489
+ expect(JSON.parse(refreshed[1]!.scoped_tags)).toEqual(["project"]);
490
+ });
491
+
492
+ it("5. rewrites #tag and [[_tags/...]] references in note bodies (incl. sub-tags)", async () => {
493
+ await store.upsertTagRecord("task/work", {});
494
+ await store.upsertTagRecord("task", {});
495
+ const a = await store.createNote(
496
+ "#task is important #task/work and a wikilink: [[_tags/task]]",
497
+ { tags: ["task"] },
498
+ );
499
+
500
+ const result = await store.renameTag("task", "todo");
501
+ expect(result.renamed).toBeGreaterThan(0);
502
+ if ("notes_rewritten" in result) expect(result.notes_rewritten).toBe(1);
503
+
504
+ const fresh = await store.getNote(a.id);
505
+ expect(fresh!.content).toContain("#todo is important");
506
+ expect(fresh!.content).toContain("#todo/work");
507
+ expect(fresh!.content).toContain("[[_tags/todo]]");
508
+ expect(fresh!.content).not.toContain("#task ");
509
+ expect(fresh!.content).not.toContain("[[_tags/task]");
510
+ });
511
+
512
+ it("6. pre-flight collision aborts without mutation when target exists", async () => {
513
+ await store.createNote("t", { tags: ["task"] });
514
+ await store.createNote("p", { tags: ["project"] });
515
+
516
+ const result = await store.renameTag("task", "project");
517
+ expect(result).toMatchObject({ error: "target_exists", conflicting: ["project"] });
518
+
519
+ // Both tags still present, untouched counts.
520
+ const tags = await store.listTags();
521
+ expect(tags.find((t) => t.name === "task")?.count).toBe(1);
522
+ expect(tags.find((t) => t.name === "project")?.count).toBe(1);
523
+ });
524
+
525
+ it("7. transactional rollback leaves the original state intact on mid-cascade failure", async () => {
526
+ await store.createNote("a", { tags: ["task"] });
527
+ await store.createNote("b", { tags: ["task/work"] });
528
+
529
+ // Inject failure: drop the tags table mid-transaction by intercepting
530
+ // the JSON cascade pass. Easiest reliable hook: corrupt the
531
+ // tokens.scoped_tags column with a value that will fail the JSON
532
+ // cascade's UPDATE (use a token_hash that violates a constraint when
533
+ // the cascade rewrites it). Simpler: spy on noteOps via monkey-patch.
534
+ //
535
+ // We use the simplest reliable approach: a row lock conflict. Two
536
+ // statements writing the same row in a deferred transaction would
537
+ // require two connections; instead we drop a required table at the
538
+ // SQL layer to force a SQL error on a downstream UPDATE inside the
539
+ // cascade. Restore after the test.
540
+ const originalDeleteTag = (db as any).prepare;
541
+ let dropOnce = false;
542
+ (db as any).prepare = function (sql: string) {
543
+ // Force the tag-row pass to fail on the DELETE step by dropping
544
+ // the tags table out from under it after the first INSERT.
545
+ if (!dropOnce && sql.startsWith("DELETE FROM tags WHERE name = ?")) {
546
+ dropOnce = true;
547
+ const stmt = originalDeleteTag.call(this, sql);
548
+ const wrapped = {
549
+ run: (...args: any[]) => {
550
+ (db as any).prepare = originalDeleteTag;
551
+ throw new Error("synthetic mid-cascade failure");
552
+ },
553
+ };
554
+ return wrapped;
555
+ }
556
+ return originalDeleteTag.call(this, sql);
557
+ };
558
+
559
+ let threw = false;
560
+ try {
561
+ await store.renameTag("task", "todo");
562
+ } catch {
563
+ threw = true;
564
+ }
565
+ (db as any).prepare = originalDeleteTag;
566
+ expect(threw).toBe(true);
567
+
568
+ // Original state intact: task tags still present, todo absent.
569
+ const tags = (await store.listTags()).map((t) => t.name);
570
+ expect(tags).toContain("task");
571
+ expect(tags).toContain("task/work");
572
+ expect(tags).not.toContain("todo");
573
+ expect(tags).not.toContain("todo/work");
574
+ });
575
+
576
+ it("8. invalidates hierarchy + schema caches after rename", async () => {
577
+ await store.upsertTagRecord("task", {
578
+ fields: { status: { type: "string" } },
579
+ });
580
+ await store.upsertTagRecord("task/work", { parent_names: ["task"] });
581
+ await store.createNote("a", { tags: ["task/work"] });
582
+
583
+ // Prime the caches by querying via the hierarchy-aware path.
584
+ await store.queryNotes({ tags: ["task"] });
585
+
586
+ await store.renameTag("task", "todo");
587
+
588
+ // queryNotes via the new tag must find the note that's now tagged
589
+ // todo/work (descendant of todo via parent_names rewrite).
590
+ const found = await store.queryNotes({ tags: ["todo"] });
591
+ expect(found.length).toBe(1);
592
+
593
+ // validateNoteAgainstSchemas must surface fields under the new tag —
594
+ // proves the schema-config cache was busted (otherwise the resolver
595
+ // would still be keyed on `task`).
596
+ const status = store.validateNoteAgainstSchemas({
597
+ tags: ["todo"],
598
+ metadata: { status: 123 },
599
+ });
600
+ expect(status?.warnings.some((w) => w.reason === "type_mismatch")).toBe(true);
601
+ });
602
+
603
+ it("9. self-rename is a structured no-op when the source exists", async () => {
604
+ await store.createNote("a", { tags: ["task"] });
605
+ const result = await store.renameTag("task", "task");
606
+ expect(result).toMatchObject({ renamed: 0, sub_tags_renamed: 0 });
607
+ expect((await store.listTags()).find((t) => t.name === "task")?.count).toBe(1);
608
+ });
609
+
610
+ it("10. preserves transitive inheritance through the cascade (manual extends note; voice extends manual; renaming manual → instruction keeps voice's effective fields)", async () => {
611
+ await store.upsertTagRecord("note", {
612
+ fields: { topic: { type: "string" } },
613
+ });
614
+ await store.upsertTagRecord("manual", {
615
+ fields: { author: { type: "string" } },
616
+ parent_names: ["note"],
617
+ });
618
+ await store.upsertTagRecord("voice", {
619
+ parent_names: ["manual"],
620
+ });
621
+
622
+ const result = await store.renameTag("manual", "instruction");
623
+ expect(result.renamed).toBe(0); // no notes
624
+ if ("parent_refs_updated" in result) expect(result.parent_refs_updated).toBe(1);
625
+
626
+ // Voice's parent_names now references `instruction` (the renamed parent).
627
+ const voice = await store.getTagRecord("voice");
628
+ expect(voice?.parent_names).toEqual(["instruction"]);
629
+
630
+ // Voice's effective fields still inherit through instruction → note.
631
+ const status = store.validateNoteAgainstSchemas({
632
+ tags: ["voice"],
633
+ metadata: { topic: 123, author: "ok" },
634
+ });
635
+ expect(status?.warnings.some((w) => w.field === "topic" && w.reason === "type_mismatch")).toBe(
636
+ true,
637
+ );
638
+ });
639
+
640
+ it("11. rewrites indexed_fields.declarer_tags JSON arrays (#275 fold N3)", async () => {
641
+ // Drive through the update-tag MCP tool — it owns indexed-field
642
+ // lifecycle (see mcp.ts §update-tag); store.upsertTagRecord does
643
+ // not populate the `indexed_fields` table.
644
+ const tools = generateMcpTools(store);
645
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
646
+ await updateTag.execute({
647
+ tag: "task",
648
+ fields: { status: { type: "string", indexed: true } },
649
+ });
650
+ await updateTag.execute({
651
+ tag: "project",
652
+ fields: { status: { type: "string", indexed: true } },
653
+ });
654
+
655
+ const beforeRow = db
656
+ .prepare("SELECT declarer_tags FROM indexed_fields WHERE field = ?")
657
+ .get("status") as { declarer_tags: string };
658
+ expect(JSON.parse(beforeRow.declarer_tags).sort()).toEqual(["project", "task"]);
659
+
660
+ const result = await store.renameTag("task", "todo");
661
+ expect(result).toMatchObject({ indexed_field_declarers_updated: 1 });
662
+
663
+ const afterRow = db
664
+ .prepare("SELECT declarer_tags FROM indexed_fields WHERE field = ?")
665
+ .get("status") as { declarer_tags: string };
666
+ const declarers = JSON.parse(afterRow.declarer_tags) as string[];
667
+ expect(declarers).toContain("todo");
668
+ expect(declarers).toContain("project");
669
+ expect(declarers).not.toContain("task");
670
+ });
671
+
672
+ it("12. rewrites `_tags/<path>` config-note paths via rewriteTagConfigPath (#275 fold N3)", async () => {
673
+ await store.upsertTagRecord("old", {});
674
+ const root = await store.createNote("root config", { path: "_tags/old" });
675
+ const sub = await store.createNote("sub config", { path: "_tags/old/nested/leaf" });
676
+
677
+ const result = await store.renameTag("old", "new");
678
+ expect(result).toMatchObject({ paths_renamed: 2 });
679
+
680
+ const rootFresh = await store.getNote(root.id);
681
+ expect(rootFresh?.path).toBe("_tags/new");
682
+
683
+ const subFresh = await store.getNote(sub.id);
684
+ expect(subFresh?.path).toBe("_tags/new/nested/leaf");
685
+ });
686
+
687
+ it("14. sub-tag discovery escapes LIKE wildcards — `task_` rename doesn't pull `taskX/sub` into the cascade (#275 re-review)", async () => {
688
+ // Pre-fold: the discovery query was `LIKE 'task_/%'` which matches
689
+ // `taskX/sub` because `_` is a single-char wildcard. `taskX/sub`
690
+ // would then enter `renames` and get rewritten to `<new>/sub` — a
691
+ // write the caller never asked for.
692
+ await store.upsertTagRecord("task_", {});
693
+ await store.upsertTagRecord("taskX/sub", {});
694
+ const stray = await store.createNote("stray", { tags: ["taskX/sub"] });
695
+
696
+ const result = await store.renameTag("task_", "todo_");
697
+ // Only the actual root rename — no spurious sub-tag pulled in.
698
+ expect(result).toMatchObject({ sub_tags_renamed: 0 });
699
+
700
+ expect(await store.getTagRecord("task_")).toBeNull();
701
+ expect(await store.getTagRecord("todo_")).toBeTruthy();
702
+
703
+ // `taskX/sub` is untouched: row still present, the note tagged with
704
+ // it still carries the original tag.
705
+ expect(await store.getTagRecord("taskX/sub")).toBeTruthy();
706
+ expect((await store.getNote(stray.id))!.tags).toEqual(["taskX/sub"]);
707
+ });
708
+
709
+ it("13. LIKE wildcard escape — a tag literally named `task_` doesn't false-match `taskX` (#275 fold N1)", async () => {
710
+ // `task_` and `taskX` are unrelated tags. Pre-fold N1 the LIKE
711
+ // pre-filter would have considered `taskX` rows as candidates for
712
+ // `task_`'s rewrite (LIKE `%"task_"%` matches `"taskX"` in JSON
713
+ // because `_` is a single-char wildcard). The cascade's downstream
714
+ // remapJsonArray would have rejected the row, so no data
715
+ // corruption — but the wasted scan + bad hygiene is what fold N1
716
+ // closes. This test pins the behavior end-to-end.
717
+ await store.upsertTagRecord("task_", {});
718
+ await store.upsertTagRecord("taskX", {});
719
+ await store.upsertTagRecord("voice", {
720
+ parent_names: ["taskX"],
721
+ });
722
+
723
+ await store.renameTag("task_", "renamed_task");
724
+
725
+ // taskX-rooted parent_names must be untouched — the escape stops
726
+ // taskX from being a candidate for the `task_` rewrite.
727
+ const voice = await store.getTagRecord("voice");
728
+ expect(voice?.parent_names).toEqual(["taskX"]);
729
+ // Sanity: the actual rename did happen.
730
+ expect(await store.getTagRecord("task_")).toBeNull();
731
+ expect(await store.getTagRecord("renamed_task")).toBeTruthy();
732
+ });
733
+ });
734
+
310
735
  describe("mergeTags", async () => {
311
736
  it("retags every note from every source onto target and drops sources", async () => {
312
737
  const n1 = await store.createNote("A", { tags: ["v1"] });
@@ -476,12 +901,24 @@ describe("vault stats", async () => {
476
901
  const result = await store.getVaultStats();
477
902
  expect(result.totalNotes).toBe(2);
478
903
  expect(result.tagCount).toBe(2);
904
+ expect(result.attachmentCount).toBe(0);
479
905
  expect(result.topTags[0].tag).toBe("x");
480
906
  expect(result.topTags[0].count).toBe(2);
481
907
  expect(result.notesByMonth).toHaveLength(2);
482
908
  expect(result.earliestNote!.createdAt).toBe("2025-05-01T00:00:00.000Z");
483
909
  expect(result.latestNote!.createdAt).toBe("2025-06-01T00:00:00.000Z");
484
910
  });
911
+
912
+ it("getVaultStats counts attachments", async () => {
913
+ const n1 = await store.createNote("one");
914
+ const n2 = await store.createNote("two");
915
+ await store.addAttachment(n1.id, "/tmp/a1.mp3", "audio/mp3");
916
+ await store.addAttachment(n1.id, "/tmp/i1.png", "image/png");
917
+ await store.addAttachment(n2.id, "/tmp/a2.mp3", "audio/mp3");
918
+
919
+ const result = await store.getVaultStats();
920
+ expect(result.attachmentCount).toBe(3);
921
+ });
485
922
  });
486
923
 
487
924
  // ---- Query ----
@@ -534,6 +971,178 @@ describe("queryNotes", async () => {
534
971
  expect(results.length).toBeGreaterThan(0);
535
972
  });
536
973
 
974
+ // ---- Generalized date_filter (vault#215) ----
975
+ //
976
+ // The legacy `dateFrom` / `dateTo` always filter on `n.created_at` (vault
977
+ // ingestion time). The new `dateFilter: { field, from, to }` shape lets a
978
+ // caller filter on any *content* date — an email's received date, a
979
+ // meeting's scheduled date — by pointing `field` at an indexed metadata
980
+ // field. `field` defaults to `created_at`, in which case the SQL is
981
+ // identical to the legacy path.
982
+ describe("dateFilter (generalized)", () => {
983
+ async function declareEmailDate() {
984
+ const { declareField } = await import("./indexed-fields.js");
985
+ declareField(db, "email_date", "TEXT", "email");
986
+ }
987
+
988
+ it("dateFilter with no field defaults to created_at (matches the legacy shorthand)", async () => {
989
+ await store.createNote("A", { created_at: "2026-01-15T00:00:00.000Z" });
990
+ await store.createNote("B", { created_at: "2026-02-15T00:00:00.000Z" });
991
+ await store.createNote("C", { created_at: "2026-03-15T00:00:00.000Z" });
992
+
993
+ const results = await store.queryNotes({
994
+ dateFilter: { from: "2026-02-01", to: "2026-03-01" },
995
+ });
996
+ expect(results.map((n) => n.content)).toEqual(["B"]);
997
+ });
998
+
999
+ it("dateFilter on an indexed metadata field filters on content date, not ingestion date", async () => {
1000
+ await declareEmailDate();
1001
+ // Ingestion order doesn't match email_date order — that's the whole
1002
+ // point: the bug was that `dateFrom` returned rows by ingestion time.
1003
+ await store.createNote("recently-synced old email", {
1004
+ metadata: { email_date: "2025-12-01T00:00:00.000Z" },
1005
+ });
1006
+ await store.createNote("recently-synced new email", {
1007
+ metadata: { email_date: "2026-04-25T00:00:00.000Z" },
1008
+ });
1009
+ await store.createNote("recently-synced ancient", {
1010
+ metadata: { email_date: "2024-08-15T00:00:00.000Z" },
1011
+ });
1012
+
1013
+ const results = await store.queryNotes({
1014
+ dateFilter: { field: "email_date", from: "2026-04-01", to: "2026-05-01" },
1015
+ });
1016
+ expect(results.map((n) => n.content)).toEqual(["recently-synced new email"]);
1017
+ });
1018
+
1019
+ it("dateFilter on a non-indexed field rejects with FIELD_NOT_INDEXED", async () => {
1020
+ await store.createNote("X", { metadata: { meeting_date: "2026-04-25T00:00:00.000Z" } });
1021
+ // Note: not declared via declareField, so the field has no generated
1022
+ // column. The error mirrors the metadata-operator + order_by gate.
1023
+ try {
1024
+ await store.queryNotes({
1025
+ dateFilter: { field: "meeting_date", from: "2026-04-01" },
1026
+ });
1027
+ throw new Error("expected QueryError");
1028
+ } catch (err: any) {
1029
+ expect(err.name).toBe("QueryError");
1030
+ expect(err.code).toBe("FIELD_NOT_INDEXED");
1031
+ expect(err.message).toContain("meeting_date");
1032
+ }
1033
+ });
1034
+
1035
+ it("dateFilter combined with top-level dateFrom rejects with INVALID_QUERY", async () => {
1036
+ await declareEmailDate();
1037
+ try {
1038
+ await store.queryNotes({
1039
+ dateFrom: "2026-01-01",
1040
+ dateFilter: { field: "email_date", from: "2026-04-01" },
1041
+ });
1042
+ throw new Error("expected QueryError");
1043
+ } catch (err: any) {
1044
+ expect(err.name).toBe("QueryError");
1045
+ expect(err.code).toBe("INVALID_QUERY");
1046
+ expect(err.message).toMatch(/cannot combine/i);
1047
+ }
1048
+ });
1049
+
1050
+ it("dateFilter with only `from` is open-ended on the upper bound", async () => {
1051
+ await declareEmailDate();
1052
+ await store.createNote("old", { metadata: { email_date: "2025-01-01T00:00:00.000Z" } });
1053
+ await store.createNote("middle", { metadata: { email_date: "2026-04-15T00:00:00.000Z" } });
1054
+ await store.createNote("new", { metadata: { email_date: "2026-05-01T00:00:00.000Z" } });
1055
+
1056
+ const results = await store.queryNotes({
1057
+ dateFilter: { field: "email_date", from: "2026-04-01" },
1058
+ });
1059
+ expect(results.map((n) => n.content).sort()).toEqual(["middle", "new"]);
1060
+ });
1061
+
1062
+ it("dateFilter with explicit field='created_at' routes to the legacy SQL path", async () => {
1063
+ // The implicit-default case is covered above; this asserts the explicit
1064
+ // form behaves identically — no indexed-field gate, same n.created_at SQL.
1065
+ await store.createNote("A", { created_at: "2026-01-15T00:00:00.000Z" });
1066
+ await store.createNote("B", { created_at: "2026-02-15T00:00:00.000Z" });
1067
+ await store.createNote("C", { created_at: "2026-03-15T00:00:00.000Z" });
1068
+
1069
+ const results = await store.queryNotes({
1070
+ dateFilter: { field: "created_at", from: "2026-02-01", to: "2026-03-01" },
1071
+ });
1072
+ expect(results.map((n) => n.content)).toEqual(["B"]);
1073
+ });
1074
+
1075
+ it("query-notes accepts date_filter on an indexed metadata field (vault#215)", async () => {
1076
+ await declareEmailDate();
1077
+ await store.createNote("old email", {
1078
+ metadata: { email_date: "2025-12-01T00:00:00.000Z" },
1079
+ });
1080
+ await store.createNote("recent email", {
1081
+ metadata: { email_date: "2026-04-25T00:00:00.000Z" },
1082
+ });
1083
+
1084
+ const tools = generateMcpTools(store);
1085
+ const query = tools.find((t) => t.name === "query-notes")!;
1086
+ const results = await query.execute({
1087
+ date_filter: { field: "email_date", from: "2026-04-01", to: "2026-05-01" },
1088
+ include_content: true,
1089
+ }) as any[];
1090
+ expect(results.map((n) => n.content)).toEqual(["recent email"]);
1091
+ });
1092
+
1093
+ // ---- updated_at filter (vault#285 friction point 1.5) ----
1094
+ //
1095
+ // Incremental-rebuild flows ask "what changed since X." Like `created_at`,
1096
+ // `updated_at` is a real column on `notes` (no indexed-field gate), but
1097
+ // it tracks the *last write* rather than ingestion time. SSGs paginate
1098
+ // against it; sync clients use it as a high-watermark cursor.
1099
+ it("dateFilter on updated_at routes to the n.updated_at column (vault#285 1.5)", async () => {
1100
+ // Two notes; only one is later modified. The filter should pick up the
1101
+ // modification time, not the original creation time.
1102
+ const a = await store.createNote("untouched", { created_at: "2026-01-15T00:00:00.000Z" });
1103
+ const b = await store.createNote("modified-later", { created_at: "2026-01-20T00:00:00.000Z" });
1104
+ // Mutate b to bump its updated_at into a window that excludes a.
1105
+ await store.updateNote(b.id, { append: " edit" });
1106
+
1107
+ // Pin each note's updated_at deterministically so the assertion isn't
1108
+ // racing real wall-clock writes from the test harness.
1109
+ db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
1110
+ .run("2026-01-15T00:00:00.000Z", a.id);
1111
+ db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
1112
+ .run("2026-04-25T00:00:00.000Z", b.id);
1113
+
1114
+ const results = await store.queryNotes({
1115
+ dateFilter: { field: "updated_at", from: "2026-04-01" },
1116
+ });
1117
+ expect(results.map((n) => n.content)).toEqual(["modified-later edit"]);
1118
+ });
1119
+
1120
+ it("dateFilter on updated_at requires no indexed-field declaration", async () => {
1121
+ // `updated_at` is a recognized real column — must not hit the
1122
+ // requireIndexedField gate that fires for arbitrary metadata fields.
1123
+ await store.createNote("x");
1124
+ // Should not throw.
1125
+ const results = await store.queryNotes({
1126
+ dateFilter: { field: "updated_at", from: "1970-01-01" },
1127
+ });
1128
+ expect(Array.isArray(results)).toBe(true);
1129
+ });
1130
+
1131
+ it("dateFilter on updated_at honors the upper-bound exclusive `to`", async () => {
1132
+ const a = await store.createNote("inside-window");
1133
+ const b = await store.createNote("after-window");
1134
+ db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
1135
+ .run("2026-04-25T00:00:00.000Z", a.id);
1136
+ db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
1137
+ .run("2026-05-15T00:00:00.000Z", b.id);
1138
+
1139
+ const results = await store.queryNotes({
1140
+ dateFilter: { field: "updated_at", from: "2026-04-01", to: "2026-05-01" },
1141
+ });
1142
+ expect(results.map((n) => n.content)).toEqual(["inside-window"]);
1143
+ });
1144
+ });
1145
+
537
1146
  it("sorts ascending and descending", async () => {
538
1147
  await store.createNote("First", { id: "first" });
539
1148
  await store.createNote("Second", { id: "second" });
@@ -907,7 +1516,7 @@ describe("attachments", async () => {
907
1516
  // ---- MCP Tools ----
908
1517
 
909
1518
  describe("MCP tools", async () => {
910
- it("generates all 9 consolidated tools", () => {
1519
+ it("generates the consolidated tool set", () => {
911
1520
  const tools = generateMcpTools(store);
912
1521
  const names = tools.map((t) => t.name);
913
1522
 
@@ -920,6 +1529,19 @@ describe("MCP tools", async () => {
920
1529
  expect(names).toContain("delete-tag");
921
1530
  expect(names).toContain("find-path");
922
1531
  expect(names).toContain("vault-info");
1532
+ // Six note-schema tools (list/update/delete-note-schema +
1533
+ // list/set/delete-schema-mapping) retired in v17 — the standalone
1534
+ // note_schemas + schema_mappings subsystem was a parallel path to
1535
+ // tags.fields with zero operator usage. See vault#267.
1536
+ expect(names).not.toContain("list-note-schemas");
1537
+ expect(names).not.toContain("update-note-schema");
1538
+ expect(names).not.toContain("delete-note-schema");
1539
+ expect(names).not.toContain("list-schema-mappings");
1540
+ expect(names).not.toContain("set-schema-mapping");
1541
+ expect(names).not.toContain("delete-schema-mapping");
1542
+ // synthesize-notes retired in v17 — replicable with query-notes(near=) +
1543
+ // find-path + agent-side aggregation. See vault#268.
1544
+ expect(names).not.toContain("synthesize-notes");
923
1545
  expect(tools).toHaveLength(9);
924
1546
  });
925
1547
 
@@ -1141,6 +1763,96 @@ describe("MCP tools", async () => {
1141
1763
  expect((await store.getNote(note.id))!.content).toBe("Test");
1142
1764
  });
1143
1765
 
1766
+ // ---- include_content response-shape opt-out (vault#285 friction point 2.response) ----
1767
+ //
1768
+ // Default behavior is unchanged: full Note is returned with `content`.
1769
+ // Setting `include_content: false` swaps in the lean NoteIndex shape
1770
+ // (drops content, adds byteSize + preview). Cuts the response cost on
1771
+ // small-edit / large-note workflows.
1772
+ describe("update-note include_content", () => {
1773
+ it("defaults to full Note (back-compat)", async () => {
1774
+ const note = await store.createNote("Original body", { path: "x" });
1775
+ const tools = generateMcpTools(store);
1776
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1777
+ const result = await updateNote.execute({
1778
+ id: note.id,
1779
+ content: "Replaced body",
1780
+ force: true,
1781
+ }) as any;
1782
+ expect(result.content).toBe("Replaced body");
1783
+ // Index-only fields must NOT appear on the back-compat shape.
1784
+ expect(result.byteSize).toBeUndefined();
1785
+ expect(result.preview).toBeUndefined();
1786
+ });
1787
+
1788
+ it("include_content: false returns the lean NoteIndex shape", async () => {
1789
+ const longBody = "a".repeat(5_000);
1790
+ const note = await store.createNote(longBody, { path: "big-note" });
1791
+ const tools = generateMcpTools(store);
1792
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1793
+ const result = await updateNote.execute({
1794
+ id: note.id,
1795
+ append: " edit",
1796
+ include_content: false,
1797
+ }) as any;
1798
+ // No content payload — that's the whole point of the opt-out.
1799
+ expect(result.content).toBeUndefined();
1800
+ // Index fields present.
1801
+ expect(typeof result.byteSize).toBe("number");
1802
+ expect(result.byteSize).toBe(5_000 + 5); // original + " edit"
1803
+ expect(typeof result.preview).toBe("string");
1804
+ expect(result.preview.length).toBeGreaterThan(0);
1805
+ expect(result.id).toBe(note.id);
1806
+ expect(result.path).toBe("big-note");
1807
+ expect(result.updatedAt).toBeTruthy();
1808
+ });
1809
+
1810
+ it("include_content: false applies uniformly across batch responses", async () => {
1811
+ await store.createNote("A", { id: "a", path: "a" });
1812
+ await store.createNote("B", { id: "b", path: "b" });
1813
+ const tools = generateMcpTools(store);
1814
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1815
+ const result = await updateNote.execute({
1816
+ include_content: false,
1817
+ notes: [
1818
+ { id: "a", content: "A v2", force: true },
1819
+ { id: "b", append: " v2" },
1820
+ ],
1821
+ }) as any[];
1822
+ expect(result).toHaveLength(2);
1823
+ for (const item of result) {
1824
+ expect(item.content).toBeUndefined();
1825
+ expect(typeof item.byteSize).toBe("number");
1826
+ expect(typeof item.preview).toBe("string");
1827
+ }
1828
+ });
1829
+
1830
+ it("include_content: false preserves validation_status when present", async () => {
1831
+ // Declare a tag schema with an indexed `priority` field that constrains
1832
+ // values; then write a note whose metadata violates the schema, so
1833
+ // attachValidationStatus has something to surface.
1834
+ await store.upsertTagSchema("task", {
1835
+ description: "tasks",
1836
+ fields: {
1837
+ priority: { type: "string", enum: ["low", "med", "high"], indexed: false },
1838
+ },
1839
+ });
1840
+ await store.createNote("a task", { id: "t1", tags: ["task"] });
1841
+ const tools = generateMcpTools(store);
1842
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1843
+ const result = await updateNote.execute({
1844
+ id: "t1",
1845
+ metadata: { priority: "URGENT" }, // not in enum — generates a warning
1846
+ include_content: false,
1847
+ force: true,
1848
+ }) as any;
1849
+ expect(result.content).toBeUndefined();
1850
+ expect(result.validation_status).toBeTruthy();
1851
+ expect(Array.isArray(result.validation_status.warnings)).toBe(true);
1852
+ expect(result.validation_status.warnings.length).toBeGreaterThan(0);
1853
+ });
1854
+ });
1855
+
1144
1856
  it("update-note force:true bypasses precondition and mutates unconditionally", async () => {
1145
1857
  const note = await store.createNote("First");
1146
1858
  const tools = generateMcpTools(store);
@@ -1267,78 +1979,348 @@ describe("MCP tools", async () => {
1267
1979
  expect((await store.getNote("source"))!.content).toBe("See [[People/Alice]] for details");
1268
1980
  });
1269
1981
 
1270
- it("query-notes single note by id", async () => {
1271
- const note = await store.createNote("Hello", { path: "test/note" });
1982
+ it("update-note append concatenates to end without precondition", async () => {
1983
+ const note = await store.createNote("first line\n", { id: "n1" });
1272
1984
  const tools = generateMcpTools(store);
1273
- const query = tools.find((t) => t.name === "query-notes")!;
1274
- const result = await query.execute({ id: note.id }) as any;
1275
- expect(result.content).toBe("Hello");
1276
- expect(result.path).toBe("test/note");
1277
- // updatedAt is the optimistic-concurrency token. Callers can't arm a
1278
- // followup update without it, so it must always come back from a
1279
- // single-note fetch.
1280
- expect(result.updatedAt).toBeTruthy();
1281
- expect(result.updatedAt).toBe(note.updatedAt);
1985
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1986
+
1987
+ // No if_updated_at and no force — append-only is precondition-exempt.
1988
+ const result = await updateNote.execute({ id: note.id, append: "second line\n" }) as any;
1989
+ expect(result.content).toBe("first line\nsecond line\n");
1990
+
1991
+ const persisted = await store.getNote(note.id);
1992
+ expect(persisted!.content).toBe("first line\nsecond line\n");
1282
1993
  });
1283
1994
 
1284
- it("query-notes single note by path", async () => {
1285
- await store.createNote("By Path", { path: "Projects/README" });
1995
+ it("update-note prepend concatenates to start without precondition", async () => {
1996
+ const note = await store.createNote("body", { id: "n1" });
1286
1997
  const tools = generateMcpTools(store);
1287
- const query = tools.find((t) => t.name === "query-notes")!;
1288
- const result = await query.execute({ id: "Projects/README" }) as any;
1289
- expect(result.content).toBe("By Path");
1998
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1999
+
2000
+ const result = await updateNote.execute({ id: note.id, prepend: "header\n" }) as any;
2001
+ expect(result.content).toBe("header\nbody");
1290
2002
  });
1291
2003
 
1292
- it("query-notes by tag", async () => {
1293
- await store.createNote("Test", { tags: ["daily"] });
2004
+ it("update-note prepend on frontmatter-led content injects after closing --- (#203)", async () => {
2005
+ const original = "---\ntitle: Foo\ntags: [bar]\n---\nbody line 1\n";
2006
+ const note = await store.createNote(original, { id: "n1" });
1294
2007
  const tools = generateMcpTools(store);
1295
- const query = tools.find((t) => t.name === "query-notes")!;
1296
- const result = await query.execute({ tag: ["daily"] }) as any[];
1297
- expect(result).toHaveLength(1);
2008
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2009
+
2010
+ const result = await updateNote.execute({ id: note.id, prepend: "preamble\n" }) as any;
2011
+ // Frontmatter still at byte 0 — parsers expecting `---\n` will find it.
2012
+ expect(result.content.startsWith("---\ntitle: Foo\ntags: [bar]\n---\n")).toBe(true);
2013
+ // Prepend lands immediately after the closing fence, before the body.
2014
+ expect(result.content).toBe(
2015
+ "---\ntitle: Foo\ntags: [bar]\n---\npreamble\nbody line 1\n",
2016
+ );
1298
2017
  });
1299
2018
 
1300
- it("query-notes has_tags=false surfaces untagged notes", async () => {
1301
- await store.createNote("Tagged", { tags: ["daily"] });
1302
- await store.createNote("Plain");
2019
+ it("update-note prepend on content lacking frontmatter injects at byte 0", async () => {
2020
+ const note = await store.createNote("# Heading\nbody\n", { id: "n1" });
1303
2021
  const tools = generateMcpTools(store);
1304
- const query = tools.find((t) => t.name === "query-notes")!;
1305
- const result = await query.execute({ has_tags: false, include_content: true }) as any[];
1306
- expect(result.map((n) => n.content)).toEqual(["Plain"]);
1307
- });
2022
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1308
2023
 
1309
- it("query-notes has_links=false surfaces orphaned notes", async () => {
1310
- const a = await store.createNote("Source", { id: "mq-a" });
1311
- const b = await store.createNote("Target", { id: "mq-b" });
1312
- await store.createNote("Orphan", { id: "mq-o" });
1313
- await store.createLink(a.id, b.id, "mentions");
2024
+ const result = await updateNote.execute({ id: note.id, prepend: "preamble\n" }) as any;
2025
+ expect(result.content).toBe("preamble\n# Heading\nbody\n");
2026
+ });
1314
2027
 
2028
+ it("update-note append+prepend in one call lands both contributions", async () => {
2029
+ const note = await store.createNote("middle", { id: "n1" });
1315
2030
  const tools = generateMcpTools(store);
1316
- const query = tools.find((t) => t.name === "query-notes")!;
1317
- const result = await query.execute({ has_links: false, include_content: true }) as any[];
1318
- expect(result.map((n) => n.content)).toEqual(["Orphan"]);
1319
- });
2031
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1320
2032
 
1321
- it("query-notes metadata operator query routes through the indexed column", async () => {
1322
- const { declareField } = await import("./indexed-fields.js");
1323
- declareField(db, "priority", "INTEGER", "project");
1324
- await store.createNote("high", { metadata: { priority: 5 } });
1325
- await store.createNote("mid", { metadata: { priority: 3 } });
1326
- await store.createNote("low", { metadata: { priority: 1 } });
2033
+ const result = await updateNote.execute({
2034
+ id: note.id,
2035
+ prepend: "[start] ",
2036
+ append: " [end]",
2037
+ }) as any;
2038
+ expect(result.content).toBe("[start] middle [end]");
2039
+ });
1327
2040
 
2041
+ it("update-note rejects content + append in same call", async () => {
2042
+ const note = await store.createNote("body", { id: "n1" });
1328
2043
  const tools = generateMcpTools(store);
1329
- const query = tools.find((t) => t.name === "query-notes")!;
1330
- const result = await query.execute({
1331
- metadata: { priority: { gte: 3 } },
1332
- include_content: true,
1333
- }) as any[];
1334
- expect(result.map((n) => n.content).sort()).toEqual(["high", "mid"]);
2044
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2045
+
2046
+ let err: any;
2047
+ try {
2048
+ await updateNote.execute({ id: note.id, content: "new", append: "more", force: true });
2049
+ } catch (e) {
2050
+ err = e;
2051
+ }
2052
+ expect(err?.message).toMatch(/mutually exclusive/);
1335
2053
  });
1336
2054
 
1337
- it("query-notes order_by + sort=desc surfaces highest-priority first", async () => {
1338
- const { declareField } = await import("./indexed-fields.js");
1339
- declareField(db, "priority", "INTEGER", "project");
1340
- await store.createNote("p2", { metadata: { priority: 2 } });
1341
- await store.createNote("p5", { metadata: { priority: 5 } });
2055
+ it("update-note rejects content + content_edit in same call", async () => {
2056
+ const note = await store.createNote("hello world", { id: "n1" });
2057
+ const tools = generateMcpTools(store);
2058
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2059
+
2060
+ let err: any;
2061
+ try {
2062
+ await updateNote.execute({
2063
+ id: note.id,
2064
+ content: "replace",
2065
+ content_edit: { old_text: "hello", new_text: "hi" },
2066
+ force: true,
2067
+ });
2068
+ } catch (e) {
2069
+ err = e;
2070
+ }
2071
+ expect(err?.message).toMatch(/mutually exclusive/);
2072
+ });
2073
+
2074
+ it("update-note rejects append + content_edit in same call", async () => {
2075
+ const note = await store.createNote("hello world", { id: "n1" });
2076
+ const tools = generateMcpTools(store);
2077
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2078
+
2079
+ let err: any;
2080
+ try {
2081
+ await updateNote.execute({
2082
+ id: note.id,
2083
+ append: " more",
2084
+ content_edit: { old_text: "hello", new_text: "hi" },
2085
+ });
2086
+ } catch (e) {
2087
+ err = e;
2088
+ }
2089
+ expect(err?.message).toMatch(/mutually exclusive/);
2090
+ });
2091
+
2092
+ it("update-note append still requires precondition when combined with other fields", async () => {
2093
+ const note = await store.createNote("body", { id: "n1" });
2094
+ const tools = generateMcpTools(store);
2095
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2096
+
2097
+ // append + metadata is NOT precondition-exempt — metadata mutation
2098
+ // can lose data on a stale read, so the safety gate stays in.
2099
+ let err: any;
2100
+ try {
2101
+ await updateNote.execute({ id: note.id, append: " more", metadata: { x: 1 } });
2102
+ } catch (e) {
2103
+ err = e;
2104
+ }
2105
+ expect(err?.code).toBe("PRECONDITION_REQUIRED");
2106
+ });
2107
+
2108
+ it("update-note append is atomic under concurrent calls — both lands", async () => {
2109
+ const note = await store.createNote("seed:", { id: "n1" });
2110
+ const tools = generateMcpTools(store);
2111
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2112
+
2113
+ // Two concurrent appends. SQL-level concat means both contributions
2114
+ // land — neither overwrites the other.
2115
+ const results = await Promise.all([
2116
+ updateNote.execute({ id: note.id, append: " A" }),
2117
+ updateNote.execute({ id: note.id, append: " B" }),
2118
+ ]);
2119
+ expect(results).toHaveLength(2);
2120
+
2121
+ const persisted = await store.getNote(note.id);
2122
+ // Final content is one of "seed: A B" or "seed: B A" — the order
2123
+ // depends on which write got the lock first, but both contributions
2124
+ // are present.
2125
+ expect(persisted!.content === "seed: A B" || persisted!.content === "seed: B A").toBe(true);
2126
+ });
2127
+
2128
+ it("update-note append updates updated_at and respects if_updated_at when supplied", async () => {
2129
+ const note = await store.createNote("seed", { id: "n1" });
2130
+ const tools = generateMcpTools(store);
2131
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2132
+
2133
+ // With if_updated_at — succeeds because we're using the right token.
2134
+ const ok = await updateNote.execute({ id: note.id, append: " A", if_updated_at: note.updatedAt }) as any;
2135
+ expect(ok.content).toBe("seed A");
2136
+ expect(ok.updatedAt).not.toBe(note.updatedAt);
2137
+
2138
+ // Stale token — conflict.
2139
+ let err: any;
2140
+ try {
2141
+ await updateNote.execute({ id: note.id, append: " B", if_updated_at: note.updatedAt });
2142
+ } catch (e) {
2143
+ err = e;
2144
+ }
2145
+ expect(err?.code).toBe("CONFLICT");
2146
+ });
2147
+
2148
+ it("update-note append parses new wikilinks introduced via append", async () => {
2149
+ const target = await store.createNote("Alice's note", { id: "alice", path: "People/Alice" });
2150
+ const source = await store.createNote("intro\n", { id: "src" });
2151
+ const tools = generateMcpTools(store);
2152
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2153
+
2154
+ await updateNote.execute({ id: source.id, append: "see [[People/Alice]]" });
2155
+
2156
+ const links = await store.getLinks(source.id, { direction: "outbound" });
2157
+ expect(links.some((l) => l.targetId === target.id && l.relationship === "wikilink")).toBe(true);
2158
+ });
2159
+
2160
+ it("update-note content_edit replaces a single occurrence", async () => {
2161
+ const note = await store.createNote("hello world", { id: "n1" });
2162
+ const tools = generateMcpTools(store);
2163
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2164
+
2165
+ const result = await updateNote.execute({
2166
+ id: note.id,
2167
+ content_edit: { old_text: "hello", new_text: "hi" },
2168
+ if_updated_at: note.updatedAt,
2169
+ }) as any;
2170
+ expect(result.content).toBe("hi world");
2171
+ });
2172
+
2173
+ it("update-note content_edit errors when old_text is not found", async () => {
2174
+ const note = await store.createNote("hello world", { id: "n1" });
2175
+ const tools = generateMcpTools(store);
2176
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2177
+
2178
+ let err: any;
2179
+ try {
2180
+ await updateNote.execute({
2181
+ id: note.id,
2182
+ content_edit: { old_text: "missing", new_text: "x" },
2183
+ if_updated_at: note.updatedAt,
2184
+ });
2185
+ } catch (e) {
2186
+ err = e;
2187
+ }
2188
+ expect(err?.message).toMatch(/not found/);
2189
+ // Note must be untouched.
2190
+ const persisted = await store.getNote(note.id);
2191
+ expect(persisted!.content).toBe("hello world");
2192
+ });
2193
+
2194
+ it("update-note content_edit errors when old_text matches multiple times", async () => {
2195
+ const note = await store.createNote("hello hello", { id: "n1" });
2196
+ const tools = generateMcpTools(store);
2197
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2198
+
2199
+ let err: any;
2200
+ try {
2201
+ await updateNote.execute({
2202
+ id: note.id,
2203
+ content_edit: { old_text: "hello", new_text: "hi" },
2204
+ if_updated_at: note.updatedAt,
2205
+ });
2206
+ } catch (e) {
2207
+ err = e;
2208
+ }
2209
+ expect(err?.message).toMatch(/matches multiple times|exactly once/);
2210
+ const persisted = await store.getNote(note.id);
2211
+ expect(persisted!.content).toBe("hello hello");
2212
+ });
2213
+
2214
+ it("update-note content_edit requires precondition by default", async () => {
2215
+ const note = await store.createNote("hello world", { id: "n1" });
2216
+ const tools = generateMcpTools(store);
2217
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2218
+
2219
+ let err: any;
2220
+ try {
2221
+ await updateNote.execute({
2222
+ id: note.id,
2223
+ content_edit: { old_text: "hello", new_text: "hi" },
2224
+ });
2225
+ } catch (e) {
2226
+ err = e;
2227
+ }
2228
+ expect(err?.code).toBe("PRECONDITION_REQUIRED");
2229
+ });
2230
+
2231
+ it("update-note content_edit conflicts when if_updated_at is stale", async () => {
2232
+ const note = await store.createNote("hello world", { id: "n1" });
2233
+ const tools = generateMcpTools(store);
2234
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2235
+
2236
+ // Bump the note so a stale token will conflict at the SQL layer.
2237
+ await updateNote.execute({ id: note.id, content: "hello world", force: true });
2238
+
2239
+ let err: any;
2240
+ try {
2241
+ await updateNote.execute({
2242
+ id: note.id,
2243
+ content_edit: { old_text: "hello", new_text: "hi" },
2244
+ if_updated_at: "2020-01-01T00:00:00.000Z",
2245
+ });
2246
+ } catch (e) {
2247
+ err = e;
2248
+ }
2249
+ expect(err?.code).toBe("CONFLICT");
2250
+ });
2251
+
2252
+ it("query-notes single note by id", async () => {
2253
+ const note = await store.createNote("Hello", { path: "test/note" });
2254
+ const tools = generateMcpTools(store);
2255
+ const query = tools.find((t) => t.name === "query-notes")!;
2256
+ const result = await query.execute({ id: note.id }) as any;
2257
+ expect(result.content).toBe("Hello");
2258
+ expect(result.path).toBe("test/note");
2259
+ // updatedAt is the optimistic-concurrency token. Callers can't arm a
2260
+ // followup update without it, so it must always come back from a
2261
+ // single-note fetch.
2262
+ expect(result.updatedAt).toBeTruthy();
2263
+ expect(result.updatedAt).toBe(note.updatedAt);
2264
+ });
2265
+
2266
+ it("query-notes single note by path", async () => {
2267
+ await store.createNote("By Path", { path: "Projects/README" });
2268
+ const tools = generateMcpTools(store);
2269
+ const query = tools.find((t) => t.name === "query-notes")!;
2270
+ const result = await query.execute({ id: "Projects/README" }) as any;
2271
+ expect(result.content).toBe("By Path");
2272
+ });
2273
+
2274
+ it("query-notes by tag", async () => {
2275
+ await store.createNote("Test", { tags: ["daily"] });
2276
+ const tools = generateMcpTools(store);
2277
+ const query = tools.find((t) => t.name === "query-notes")!;
2278
+ const result = await query.execute({ tag: ["daily"] }) as any[];
2279
+ expect(result).toHaveLength(1);
2280
+ });
2281
+
2282
+ it("query-notes has_tags=false surfaces untagged notes", async () => {
2283
+ await store.createNote("Tagged", { tags: ["daily"] });
2284
+ await store.createNote("Plain");
2285
+ const tools = generateMcpTools(store);
2286
+ const query = tools.find((t) => t.name === "query-notes")!;
2287
+ const result = await query.execute({ has_tags: false, include_content: true }) as any[];
2288
+ expect(result.map((n) => n.content)).toEqual(["Plain"]);
2289
+ });
2290
+
2291
+ it("query-notes has_links=false surfaces orphaned notes", async () => {
2292
+ const a = await store.createNote("Source", { id: "mq-a" });
2293
+ const b = await store.createNote("Target", { id: "mq-b" });
2294
+ await store.createNote("Orphan", { id: "mq-o" });
2295
+ await store.createLink(a.id, b.id, "mentions");
2296
+
2297
+ const tools = generateMcpTools(store);
2298
+ const query = tools.find((t) => t.name === "query-notes")!;
2299
+ const result = await query.execute({ has_links: false, include_content: true }) as any[];
2300
+ expect(result.map((n) => n.content)).toEqual(["Orphan"]);
2301
+ });
2302
+
2303
+ it("query-notes metadata operator query routes through the indexed column", async () => {
2304
+ const { declareField } = await import("./indexed-fields.js");
2305
+ declareField(db, "priority", "INTEGER", "project");
2306
+ await store.createNote("high", { metadata: { priority: 5 } });
2307
+ await store.createNote("mid", { metadata: { priority: 3 } });
2308
+ await store.createNote("low", { metadata: { priority: 1 } });
2309
+
2310
+ const tools = generateMcpTools(store);
2311
+ const query = tools.find((t) => t.name === "query-notes")!;
2312
+ const result = await query.execute({
2313
+ metadata: { priority: { gte: 3 } },
2314
+ include_content: true,
2315
+ }) as any[];
2316
+ expect(result.map((n) => n.content).sort()).toEqual(["high", "mid"]);
2317
+ });
2318
+
2319
+ it("query-notes order_by + sort=desc surfaces highest-priority first", async () => {
2320
+ const { declareField } = await import("./indexed-fields.js");
2321
+ declareField(db, "priority", "INTEGER", "project");
2322
+ await store.createNote("p2", { metadata: { priority: 2 } });
2323
+ await store.createNote("p5", { metadata: { priority: 5 } });
1342
2324
  await store.createNote("p1", { metadata: { priority: 1 } });
1343
2325
 
1344
2326
  const tools = generateMcpTools(store);
@@ -1529,6 +2511,35 @@ describe("MCP tools", async () => {
1529
2511
  expect(result[0].id).toBe("near");
1530
2512
  });
1531
2513
 
2514
+ it("query-notes near returns neighborhood even when limit is small and unrelated notes were created first (#130)", async () => {
2515
+ // Repro of #130: anchor + linked notes get crowded out by unrelated notes
2516
+ // when the query runs ORDER BY created_at LIMIT 5 BEFORE the
2517
+ // neighborhood filter. With the SQL-pushed ids filter, LIMIT applies to
2518
+ // the neighborhood, not the whole notes table.
2519
+ //
2520
+ // Seed: 10 unrelated notes created first, THEN the anchor + 2 linked
2521
+ // notes. With limit=5 and ORDER BY created_at ASC, the unrelated ten
2522
+ // would fill the slate and the in-neighborhood notes would never appear.
2523
+ for (let i = 0; i < 10; i++) {
2524
+ await store.createNote(`Unrelated ${i}`, { id: `unrelated-${i}` });
2525
+ }
2526
+ await store.createNote("Anchor", { id: "anchor" });
2527
+ await store.createNote("Outbound target", { id: "outbound" });
2528
+ await store.createNote("Inbound source", { id: "inbound" });
2529
+ await store.createLink("anchor", "outbound", "wikilink");
2530
+ await store.createLink("inbound", "anchor", "wikilink");
2531
+
2532
+ const tools = generateMcpTools(store);
2533
+ const query = tools.find((t) => t.name === "query-notes")!;
2534
+ const result = await query.execute({
2535
+ near: { note_id: "anchor", depth: 2 },
2536
+ limit: 5,
2537
+ }) as any[];
2538
+
2539
+ const ids = result.map((n: any) => n.id).sort();
2540
+ expect(ids).toEqual(["anchor", "inbound", "outbound"]);
2541
+ });
2542
+
1532
2543
  it("delete-note accepts path", async () => {
1533
2544
  await store.createNote("To delete", { path: "Temp/note" });
1534
2545
  const tools = generateMcpTools(store);
@@ -1688,51 +2699,360 @@ describe("MCP tools", async () => {
1688
2699
  expect(fresh.metadata.priority).toBe(0);
1689
2700
  expect(fresh.metadata.status).toBe("active");
1690
2701
  });
1691
- });
1692
2702
 
1693
- // ---- query-notes link expansion ----
2703
+ // ---- query-notes input-shape tolerance (vault#214) ----
2704
+ //
2705
+ // The MCP framework drops top-level keys not in the inputSchema without
2706
+ // raising — so an LLM caller passing the wrong field name gets a silent
2707
+ // no-op rather than an error. We accept canonical + camelCase + singular
2708
+ // aliases so the most common LLM mistakes still apply the filter, and
2709
+ // we mirror the `tag` param's string-or-array shape so a single excluded
2710
+ // tag doesn't need a wrapping array.
1694
2711
 
1695
- describe("query-notes link expansion", async () => {
1696
- it("expands a single [[wikilink]] inline in full mode by default", async () => {
1697
- await store.createNote("# Who I Am\nI teach Taiji.", { path: "Statements/Who" });
1698
- await store.createNote(
1699
- "Canon:\nSee [[Statements/Who]] for identity.",
1700
- { path: "Canon" },
1701
- );
2712
+ it("query-notes accepts `excludeTags` (camelCase alias)", async () => {
2713
+ await store.createNote("a", { tags: ["email"] });
2714
+ await store.createNote("b", { tags: ["email", "urgent"] });
1702
2715
  const tools = generateMcpTools(store);
1703
- const query = tools.find((t) => t.name === "query-notes")!;
2716
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
2717
+ const r = await queryNotes.execute({ tag: "email", excludeTags: ["urgent"], include_content: true }) as any[];
2718
+ expect(r).toHaveLength(1);
2719
+ expect(r[0].content).toBe("a");
2720
+ });
1704
2721
 
1705
- const result = await query.execute({
1706
- id: "Canon",
1707
- expand_links: true,
1708
- }) as any;
2722
+ it("query-notes accepts `exclude_tag` (singular alias)", async () => {
2723
+ await store.createNote("a", { tags: ["email"] });
2724
+ await store.createNote("b", { tags: ["email", "urgent"] });
2725
+ const tools = generateMcpTools(store);
2726
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
2727
+ const r = await queryNotes.execute({ tag: "email", exclude_tag: "urgent", include_content: true }) as any[];
2728
+ expect(r).toHaveLength(1);
2729
+ expect(r[0].content).toBe("a");
2730
+ });
1709
2731
 
1710
- expect(result.content).toContain('<expanded path="Statements/Who" mode="full">');
1711
- expect(result.content).toContain("I teach Taiji.");
1712
- expect(result.content).toContain("</expanded>");
2732
+ it("query-notes `exclude_tags` accepts a single string (mirrors `tag`)", async () => {
2733
+ await store.createNote("a", { tags: ["email"] });
2734
+ await store.createNote("b", { tags: ["email", "urgent"] });
2735
+ const tools = generateMcpTools(store);
2736
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
2737
+ const r = await queryNotes.execute({ tag: "email", exclude_tags: "urgent", include_content: true }) as any[];
2738
+ expect(r).toHaveLength(1);
2739
+ expect(r[0].content).toBe("a");
1713
2740
  });
1714
2741
 
1715
- it("summary mode inlines only metadata.summary, not full content", async () => {
1716
- await store.createNote(
1717
- "# Long canonical statement\n\n(Many paragraphs of detail follow...)",
1718
- { path: "Statements/Philosophy", metadata: { summary: "Unforced / wu wei." } },
1719
- );
1720
- await store.createNote("Overview: [[Statements/Philosophy]]", { path: "Index" });
2742
+ it("query-notes canonical `exclude_tags: [...]` still works (regression)", async () => {
2743
+ await store.createNote("a", { tags: ["email"] });
2744
+ await store.createNote("b", { tags: ["email", "urgent"] });
1721
2745
  const tools = generateMcpTools(store);
1722
- const query = tools.find((t) => t.name === "query-notes")!;
2746
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
2747
+ const r = await queryNotes.execute({ tag: "email", exclude_tags: ["urgent"], include_content: true }) as any[];
2748
+ expect(r).toHaveLength(1);
2749
+ expect(r[0].content).toBe("a");
2750
+ });
2751
+
2752
+ it("query-notes routes through store.queryNotes so tag-hierarchy expansion fires", async () => {
2753
+ // `voice` and `text` declare "manual" as their parent via the v14
2754
+ // tags.parent_names column. A query for `tag: "manual"` should match
2755
+ // notes tagged with either child — that expansion only happens when
2756
+ // the call goes through `store.queryNotes`, not `noteOps.queryNotes`
2757
+ // directly.
2758
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
2759
+ await store.upsertTagRecord("text", { parent_names: ["manual"] });
2760
+ await store.createNote("voice memo", { tags: ["voice"] });
2761
+ await store.createNote("text memo", { tags: ["text"] });
2762
+ await store.createNote("unrelated", { tags: ["other"] });
1723
2763
 
1724
- const result = await query.execute({
1725
- id: "Index",
1726
- expand_links: true,
1727
- expand_mode: "summary",
1728
- }) as any;
2764
+ const tools = generateMcpTools(store);
2765
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
2766
+ const r = await queryNotes.execute({ tag: "manual", include_content: true }) as any[];
2767
+ expect(r).toHaveLength(2);
2768
+ expect(r.map((n) => n.content).sort()).toEqual(["text memo", "voice memo"]);
2769
+ });
2770
+
2771
+ it("query-notes FTS path routes through store.searchNotes so tag-hierarchy expansion fires (vault#227)", async () => {
2772
+ // Same fixture shape as the structured-query hierarchy test above, but
2773
+ // exercising the search branch. Pre-fix the FTS path called
2774
+ // `noteOps.searchNotes` directly and silently dropped descendant matches —
2775
+ // `tag: "manual"` would only return notes literally tagged #manual, not
2776
+ // notes tagged with the declared children #voice / #text.
2777
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
2778
+ await store.upsertTagRecord("text", { parent_names: ["manual"] });
2779
+ await store.createNote("voice handoff notes", { tags: ["voice"] });
2780
+ await store.createNote("text handoff notes", { tags: ["text"] });
2781
+ await store.createNote("unrelated handoff", { tags: ["other"] });
1729
2782
 
1730
- expect(result.content).toContain('mode="summary"');
1731
- expect(result.content).toContain("Unforced / wu wei.");
1732
- expect(result.content).not.toContain("Many paragraphs of detail");
2783
+ const tools = generateMcpTools(store);
2784
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
2785
+ const r = await queryNotes.execute({ search: "handoff", tag: "manual", include_content: true }) as any[];
2786
+ expect(r).toHaveLength(2);
2787
+ expect(r.map((n) => n.content).sort()).toEqual(["text handoff notes", "voice handoff notes"]);
2788
+ expect(r.map((n) => n.content)).not.toContain("unrelated handoff");
2789
+ });
2790
+
2791
+ it("query-notes does not mutate caller's params object across repeated calls", async () => {
2792
+ // normalizeTags returns a defensive copy of array inputs so the downstream
2793
+ // store layer can sort/dedupe without touching the caller's reference.
2794
+ // Without the copy, a caller reusing the same params object would see its
2795
+ // exclude_tags array reordered (or worse) on the second call.
2796
+ await store.createNote("a", { tags: ["email"] });
2797
+ await store.createNote("b", { tags: ["email", "urgent"] });
2798
+ await store.createNote("c", { tags: ["email", "spam"] });
2799
+
2800
+ const tools = generateMcpTools(store);
2801
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
2802
+ const params = { tag: "email", exclude_tags: ["urgent", "spam"], include_content: true };
2803
+
2804
+ const r1 = await queryNotes.execute(params) as any[];
2805
+ expect(params.exclude_tags).toEqual(["urgent", "spam"]);
2806
+
2807
+ const r2 = await queryNotes.execute(params) as any[];
2808
+ expect(params.exclude_tags).toEqual(["urgent", "spam"]);
2809
+
2810
+ expect(r1.map((n) => n.content).sort()).toEqual(["a"]);
2811
+ expect(r2.map((n) => n.content).sort()).toEqual(["a"]);
1733
2812
  });
1734
2813
 
1735
- it("deduplicates: a linked note expanded once, subsequent references marked", async () => {
2814
+ // ---- empty-note + batch-cap MCP regressions (#213) ----
2815
+
2816
+ it("create-note rejects bare empty content with no path (EMPTY_NOTE)", async () => {
2817
+ const tools = generateMcpTools(store);
2818
+ const createNote = tools.find((t) => t.name === "create-note")!;
2819
+ let err: any;
2820
+ try {
2821
+ await createNote.execute({ content: "" });
2822
+ } catch (e) {
2823
+ err = e;
2824
+ }
2825
+ expect(err?.code).toBe("EMPTY_NOTE");
2826
+ });
2827
+
2828
+ it("create-note batch rejects when any entry is empty content + no path (atomic, with item_index)", async () => {
2829
+ const tools = generateMcpTools(store);
2830
+ const createNote = tools.find((t) => t.name === "create-note")!;
2831
+ const beforeCount = (await store.queryNotes({ search: "atomic-marker" })).length;
2832
+ let err: any;
2833
+ try {
2834
+ await createNote.execute({
2835
+ notes: [
2836
+ { content: "atomic-marker first" },
2837
+ { content: "" },
2838
+ ],
2839
+ });
2840
+ } catch (e) {
2841
+ err = e;
2842
+ }
2843
+ expect(err?.code).toBe("EMPTY_NOTE");
2844
+ // The first item must NOT have been created — pre-validation rolls
2845
+ // the whole batch back atomically. Partial-create would leak prefixes
2846
+ // on every runaway-client burst (#213).
2847
+ const afterCount = (await store.queryNotes({ search: "atomic-marker" })).length;
2848
+ expect(afterCount).toBe(beforeCount);
2849
+ // Parity with HTTP route: MCP callers with multi-item batches need to
2850
+ // know which entry triggered the rejection. The bad entry is at index 1.
2851
+ expect(err.item_index).toBe(1);
2852
+ });
2853
+
2854
+ it("create-note single empty has null item_index (not a batch position)", async () => {
2855
+ const tools = generateMcpTools(store);
2856
+ const createNote = tools.find((t) => t.name === "create-note")!;
2857
+ let err: any;
2858
+ try {
2859
+ await createNote.execute({ content: "" });
2860
+ } catch (e) {
2861
+ err = e;
2862
+ }
2863
+ expect(err?.code).toBe("EMPTY_NOTE");
2864
+ // Single-call (no `notes` array) — there's no batch position to report.
2865
+ expect(err.item_index).toBeNull();
2866
+ });
2867
+
2868
+ it("create-note batch over MAX_BATCH_SIZE rejects with BATCH_TOO_LARGE", async () => {
2869
+ const tools = generateMcpTools(store);
2870
+ const createNote = tools.find((t) => t.name === "create-note")!;
2871
+ const notes = Array.from({ length: 501 }, (_, i) => ({ content: `n${i}` }));
2872
+ let err: any;
2873
+ try {
2874
+ await createNote.execute({ notes });
2875
+ } catch (e) {
2876
+ err = e;
2877
+ }
2878
+ expect(err?.code).toBe("BATCH_TOO_LARGE");
2879
+ expect(err.limit).toBe(500);
2880
+ expect(err.got).toBe(501);
2881
+ });
2882
+
2883
+ it("update-note batch over MAX_BATCH_SIZE rejects with BATCH_TOO_LARGE", async () => {
2884
+ const tools = generateMcpTools(store);
2885
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2886
+ const notes = Array.from({ length: 501 }, (_, i) => ({
2887
+ id: `id${i}`,
2888
+ content: "x",
2889
+ force: true,
2890
+ }));
2891
+ let err: any;
2892
+ try {
2893
+ await updateNote.execute({ notes });
2894
+ } catch (e) {
2895
+ err = e;
2896
+ }
2897
+ expect(err?.code).toBe("BATCH_TOO_LARGE");
2898
+ expect(err.limit).toBe(500);
2899
+ expect(err.got).toBe(501);
2900
+ });
2901
+
2902
+ it("create-note batch where mid-item triggers PATH_CONFLICT rolls back prefix items (#236)", async () => {
2903
+ // The empty-note pre-walk (#213) catches `{}` before any DB write; a
2904
+ // path-conflict can only surface on the actual INSERT, mid-loop. Without
2905
+ // the BEGIN/COMMIT wrap the prefix items would have already landed.
2906
+ await store.createNote("seed", { path: "taken-236" });
2907
+ const tools = generateMcpTools(store);
2908
+ const createNote = tools.find((t) => t.name === "create-note")!;
2909
+ const beforeIds = (await store.queryNotes({})).map((n) => n.id).sort();
2910
+
2911
+ let err: any;
2912
+ try {
2913
+ await createNote.execute({
2914
+ notes: [
2915
+ { content: "ok-1", path: "fresh-236-1" },
2916
+ { content: "ok-2", path: "fresh-236-2" },
2917
+ { content: "boom", path: "taken-236" },
2918
+ ],
2919
+ });
2920
+ } catch (e) {
2921
+ err = e;
2922
+ }
2923
+ expect(err).toBeTruthy();
2924
+
2925
+ // The two prefix items must NOT have been created — atomic rollback.
2926
+ const afterIds = (await store.queryNotes({})).map((n) => n.id).sort();
2927
+ expect(afterIds).toEqual(beforeIds);
2928
+ expect(await store.queryNotes({ path: "fresh-236-1" })).toHaveLength(0);
2929
+ expect(await store.queryNotes({ path: "fresh-236-2" })).toHaveLength(0);
2930
+ });
2931
+
2932
+ it("update-note batch rolls back prefix tag mutation when a later item path-conflicts (#236)", async () => {
2933
+ await store.createNote("A", { id: "a236" });
2934
+ await store.createNote("B", { id: "b236" });
2935
+ await store.createNote("C", { id: "c236", path: "occupied-236" });
2936
+ const tools = generateMcpTools(store);
2937
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2938
+
2939
+ const aBefore = await store.getNote("a236");
2940
+
2941
+ let err: any;
2942
+ try {
2943
+ await updateNote.execute({
2944
+ notes: [
2945
+ // Item 0 mutates a236's content + adds a tag. force=true skips
2946
+ // the if_updated_at precondition.
2947
+ { id: "a236", content: "A mutated", force: true, tags: { add: ["should-rollback"] } },
2948
+ // Item 1 tries to take a path already owned by c236 — PATH_CONFLICT.
2949
+ { id: "b236", path: "occupied-236", force: true },
2950
+ ],
2951
+ });
2952
+ } catch (e) {
2953
+ err = e;
2954
+ }
2955
+ expect(err?.code).toBe("PATH_CONFLICT");
2956
+
2957
+ // Item 0's tag-add + content change must be rolled back — the batch
2958
+ // transaction reverted them when item 1 path-conflicted (#236).
2959
+ const aAfter = await store.getNote("a236");
2960
+ expect(aAfter!.content).toBe(aBefore!.content);
2961
+ expect(aAfter!.tags ?? []).not.toContain("should-rollback");
2962
+ });
2963
+
2964
+ it("update-note batch rolls back prefix mutation when a later item if_updated_at-conflicts (#261)", async () => {
2965
+ // The companion to the PATH_CONFLICT test above. Item 1's stale
2966
+ // `if_updated_at` must surface as a ConflictError so the batch's
2967
+ // BEGIN/COMMIT wrap can roll back item 0's mutation.
2968
+ //
2969
+ // Pre-fix (vault#261): `noteOps.updateNote` checked `res.changes === 0`
2970
+ // to detect the precondition miss. Inside this multi-statement batch
2971
+ // transaction, `.changes` reported a stale non-zero value from prior
2972
+ // writes, so the conflict was silently missed and item 0's mutation
2973
+ // committed with item 1 ignored.
2974
+ //
2975
+ // Post-fix: the conditional UPDATE uses `RETURNING id` and detects the
2976
+ // miss directly from row presence. ConflictError fires; batch rolls back.
2977
+ //
2978
+ // Standalone bun:sqlite repro is pending — six attempted reductions
2979
+ // (basic txn, async/microtask, prepared-statement reuse, mcp-loop
2980
+ // mirror, hook-dispatch mirror, syncWikilinks-style writes) failed to
2981
+ // hit the .changes-stale path outside the full BunStore plumbing. The
2982
+ // bug surfaces only through `BunStore.updateNote` → hook dispatch.
2983
+ // See vault#261 for the audit trail.
2984
+ await store.createNote("A", { id: "a261" });
2985
+ await store.createNote("B", { id: "b261" });
2986
+ const tools = generateMcpTools(store);
2987
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2988
+
2989
+ const aBefore = await store.getNote("a261");
2990
+
2991
+ let err: any;
2992
+ try {
2993
+ await updateNote.execute({
2994
+ notes: [
2995
+ // Item 0 mutates a261's content + adds a tag (force, no precondition).
2996
+ { id: "a261", content: "A mutated", force: true, tags: { add: ["should-rollback"] } },
2997
+ // Item 1 has a stale if_updated_at on b261 — should ConflictError.
2998
+ { id: "b261", content: "B should-not-land", if_updated_at: "2020-01-01T00:00:00.000Z" },
2999
+ ],
3000
+ });
3001
+ } catch (e) {
3002
+ err = e;
3003
+ }
3004
+ expect(err?.code).toBe("CONFLICT");
3005
+
3006
+ // Item 0's tag-add + content change must be rolled back.
3007
+ const aAfter = await store.getNote("a261");
3008
+ expect(aAfter!.content).toBe(aBefore!.content);
3009
+ expect(aAfter!.tags ?? []).not.toContain("should-rollback");
3010
+ });
3011
+ });
3012
+
3013
+ // ---- query-notes link expansion ----
3014
+
3015
+ describe("query-notes link expansion", async () => {
3016
+ it("expands a single [[wikilink]] inline in full mode by default", async () => {
3017
+ await store.createNote("# Who I Am\nI teach Taiji.", { path: "Statements/Who" });
3018
+ await store.createNote(
3019
+ "Canon:\nSee [[Statements/Who]] for identity.",
3020
+ { path: "Canon" },
3021
+ );
3022
+ const tools = generateMcpTools(store);
3023
+ const query = tools.find((t) => t.name === "query-notes")!;
3024
+
3025
+ const result = await query.execute({
3026
+ id: "Canon",
3027
+ expand_links: true,
3028
+ }) as any;
3029
+
3030
+ expect(result.content).toContain('<expanded path="Statements/Who" mode="full">');
3031
+ expect(result.content).toContain("I teach Taiji.");
3032
+ expect(result.content).toContain("</expanded>");
3033
+ });
3034
+
3035
+ it("summary mode inlines only metadata.summary, not full content", async () => {
3036
+ await store.createNote(
3037
+ "# Long canonical statement\n\n(Many paragraphs of detail follow...)",
3038
+ { path: "Statements/Philosophy", metadata: { summary: "Unforced / wu wei." } },
3039
+ );
3040
+ await store.createNote("Overview: [[Statements/Philosophy]]", { path: "Index" });
3041
+ const tools = generateMcpTools(store);
3042
+ const query = tools.find((t) => t.name === "query-notes")!;
3043
+
3044
+ const result = await query.execute({
3045
+ id: "Index",
3046
+ expand_links: true,
3047
+ expand_mode: "summary",
3048
+ }) as any;
3049
+
3050
+ expect(result.content).toContain('mode="summary"');
3051
+ expect(result.content).toContain("Unforced / wu wei.");
3052
+ expect(result.content).not.toContain("Many paragraphs of detail");
3053
+ });
3054
+
3055
+ it("deduplicates: a linked note expanded once, subsequent references marked", async () => {
1736
3056
  await store.createNote("target body", { path: "Target" });
1737
3057
  await store.createNote(
1738
3058
  "First [[Target]], then [[Target]] again.",
@@ -1978,3 +3298,1581 @@ describe("query-notes link expansion", async () => {
1978
3298
  expect(result.content).not.toContain("unsummarized body");
1979
3299
  });
1980
3300
  });
3301
+
3302
+ // ---------------------------------------------------------------------------
3303
+ // Tag hierarchy via tags.parent_names (post-v14, patterns/tag-data-model.md)
3304
+ // ---------------------------------------------------------------------------
3305
+
3306
+ describe("tag hierarchy (tags.parent_names)", async () => {
3307
+ it("query for parent tag returns notes tagged with declared child", async () => {
3308
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3309
+ await store.createNote("voice note", { tags: ["voice"] });
3310
+ await store.createNote("text note", { tags: ["text"] });
3311
+
3312
+ const results = await store.queryNotes({ tags: ["manual"] });
3313
+ expect(results.length).toBe(1);
3314
+ expect(results[0]!.content).toBe("voice note");
3315
+ });
3316
+
3317
+ it("expands transitively across multiple levels", async () => {
3318
+ await store.upsertTagRecord("manual", { parent_names: ["note"] });
3319
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3320
+ await store.createNote("voice note", { tags: ["voice"] });
3321
+ await store.createNote("manual-only note", { tags: ["manual"] });
3322
+ await store.createNote("note-only note", { tags: ["note"] });
3323
+
3324
+ // #note matches all three (note + manual + voice).
3325
+ const noteResults = await store.queryNotes({ tags: ["note"] });
3326
+ expect(noteResults.length).toBe(3);
3327
+
3328
+ // #manual matches voice + manual-only, not note-only.
3329
+ const manualResults = await store.queryNotes({ tags: ["manual"] });
3330
+ expect(manualResults.map((n) => n.content).sort()).toEqual([
3331
+ "manual-only note",
3332
+ "voice note",
3333
+ ]);
3334
+ });
3335
+
3336
+ it("query for child does not match parent-tagged notes", async () => {
3337
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3338
+ await store.createNote("voice note", { tags: ["voice"] });
3339
+ await store.createNote("manual-only note", { tags: ["manual"] });
3340
+
3341
+ const results = await store.queryNotes({ tags: ["voice"] });
3342
+ expect(results.length).toBe(1);
3343
+ expect(results[0]!.content).toBe("voice note");
3344
+ });
3345
+
3346
+ it("supports multiple parents (diamond inheritance)", async () => {
3347
+ await store.upsertTagRecord("voice-meeting", { parent_names: ["voice", "meeting"] });
3348
+ await store.createNote("vm", { tags: ["voice-meeting"] });
3349
+ await store.createNote("v", { tags: ["voice"] });
3350
+ await store.createNote("m", { tags: ["meeting"] });
3351
+
3352
+ expect((await store.queryNotes({ tags: ["voice"] })).length).toBe(2); // v + vm
3353
+ expect((await store.queryNotes({ tags: ["meeting"] })).length).toBe(2); // m + vm
3354
+ });
3355
+
3356
+ it("hierarchy is invalidated when parent_names is set", async () => {
3357
+ await store.createNote("voice note", { tags: ["voice"] });
3358
+ // Before the parents are declared, #manual matches nothing.
3359
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
3360
+
3361
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3362
+
3363
+ // After upsert, the cache invalidates and the next query expands.
3364
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
3365
+ });
3366
+
3367
+ it("hierarchy is invalidated when parent_names is repointed", async () => {
3368
+ await store.createNote("voice note", { tags: ["voice"] });
3369
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3370
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
3371
+
3372
+ // Repoint the parent.
3373
+ await store.upsertTagRecord("voice", { parent_names: ["audio"] });
3374
+
3375
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
3376
+ expect((await store.queryNotes({ tags: ["audio"] })).length).toBe(1);
3377
+ });
3378
+
3379
+ it("hierarchy is invalidated when parent_names is cleared", async () => {
3380
+ await store.createNote("voice note", { tags: ["voice"] });
3381
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3382
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
3383
+
3384
+ await store.upsertTagRecord("voice", { parent_names: null });
3385
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
3386
+ });
3387
+
3388
+ it("hierarchy is invalidated when a parent tag is deleted", async () => {
3389
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3390
+ await store.createNote("voice note", { tags: ["voice"] });
3391
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
3392
+
3393
+ // Drop the child tag — the row holding the parent_names declaration
3394
+ // disappears, so the hierarchy edge goes with it.
3395
+ await store.deleteTag("voice");
3396
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
3397
+ });
3398
+
3399
+ it("tagMatch=any flattens all expansions across input tags", async () => {
3400
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3401
+ await store.createNote("v", { tags: ["voice"] });
3402
+ await store.createNote("p", { tags: ["project"] });
3403
+ await store.createNote("o", { tags: ["other"] });
3404
+
3405
+ const results = await store.queryNotes({
3406
+ tags: ["manual", "project"],
3407
+ tagMatch: "any",
3408
+ });
3409
+ expect(results.map((n) => n.content).sort()).toEqual(["p", "v"]);
3410
+ });
3411
+
3412
+ it("tagMatch=all (default) requires each input tag's expanded set to be present", async () => {
3413
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3414
+ // Note has both #voice (which satisfies #manual via expansion) AND #project.
3415
+ await store.createNote("vp", { tags: ["voice", "project"] });
3416
+ await store.createNote("p-only", { tags: ["project"] });
3417
+
3418
+ const results = await store.queryNotes({
3419
+ tags: ["manual", "project"], // default tagMatch=all
3420
+ });
3421
+ expect(results.length).toBe(1);
3422
+ expect(results[0]!.content).toBe("vp");
3423
+ });
3424
+
3425
+ it("tolerates a cycle without infinite-looping", async () => {
3426
+ await store.upsertTagRecord("a", { parent_names: ["b"] });
3427
+ await store.upsertTagRecord("b", { parent_names: ["a"] });
3428
+ await store.createNote("note-a", { tags: ["a"] });
3429
+
3430
+ // Both a and b should resolve without hanging; both reach the same set {a, b}.
3431
+ expect((await store.queryNotes({ tags: ["a"] })).length).toBe(1);
3432
+ expect((await store.queryNotes({ tags: ["b"] })).length).toBe(1);
3433
+ });
3434
+
3435
+ it("malformed parent_names JSON is ignored silently", async () => {
3436
+ // Stuff a malformed value into the column directly to simulate an
3437
+ // out-of-band write. The resolver should drop it without throwing.
3438
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3439
+ (store as any).db.prepare("UPDATE tags SET parent_names = ? WHERE name = ?")
3440
+ .run("not valid json {{{", "voice");
3441
+ // Force cache reload.
3442
+ (store as any)._tagHierarchy = null;
3443
+ await store.createNote("v", { tags: ["voice"] });
3444
+
3445
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
3446
+ expect((await store.queryNotes({ tags: ["voice"] })).length).toBe(1);
3447
+ });
3448
+
3449
+ it("a tag with no parent_names is a hierarchy no-op", async () => {
3450
+ await store.upsertTagRecord("voice", { description: "voice notes" });
3451
+ await store.createNote("v", { tags: ["voice"] });
3452
+ await store.createNote("m", { tags: ["manual"] });
3453
+
3454
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
3455
+ expect((await store.queryNotes({ tags: ["voice"] })).length).toBe(1);
3456
+ });
3457
+
3458
+ it("legacy `_tags/<name>` notes left in place do not affect the hierarchy", async () => {
3459
+ // Post-v14, the resolver reads tags.parent_names — not notes. A leftover
3460
+ // `_tags/*` note from a pre-v14 vault is harmless historical record.
3461
+ await store.createNote("legacy", {
3462
+ path: "_tags/voice",
3463
+ metadata: { parents: ["manual"] },
3464
+ });
3465
+ await store.createNote("voice note", { tags: ["voice"] });
3466
+
3467
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
3468
+ });
3469
+ });
3470
+
3471
+ // ---------------------------------------------------------------------------
3472
+ // Schema validation — driven by `tags.fields` (post-v17, vault#267)
3473
+ // ---------------------------------------------------------------------------
3474
+ // Originally written against the `_schemas/<name>` + `_schema_defaults`
3475
+ // notes-as-config convention (issue #177); rewritten for vault#246 against
3476
+ // the standalone `note_schemas` + `schema_mappings` tables; rewritten again
3477
+ // for vault#267 against `tags.fields` after the standalone subsystem retired.
3478
+ // The validation surface is intentionally smaller now — schemas are tag-axis
3479
+ // only (no path_prefix) and advisory only (no `required` concept).
3480
+
3481
+ describe("schema validation (tags.fields)", async () => {
3482
+ it("returns no validation_status when no tag declares fields", async () => {
3483
+ const tools = generateMcpTools(store);
3484
+ const create = tools.find((t) => t.name === "create-note")!;
3485
+ const result = await create.execute({ content: "plain note" }) as any;
3486
+ expect(result.validation_status).toBeUndefined();
3487
+ });
3488
+
3489
+ it("returns no validation_status when no tag on the note declares fields", async () => {
3490
+ // A different tag has fields, but the note isn't tagged with it.
3491
+ await store.upsertTagSchema("task", {
3492
+ fields: { priority: { type: "string" } },
3493
+ });
3494
+
3495
+ const tools = generateMcpTools(store);
3496
+ const create = tools.find((t) => t.name === "create-note")!;
3497
+ const result = await create.execute({ content: "plain note", tags: ["other"] }) as any;
3498
+ expect(result.validation_status).toBeUndefined();
3499
+ });
3500
+
3501
+ it("validation passes (empty warnings) when fields match types", async () => {
3502
+ await store.upsertTagSchema("task", {
3503
+ fields: {
3504
+ priority: { type: "string", enum: ["high", "medium", "low"] },
3505
+ done: { type: "boolean" },
3506
+ },
3507
+ });
3508
+
3509
+ const tools = generateMcpTools(store);
3510
+ const create = tools.find((t) => t.name === "create-note")!;
3511
+ const result = await create.execute({
3512
+ content: "ok",
3513
+ tags: ["task"],
3514
+ metadata: { priority: "high", done: false },
3515
+ }) as any;
3516
+
3517
+ expect(result.validation_status.schemas).toEqual(["task"]);
3518
+ expect(result.validation_status.warnings).toEqual([]);
3519
+ });
3520
+
3521
+ it("type_mismatch warning when a field's value is the wrong type", async () => {
3522
+ await store.upsertTagSchema("task", {
3523
+ fields: { priority: { type: "string" }, done: { type: "boolean" } },
3524
+ });
3525
+
3526
+ const tools = generateMcpTools(store);
3527
+ const create = tools.find((t) => t.name === "create-note")!;
3528
+ const result = await create.execute({
3529
+ content: "x",
3530
+ tags: ["task"],
3531
+ metadata: { priority: "high", done: "yes" }, // wrong: should be boolean
3532
+ }) as any;
3533
+
3534
+ expect(result.validation_status.warnings.length).toBe(1);
3535
+ expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
3536
+ expect(result.validation_status.warnings[0].field).toBe("done");
3537
+ });
3538
+
3539
+ it("enum_mismatch warning when a field's value is outside the declared enum", async () => {
3540
+ await store.upsertTagSchema("task", {
3541
+ fields: { priority: { type: "string", enum: ["high", "medium", "low"] } },
3542
+ });
3543
+
3544
+ const tools = generateMcpTools(store);
3545
+ const create = tools.find((t) => t.name === "create-note")!;
3546
+ const result = await create.execute({
3547
+ content: "x",
3548
+ tags: ["task"],
3549
+ metadata: { priority: "ULTRA" },
3550
+ }) as any;
3551
+
3552
+ expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
3553
+ });
3554
+
3555
+ it("validation never blocks the write — note exists with warnings attached", async () => {
3556
+ await store.upsertTagSchema("task", {
3557
+ fields: { priority: { type: "boolean" } },
3558
+ });
3559
+
3560
+ const tools = generateMcpTools(store);
3561
+ const create = tools.find((t) => t.name === "create-note")!;
3562
+ const result = await create.execute({
3563
+ content: "x",
3564
+ tags: ["task"],
3565
+ metadata: { priority: "high" }, // wrong type, but the write still lands
3566
+ }) as any;
3567
+
3568
+ expect(result.id).toBeTruthy();
3569
+ expect(result.validation_status.warnings.length).toBe(1);
3570
+
3571
+ const fetched = await store.getNote(result.id);
3572
+ expect(fetched).not.toBeNull();
3573
+ expect(fetched!.content).toBe("x");
3574
+ });
3575
+
3576
+ it("update-note also surfaces validation_status", async () => {
3577
+ await store.upsertTagSchema("task", {
3578
+ fields: { priority: { type: "string", enum: ["high", "low"] } },
3579
+ });
3580
+ const note = await store.createNote("body", { tags: ["task"], metadata: { priority: "high" } });
3581
+
3582
+ const tools = generateMcpTools(store);
3583
+ const update = tools.find((t) => t.name === "update-note")!;
3584
+ const result = await update.execute({
3585
+ id: note.id,
3586
+ metadata: { priority: "ULTRA" },
3587
+ if_updated_at: note.updatedAt,
3588
+ }) as any;
3589
+
3590
+ expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
3591
+ });
3592
+
3593
+ it("cache invalidates when a tag schema is updated", async () => {
3594
+ await store.upsertTagSchema("task", {
3595
+ fields: { priority: { type: "boolean" } },
3596
+ });
3597
+
3598
+ const tools = generateMcpTools(store);
3599
+ const create = tools.find((t) => t.name === "create-note")!;
3600
+ let result = await create.execute({
3601
+ content: "x",
3602
+ tags: ["task"],
3603
+ metadata: { priority: "high" },
3604
+ }) as any;
3605
+ expect(result.validation_status.warnings[0].field).toBe("priority");
3606
+ expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
3607
+
3608
+ // Re-declare with a string type; cache must reflect the change.
3609
+ await store.upsertTagSchema("task", {
3610
+ fields: { priority: { type: "string" } },
3611
+ });
3612
+ result = await create.execute({
3613
+ content: "y",
3614
+ tags: ["task"],
3615
+ metadata: { priority: "high" },
3616
+ }) as any;
3617
+ expect(result.validation_status.warnings).toEqual([]);
3618
+ });
3619
+
3620
+ it("cache invalidates when a tag schema is deleted", async () => {
3621
+ await store.upsertTagSchema("task", {
3622
+ fields: { priority: { type: "boolean" } },
3623
+ });
3624
+
3625
+ const tools = generateMcpTools(store);
3626
+ const create = tools.find((t) => t.name === "create-note")!;
3627
+ let result = await create.execute({
3628
+ content: "x",
3629
+ tags: ["task"],
3630
+ metadata: { priority: "high" },
3631
+ }) as any;
3632
+ expect(result.validation_status.warnings.length).toBe(1);
3633
+
3634
+ await store.deleteTagSchema("task");
3635
+
3636
+ result = await create.execute({
3637
+ content: "y",
3638
+ tags: ["task"],
3639
+ metadata: { priority: "high" },
3640
+ }) as any;
3641
+ expect(result.validation_status).toBeUndefined();
3642
+ });
3643
+
3644
+ it("multiple tag schemas combine warnings", async () => {
3645
+ await store.upsertTagSchema("task", {
3646
+ fields: { priority: { type: "string" } },
3647
+ });
3648
+ await store.upsertTagSchema("project", {
3649
+ fields: { status: { type: "string", enum: ["active", "done"] } },
3650
+ });
3651
+
3652
+ const tools = generateMcpTools(store);
3653
+ const create = tools.find((t) => t.name === "create-note")!;
3654
+ const result = await create.execute({
3655
+ content: "x",
3656
+ tags: ["task", "project"],
3657
+ metadata: { priority: 7, status: "WIP" }, // bad: wrong type, bad enum
3658
+ }) as any;
3659
+
3660
+ expect(result.validation_status.schemas.sort()).toEqual(["project", "task"]);
3661
+ expect(result.validation_status.warnings.length).toBe(2);
3662
+ });
3663
+
3664
+ it("legacy `_schemas/<name>` notes are inert post-v17", async () => {
3665
+ // The notes still write/read fine — they're just no longer interpreted
3666
+ // as schema config. Nothing in tags.fields → no validation.
3667
+ await store.createNote("", {
3668
+ path: "_schemas/task",
3669
+ metadata: { fields: { priority: { type: "string" } } },
3670
+ });
3671
+ await store.createNote("", {
3672
+ path: "_schema_defaults",
3673
+ metadata: { tags: { task: "task" } },
3674
+ });
3675
+
3676
+ const tools = generateMcpTools(store);
3677
+ const create = tools.find((t) => t.name === "create-note")!;
3678
+ const result = await create.execute({ content: "x", tags: ["task"] }) as any;
3679
+ expect(result.validation_status).toBeUndefined();
3680
+ });
3681
+ });
3682
+
3683
+ // ---------------------------------------------------------------------------
3684
+ // Schema inheritance via parent_names + `_default` universal parent — vault#270
3685
+ // ---------------------------------------------------------------------------
3686
+
3687
+ describe("schema inheritance via parent_names (vault#270)", async () => {
3688
+ it("single-parent: child inherits parent's fields", async () => {
3689
+ await store.upsertTagRecord("work", {
3690
+ fields: { project: { type: "string" } },
3691
+ });
3692
+ await store.upsertTagRecord("task", {
3693
+ parent_names: ["work"],
3694
+ fields: { priority: { type: "string" } },
3695
+ });
3696
+
3697
+ const tools = generateMcpTools(store);
3698
+ const create = tools.find((t) => t.name === "create-note")!;
3699
+ const result = await create.execute({
3700
+ content: "x",
3701
+ tags: ["task"],
3702
+ metadata: { priority: 7, project: 42 }, // both wrong type
3703
+ }) as any;
3704
+
3705
+ expect(result.validation_status.warnings.length).toBe(2);
3706
+ const fields = result.validation_status.warnings.map((w: any) => w.field).sort();
3707
+ expect(fields).toEqual(["priority", "project"]);
3708
+ });
3709
+
3710
+ it("multi-parent: child gets union of parents' fields", async () => {
3711
+ await store.upsertTagRecord("work", {
3712
+ fields: { project: { type: "string" } },
3713
+ });
3714
+ await store.upsertTagRecord("publication", {
3715
+ fields: { venue: { type: "string" } },
3716
+ });
3717
+ await store.upsertTagRecord("paper", {
3718
+ parent_names: ["work", "publication"],
3719
+ fields: { title: { type: "string" } },
3720
+ });
3721
+
3722
+ const tools = generateMcpTools(store);
3723
+ const create = tools.find((t) => t.name === "create-note")!;
3724
+ const result = await create.execute({
3725
+ content: "x",
3726
+ tags: ["paper"],
3727
+ metadata: { title: 1, project: 2, venue: 3 }, // all wrong type
3728
+ }) as any;
3729
+
3730
+ expect(result.validation_status.warnings.length).toBe(3);
3731
+ const fields = result.validation_status.warnings.map((w: any) => w.field).sort();
3732
+ expect(fields).toEqual(["project", "title", "venue"]);
3733
+ });
3734
+
3735
+ it("diamond: A→B, A→C, B→D, C→D — D's field appears once", async () => {
3736
+ await store.upsertTagRecord("D", {
3737
+ fields: { d_field: { type: "string" } },
3738
+ });
3739
+ await store.upsertTagRecord("B", { parent_names: ["D"] });
3740
+ await store.upsertTagRecord("C", { parent_names: ["D"] });
3741
+ await store.upsertTagRecord("A", { parent_names: ["B", "C"] });
3742
+
3743
+ const tools = generateMcpTools(store);
3744
+ const create = tools.find((t) => t.name === "create-note")!;
3745
+ const result = await create.execute({
3746
+ content: "x",
3747
+ tags: ["A"],
3748
+ metadata: { d_field: 999 }, // wrong type
3749
+ }) as any;
3750
+
3751
+ expect(result.validation_status.warnings.length).toBe(1);
3752
+ expect(result.validation_status.warnings[0].field).toBe("d_field");
3753
+ expect(result.validation_status.warnings[0].schema).toBe("D");
3754
+ });
3755
+
3756
+ it("cycle: A→B, B→A — no infinite loop, both fields visible", async () => {
3757
+ await store.upsertTagRecord("A", {
3758
+ parent_names: ["B"],
3759
+ fields: { a_field: { type: "string" } },
3760
+ });
3761
+ await store.upsertTagRecord("B", {
3762
+ parent_names: ["A"],
3763
+ fields: { b_field: { type: "string" } },
3764
+ });
3765
+
3766
+ const tools = generateMcpTools(store);
3767
+ const create = tools.find((t) => t.name === "create-note")!;
3768
+ const result = await create.execute({
3769
+ content: "x",
3770
+ tags: ["A"],
3771
+ metadata: { a_field: 1, b_field: 2 }, // both wrong type
3772
+ }) as any;
3773
+
3774
+ expect(result.validation_status.warnings.length).toBe(2);
3775
+ const fields = result.validation_status.warnings.map((w: any) => w.field).sort();
3776
+ expect(fields).toEqual(["a_field", "b_field"]);
3777
+ });
3778
+
3779
+ it("override: child's spec wins over parent's for the same field name", async () => {
3780
+ await store.upsertTagRecord("parent_tag", {
3781
+ fields: { status: { type: "string", enum: ["a", "b"] } },
3782
+ });
3783
+ await store.upsertTagRecord("child_tag", {
3784
+ parent_names: ["parent_tag"],
3785
+ fields: { status: { type: "string", enum: ["x", "y"] } },
3786
+ });
3787
+
3788
+ const tools = generateMcpTools(store);
3789
+ const create = tools.find((t) => t.name === "create-note")!;
3790
+ const result = await create.execute({
3791
+ content: "x",
3792
+ tags: ["child_tag"],
3793
+ metadata: { status: "x" }, // valid under child, invalid under parent
3794
+ }) as any;
3795
+
3796
+ // Child's spec wins → "x" passes the enum check.
3797
+ const enumWarnings = result.validation_status.warnings.filter(
3798
+ (w: any) => w.reason === "enum_mismatch",
3799
+ );
3800
+ expect(enumWarnings.length).toBe(0);
3801
+ // The conflict surfaces as a schema_conflict warning whose `schema` field
3802
+ // names the *winning* tag (child).
3803
+ const conflict = result.validation_status.warnings.find(
3804
+ (w: any) => w.reason === "schema_conflict",
3805
+ );
3806
+ expect(conflict).toBeDefined();
3807
+ expect(conflict.field).toBe("status");
3808
+ expect(conflict.schema).toBe("child_tag");
3809
+ });
3810
+
3811
+ it("conflict warning: two parents declare same field with different specs, first wins", async () => {
3812
+ await store.upsertTagRecord("task", {
3813
+ fields: { status: { type: "string", enum: ["todo", "doing", "done"] } },
3814
+ });
3815
+ await store.upsertTagRecord("publication", {
3816
+ fields: { status: { type: "string", enum: ["draft", "published"] } },
3817
+ });
3818
+ // parent_names order = ["task", "publication"] → task wins.
3819
+ await store.upsertTagRecord("article_task", {
3820
+ parent_names: ["task", "publication"],
3821
+ });
3822
+
3823
+ const tools = generateMcpTools(store);
3824
+ const create = tools.find((t) => t.name === "create-note")!;
3825
+ const result = await create.execute({
3826
+ content: "x",
3827
+ tags: ["article_task"],
3828
+ metadata: { status: "todo" }, // valid under task (winner), invalid under publication
3829
+ }) as any;
3830
+
3831
+ const conflict = result.validation_status.warnings.find(
3832
+ (w: any) => w.reason === "schema_conflict",
3833
+ );
3834
+ expect(conflict).toBeDefined();
3835
+ expect(conflict.field).toBe("status");
3836
+ expect(conflict.schema).toBe("task"); // winner
3837
+ // No enum_mismatch — the value is valid under task's enum.
3838
+ const enumMismatch = result.validation_status.warnings.find(
3839
+ (w: any) => w.reason === "enum_mismatch",
3840
+ );
3841
+ expect(enumMismatch).toBeUndefined();
3842
+ });
3843
+
3844
+ it("`_default` universal parent: untagged note picks up `_default`'s schema", async () => {
3845
+ await store.upsertTagRecord("_default", {
3846
+ fields: { author: { type: "string" } },
3847
+ });
3848
+
3849
+ const tools = generateMcpTools(store);
3850
+ const create = tools.find((t) => t.name === "create-note")!;
3851
+ const result = await create.execute({
3852
+ content: "untagged note",
3853
+ metadata: { author: 42 }, // wrong type
3854
+ }) as any;
3855
+
3856
+ expect(result.validation_status.schemas).toEqual(["_default"]);
3857
+ expect(result.validation_status.warnings.length).toBe(1);
3858
+ expect(result.validation_status.warnings[0].field).toBe("author");
3859
+ });
3860
+
3861
+ it("`_default` universal parent: tagged note gets `_default` + its tag schema", async () => {
3862
+ await store.upsertTagRecord("_default", {
3863
+ fields: { author: { type: "string" } },
3864
+ });
3865
+ await store.upsertTagRecord("task", {
3866
+ fields: { priority: { type: "string" } },
3867
+ });
3868
+
3869
+ const tools = generateMcpTools(store);
3870
+ const create = tools.find((t) => t.name === "create-note")!;
3871
+ const result = await create.execute({
3872
+ content: "x",
3873
+ tags: ["task"],
3874
+ metadata: { author: 1, priority: 2 }, // both wrong type
3875
+ }) as any;
3876
+
3877
+ expect(result.validation_status.warnings.length).toBe(2);
3878
+ const schemas = new Set(
3879
+ result.validation_status.warnings.map((w: any) => w.schema),
3880
+ );
3881
+ expect(schemas.has("_default")).toBe(true);
3882
+ expect(schemas.has("task")).toBe(true);
3883
+ });
3884
+
3885
+ it("`_default` query expansion: query-notes { tag: '_default' } returns every note", async () => {
3886
+ await store.upsertTagRecord("_default", { description: "universal parent" });
3887
+ const a = await store.createNote("alpha", { tags: ["task"] });
3888
+ const b = await store.createNote("beta", { tags: ["project"] });
3889
+ const g = await store.createNote("gamma"); // untagged
3890
+
3891
+ const tools = generateMcpTools(store);
3892
+ const query = tools.find((t) => t.name === "query-notes")!;
3893
+ const result = await query.execute({ tag: "_default" }) as any;
3894
+
3895
+ expect(result.length).toBe(3);
3896
+ const ids = (result as { id: string }[]).map((n) => n.id).sort();
3897
+ expect(ids).toEqual([a.id, b.id, g.id].sort());
3898
+ });
3899
+
3900
+ it("missing parent: non-existent name in parent_names is silently skipped", async () => {
3901
+ await store.upsertTagRecord("task", {
3902
+ parent_names: ["nonexistent_tag"],
3903
+ fields: { priority: { type: "string" } },
3904
+ });
3905
+
3906
+ const tools = generateMcpTools(store);
3907
+ const create = tools.find((t) => t.name === "create-note")!;
3908
+ const result = await create.execute({
3909
+ content: "x",
3910
+ tags: ["task"],
3911
+ metadata: { priority: 999 }, // wrong type
3912
+ }) as any;
3913
+
3914
+ // No error. task's own field still validates.
3915
+ expect(result.validation_status.warnings.length).toBe(1);
3916
+ expect(result.validation_status.warnings[0].field).toBe("priority");
3917
+ });
3918
+
3919
+ it("`_default` deleted mid-session: cache invalidates, default behavior goes away", async () => {
3920
+ await store.upsertTagRecord("_default", {
3921
+ fields: { author: { type: "string" } },
3922
+ });
3923
+
3924
+ const tools = generateMcpTools(store);
3925
+ const create = tools.find((t) => t.name === "create-note")!;
3926
+ let result = await create.execute({
3927
+ content: "x",
3928
+ metadata: { author: 42 }, // wrong type
3929
+ }) as any;
3930
+ expect(result.validation_status.warnings.length).toBe(1);
3931
+
3932
+ await store.deleteTag("_default");
3933
+
3934
+ result = await create.execute({
3935
+ content: "y",
3936
+ metadata: { author: 42 },
3937
+ }) as any;
3938
+ expect(result.validation_status).toBeUndefined();
3939
+ });
3940
+
3941
+ it("`_default` + `tagMatch: 'any'` drops the tag filter entirely (every note matches)", async () => {
3942
+ // Folded from PR #272 review (N1). With OR-semantics, `_default` matches
3943
+ // everything → the disjunction collapses regardless of what else is in
3944
+ // the list. Pre-fold this would have narrowed to `task`-tagged notes.
3945
+ await store.upsertTagRecord("_default", { description: "universal parent" });
3946
+ const a = await store.createNote("alpha", { tags: ["task"] });
3947
+ const b = await store.createNote("beta", { tags: ["project"] });
3948
+ const g = await store.createNote("gamma"); // untagged
3949
+
3950
+ const results = await store.queryNotes({ tags: ["_default", "task"], tagMatch: "any" });
3951
+ const ids = results.map((n) => n.id).sort();
3952
+ expect(ids).toEqual([a.id, b.id, g.id].sort());
3953
+ });
3954
+
3955
+ it("`_default` + `tagMatch: 'all'` drops only `_default` from the AND-set", async () => {
3956
+ // Symmetric guard for N1: AND-semantics should NOT collapse — `_default`
3957
+ // is universally satisfied so it can be dropped, but other tags still
3958
+ // narrow the result set.
3959
+ await store.upsertTagRecord("_default", { description: "universal parent" });
3960
+ const a = await store.createNote("alpha", { tags: ["task"] });
3961
+ await store.createNote("beta", { tags: ["project"] });
3962
+ await store.createNote("gamma"); // untagged
3963
+
3964
+ const results = await store.queryNotes({ tags: ["_default", "task"], tagMatch: "all" });
3965
+ expect(results.length).toBe(1);
3966
+ expect(results[0].id).toBe(a.id);
3967
+ });
3968
+
3969
+ it("`searchNotes` with `_default` returns matches from every note (including untagged)", async () => {
3970
+ // Folded from PR #272 review (N2). FTS-backed search now short-circuits
3971
+ // the tag filter when `_default` is requested, matching `queryNotes`.
3972
+ await store.upsertTagRecord("_default", { description: "universal parent" });
3973
+ const a = await store.createNote("findme alpha", { tags: ["task"] });
3974
+ const b = await store.createNote("findme beta"); // untagged
3975
+
3976
+ const results = await store.searchNotes("findme", { tags: ["_default"] });
3977
+ const ids = results.map((n) => n.id).sort();
3978
+ expect(ids).toEqual([a.id, b.id].sort());
3979
+ });
3980
+
3981
+ it("`schema_conflict` warning carries structured `loser_schema`", async () => {
3982
+ // Folded from PR #272 review (N3). Agents shouldn't have to regex
3983
+ // `message` to find the overridden tag — surface it structurally.
3984
+ await store.upsertTagRecord("task", {
3985
+ fields: { status: { type: "string", enum: ["todo", "done"] } },
3986
+ });
3987
+ await store.upsertTagRecord("publication", {
3988
+ fields: { status: { type: "string", enum: ["draft", "published"] } },
3989
+ });
3990
+ await store.upsertTagRecord("article_task", {
3991
+ parent_names: ["task", "publication"],
3992
+ });
3993
+
3994
+ const tools = generateMcpTools(store);
3995
+ const create = tools.find((t) => t.name === "create-note")!;
3996
+ const result = await create.execute({
3997
+ content: "x",
3998
+ tags: ["article_task"],
3999
+ metadata: { status: "todo" },
4000
+ }) as any;
4001
+
4002
+ const conflict = result.validation_status.warnings.find(
4003
+ (w: any) => w.reason === "schema_conflict",
4004
+ );
4005
+ expect(conflict).toBeDefined();
4006
+ expect(conflict.schema).toBe("task"); // winner
4007
+ expect(conflict.loser_schema).toBe("publication"); // overridden
4008
+ });
4009
+
4010
+ it("non-conflict warnings (type/enum mismatch) don't carry `loser_schema`", async () => {
4011
+ // Symmetric guard for N3: `loser_schema` is only meaningful for
4012
+ // schema_conflict; absent on type/enum mismatches.
4013
+ await store.upsertTagSchema("task", {
4014
+ fields: { priority: { type: "string", enum: ["high", "low"] } },
4015
+ });
4016
+
4017
+ const tools = generateMcpTools(store);
4018
+ const create = tools.find((t) => t.name === "create-note")!;
4019
+ const result = await create.execute({
4020
+ content: "x",
4021
+ tags: ["task"],
4022
+ metadata: { priority: "ULTRA" },
4023
+ }) as any;
4024
+
4025
+ expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
4026
+ expect(result.validation_status.warnings[0].loser_schema).toBeUndefined();
4027
+ });
4028
+
4029
+ it("invalidates schema cache when only parent_names changes (no fields touched)", async () => {
4030
+ // Regression guard: pre-vault#270, parent_names changes only invalidated
4031
+ // the hierarchy cache. Now they must also invalidate the schema cache,
4032
+ // since inheritance walks parent chains at validation time.
4033
+ await store.upsertTagRecord("base", {
4034
+ fields: { tier: { type: "string" } },
4035
+ });
4036
+ await store.upsertTagRecord("derived", { description: "starts orphaned" });
4037
+
4038
+ const tools = generateMcpTools(store);
4039
+ const create = tools.find((t) => t.name === "create-note")!;
4040
+ let result = await create.execute({
4041
+ content: "x",
4042
+ tags: ["derived"],
4043
+ metadata: { tier: 1 },
4044
+ }) as any;
4045
+ expect(result.validation_status).toBeUndefined();
4046
+
4047
+ // Wire up inheritance — fields *not* touched, only parent_names.
4048
+ await store.upsertTagRecord("derived", { parent_names: ["base"] });
4049
+
4050
+ result = await create.execute({
4051
+ content: "y",
4052
+ tags: ["derived"],
4053
+ metadata: { tier: 1 }, // wrong type per base.tier
4054
+ }) as any;
4055
+ expect(result.validation_status.warnings.length).toBe(1);
4056
+ expect(result.validation_status.warnings[0].field).toBe("tier");
4057
+ expect(result.validation_status.warnings[0].schema).toBe("base");
4058
+ });
4059
+ });
4060
+
4061
+ describe("expandTagsWithDescendants (tag-scoped tokens — patterns/tag-scoped-tokens.md)", async () => {
4062
+ it("returns the union of root + every descendant per tags.parent_names", async () => {
4063
+ await store.upsertTagRecord("health/food", { parent_names: ["health"] });
4064
+ await store.upsertTagRecord("health/food/breakfast", { parent_names: ["health/food"] });
4065
+ await store.upsertTagRecord("work", { description: "work things" });
4066
+
4067
+ const expanded = await store.expandTagsWithDescendants(["health"]);
4068
+ expect(expanded.has("health")).toBe(true);
4069
+ expect(expanded.has("health/food")).toBe(true);
4070
+ expect(expanded.has("health/food/breakfast")).toBe(true);
4071
+ expect(expanded.has("work")).toBe(false);
4072
+ });
4073
+
4074
+ it("returns an empty set for an empty input (no allowlist = nothing to expand)", async () => {
4075
+ const expanded = await store.expandTagsWithDescendants([]);
4076
+ expect(expanded.size).toBe(0);
4077
+ });
4078
+
4079
+ it("includes the root verbatim even when the tag has no declared descendants", async () => {
4080
+ await store.createNote("solo", { tags: ["loner"] });
4081
+ const expanded = await store.expandTagsWithDescendants(["loner"]);
4082
+ expect([...expanded]).toEqual(["loner"]);
4083
+ });
4084
+
4085
+ it("unions descendants from multiple roots", async () => {
4086
+ await store.upsertTagRecord("health/food", { parent_names: ["health"] });
4087
+ await store.upsertTagRecord("work/standup", { parent_names: ["work"] });
4088
+ const expanded = await store.expandTagsWithDescendants(["health", "work"]);
4089
+ expect(expanded.has("health")).toBe(true);
4090
+ expect(expanded.has("health/food")).toBe(true);
4091
+ expect(expanded.has("work")).toBe(true);
4092
+ expect(expanded.has("work/standup")).toBe(true);
4093
+ });
4094
+ });
4095
+
4096
+ // ---------------------------------------------------------------------------
4097
+ // Tag record API — patterns/tag-data-model.md
4098
+ // ---------------------------------------------------------------------------
4099
+
4100
+ describe("tag record API (patterns/tag-data-model.md)", async () => {
4101
+ it("upsertTagRecord persists description + fields + relationships + parent_names", async () => {
4102
+ await store.upsertTagRecord("project", {
4103
+ description: "long-running deliverable",
4104
+ fields: { status: { type: "string", enum: ["active", "shipped"] } },
4105
+ relationships: {
4106
+ owned_by: { target_tag: "person", cardinality: "one", description: "DRI" },
4107
+ },
4108
+ parent_names: ["work"],
4109
+ });
4110
+ const r = await store.getTagRecord("project");
4111
+ expect(r?.description).toBe("long-running deliverable");
4112
+ expect(r?.fields?.status?.type).toBe("string");
4113
+ expect(r?.relationships?.owned_by?.target_tag).toBe("person");
4114
+ expect(r?.relationships?.owned_by?.cardinality).toBe("one");
4115
+ expect(r?.parent_names).toEqual(["work"]);
4116
+ expect(r?.created_at).toBeDefined();
4117
+ expect(r?.updated_at).toBeDefined();
4118
+ });
4119
+
4120
+ it("upsertTagRecord preserves columns left undefined in the patch", async () => {
4121
+ await store.upsertTagRecord("project", {
4122
+ description: "first",
4123
+ fields: { status: { type: "string" } },
4124
+ parent_names: ["work"],
4125
+ });
4126
+ await store.upsertTagRecord("project", { description: "second" });
4127
+ const r = await store.getTagRecord("project");
4128
+ expect(r?.description).toBe("second");
4129
+ expect(r?.fields?.status?.type).toBe("string");
4130
+ expect(r?.parent_names).toEqual(["work"]);
4131
+ });
4132
+
4133
+ it("upsertTagRecord clears a column when patch passes null", async () => {
4134
+ await store.upsertTagRecord("project", {
4135
+ description: "deliverable",
4136
+ parent_names: ["work"],
4137
+ });
4138
+ await store.upsertTagRecord("project", { parent_names: null });
4139
+ const r = await store.getTagRecord("project");
4140
+ expect(r?.description).toBe("deliverable");
4141
+ expect(r?.parent_names).toBeUndefined();
4142
+ });
4143
+
4144
+ it("listTagRecords returns every tag row, sorted by name", async () => {
4145
+ await store.upsertTagRecord("zebra", { description: "z" });
4146
+ await store.upsertTagRecord("alpha", { description: "a" });
4147
+ const records = await store.listTagRecords();
4148
+ const names = records.map((r) => r.tag);
4149
+ const idxAlpha = names.indexOf("alpha");
4150
+ const idxZebra = names.indexOf("zebra");
4151
+ expect(idxAlpha).toBeGreaterThanOrEqual(0);
4152
+ expect(idxZebra).toBeGreaterThan(idxAlpha);
4153
+ });
4154
+
4155
+ it("update-tag MCP rejects an invalid cardinality", async () => {
4156
+ const tools = generateMcpTools(store);
4157
+ const update = tools.find((t) => t.name === "update-tag")!;
4158
+ await expect(
4159
+ update.execute({
4160
+ tag: "project",
4161
+ relationships: {
4162
+ owned_by: { target_tag: "person", cardinality: "bogus" },
4163
+ },
4164
+ }),
4165
+ ).rejects.toThrow(/cardinality/);
4166
+ });
4167
+
4168
+ it("update-tag MCP accepts every cardinality in the named vocabulary", async () => {
4169
+ const tools = generateMcpTools(store);
4170
+ const update = tools.find((t) => t.name === "update-tag")!;
4171
+ for (const card of ["one", "optional", "many", "many-required"]) {
4172
+ await update.execute({
4173
+ tag: `tag-${card}`,
4174
+ relationships: {
4175
+ rel: { target_tag: "other", cardinality: card },
4176
+ },
4177
+ });
4178
+ const r = await store.getTagRecord(`tag-${card}`);
4179
+ expect(r?.relationships?.rel?.cardinality).toBe(card);
4180
+ }
4181
+ });
4182
+
4183
+ it("update-tag MCP rejects a relationship missing target_tag", async () => {
4184
+ const tools = generateMcpTools(store);
4185
+ const update = tools.find((t) => t.name === "update-tag")!;
4186
+ await expect(
4187
+ update.execute({
4188
+ tag: "project",
4189
+ relationships: { owned_by: { cardinality: "one" } },
4190
+ }),
4191
+ ).rejects.toThrow(/target_tag/);
4192
+ });
4193
+
4194
+ it("update-tag MCP sets parent_names and the hierarchy invalidates", async () => {
4195
+ const tools = generateMcpTools(store);
4196
+ const update = tools.find((t) => t.name === "update-tag")!;
4197
+
4198
+ await store.createNote("v note", { tags: ["voice"] });
4199
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
4200
+
4201
+ await update.execute({
4202
+ tag: "voice",
4203
+ parent_names: ["manual"],
4204
+ });
4205
+
4206
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
4207
+ });
4208
+
4209
+ it("update-tag MCP empty parent_names array clears the column", async () => {
4210
+ const tools = generateMcpTools(store);
4211
+ const update = tools.find((t) => t.name === "update-tag")!;
4212
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
4213
+ await update.execute({ tag: "voice", parent_names: [] });
4214
+ const r = await store.getTagRecord("voice");
4215
+ expect(r?.parent_names).toBeUndefined();
4216
+ });
4217
+
4218
+ it("list-tags MCP single-tag detail includes relationships + parent_names", async () => {
4219
+ await store.upsertTagRecord("project", {
4220
+ description: "p",
4221
+ relationships: { owned_by: { target_tag: "person", cardinality: "one" } },
4222
+ parent_names: ["work"],
4223
+ });
4224
+ const tools = generateMcpTools(store);
4225
+ const listTags = tools.find((t) => t.name === "list-tags")!;
4226
+ const result = await listTags.execute({ tag: "project" }) as any;
4227
+ expect(result.relationships?.owned_by?.target_tag).toBe("person");
4228
+ expect(result.parent_names).toEqual(["work"]);
4229
+ expect(result.created_at).toBeDefined();
4230
+ });
4231
+
4232
+ it("list-tags MCP include_schema returns relationships + parent_names per tag", async () => {
4233
+ await store.upsertTagRecord("project", {
4234
+ relationships: { owned_by: { target_tag: "person", cardinality: "one" } },
4235
+ parent_names: ["work"],
4236
+ });
4237
+ await store.createNote("p note", { tags: ["project"] });
4238
+ const tools = generateMcpTools(store);
4239
+ const listTags = tools.find((t) => t.name === "list-tags")!;
4240
+ const all = await listTags.execute({ include_schema: true }) as any[];
4241
+ const project = all.find((t) => t.name === "project")!;
4242
+ expect(project.relationships?.owned_by?.target_tag).toBe("person");
4243
+ expect(project.parent_names).toEqual(["work"]);
4244
+ });
4245
+
4246
+ it("renameTag carries description + fields + relationships + parent_names onto the new row", async () => {
4247
+ await store.upsertTagRecord("old-name", {
4248
+ description: "before",
4249
+ fields: { status: { type: "string" } },
4250
+ relationships: { owned_by: { target_tag: "person", cardinality: "one" } },
4251
+ parent_names: ["work"],
4252
+ });
4253
+ const result = await store.renameTag("old-name", "new-name");
4254
+ expect("renamed" in result).toBe(true);
4255
+
4256
+ const renamed = await store.getTagRecord("new-name");
4257
+ expect(renamed?.description).toBe("before");
4258
+ expect(renamed?.fields?.status?.type).toBe("string");
4259
+ expect(renamed?.relationships?.owned_by?.target_tag).toBe("person");
4260
+ expect(renamed?.parent_names).toEqual(["work"]);
4261
+
4262
+ const old = await store.getTagRecord("old-name");
4263
+ expect(old).toBeNull();
4264
+ });
4265
+
4266
+ it("deleteTag drops the identity row + invalidates the hierarchy", async () => {
4267
+ await store.upsertTagRecord("voice", {
4268
+ description: "voice notes",
4269
+ parent_names: ["manual"],
4270
+ });
4271
+ await store.createNote("v", { tags: ["voice"] });
4272
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
4273
+
4274
+ await store.deleteTag("voice");
4275
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
4276
+ expect(await store.getTagRecord("voice")).toBeNull();
4277
+ });
4278
+ });
4279
+
4280
+ // ---------------------------------------------------------------------------
4281
+ // Schema migration v13 → v14 — patterns/tag-data-model.md
4282
+ // ---------------------------------------------------------------------------
4283
+
4284
+ describe("schema migration v13 → v14", async () => {
4285
+ it("backfills tags.parent_names from `_tags/<name>` notes", async () => {
4286
+ // Simulate a pre-v14 vault by writing a `_tags/<name>` note + the
4287
+ // legacy tag_schemas row directly via a fresh DB at v13 shape.
4288
+ const { Database } = await import("bun:sqlite");
4289
+ const db = new Database(":memory:");
4290
+
4291
+ // Build the v13 shape inline: tags(name PK only), separate tag_schemas
4292
+ // table, plus a notes row at `_tags/voice`.
4293
+ db.exec("PRAGMA journal_mode = WAL");
4294
+ db.exec(`CREATE TABLE notes (
4295
+ id TEXT PRIMARY KEY, content TEXT DEFAULT '', path TEXT,
4296
+ metadata TEXT DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT
4297
+ )`);
4298
+ db.exec(`CREATE TABLE tags (name TEXT PRIMARY KEY)`);
4299
+ db.exec(`CREATE TABLE tag_schemas (
4300
+ tag_name TEXT PRIMARY KEY REFERENCES tags(name) ON DELETE CASCADE,
4301
+ description TEXT, fields TEXT
4302
+ )`);
4303
+
4304
+ db.prepare("INSERT INTO tags (name) VALUES (?)").run("voice");
4305
+ db.prepare("INSERT INTO tag_schemas (tag_name, description, fields) VALUES (?, ?, ?)")
4306
+ .run("voice", "voice notes", '{"recorded_at":{"type":"string"}}');
4307
+ db.prepare(`INSERT INTO notes (id, path, metadata, created_at) VALUES (?, ?, ?, ?)`)
4308
+ .run("n1", "_tags/voice", JSON.stringify({ parents: ["manual"] }), new Date().toISOString());
4309
+
4310
+ // Now run initSchema — it should add the v14 columns, copy schema +
4311
+ // hierarchy data onto the tags row, and drop tag_schemas.
4312
+ const { initSchema } = await import("./schema.ts");
4313
+ initSchema(db);
4314
+
4315
+ // tag_schemas should be gone.
4316
+ const tableExists = db.prepare(
4317
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='tag_schemas'",
4318
+ ).get();
4319
+ expect(tableExists).toBeNull();
4320
+
4321
+ // tags row should carry the migrated fields.
4322
+ const row = db.prepare(
4323
+ "SELECT name, description, fields, parent_names FROM tags WHERE name = 'voice'",
4324
+ ).get() as any;
4325
+ expect(row.description).toBe("voice notes");
4326
+ expect(JSON.parse(row.fields).recorded_at.type).toBe("string");
4327
+ expect(JSON.parse(row.parent_names)).toEqual(["manual"]);
4328
+
4329
+ // The `_tags/voice` note is left in place as harmless historical record.
4330
+ const note = db.prepare("SELECT id FROM notes WHERE path = '_tags/voice'").get();
4331
+ expect(note).toBeDefined();
4332
+
4333
+ db.close();
4334
+ });
4335
+
4336
+ it("is idempotent — running initSchema twice is a no-op the second time", async () => {
4337
+ const { Database } = await import("bun:sqlite");
4338
+ const { initSchema } = await import("./schema.ts");
4339
+ const db = new Database(":memory:");
4340
+ initSchema(db);
4341
+ db.prepare(`
4342
+ INSERT INTO tags (name, description, parent_names, created_at, updated_at)
4343
+ VALUES (?, ?, ?, ?, ?)
4344
+ `).run(
4345
+ "voice",
4346
+ "voice notes",
4347
+ JSON.stringify(["manual"]),
4348
+ new Date().toISOString(),
4349
+ new Date().toISOString(),
4350
+ );
4351
+
4352
+ // Second run must not throw, must not perturb the row, must not
4353
+ // reintroduce tag_schemas.
4354
+ initSchema(db);
4355
+
4356
+ const row = db.prepare("SELECT description, parent_names FROM tags WHERE name = 'voice'").get() as any;
4357
+ expect(row.description).toBe("voice notes");
4358
+ expect(JSON.parse(row.parent_names)).toEqual(["manual"]);
4359
+
4360
+ const tableExists = db.prepare(
4361
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='tag_schemas'",
4362
+ ).get();
4363
+ expect(tableExists).toBeNull();
4364
+ db.close();
4365
+ });
4366
+
4367
+ // vault#248 — the migration body is wrapped in BEGIN IMMEDIATE / COMMIT
4368
+ // with a try/catch ROLLBACK. A crash mid-migration must leave the DB in
4369
+ // its pre-migration state (NOT half-migrated), and a retry must converge
4370
+ // to the same final state as a clean run. The transaction wrap is what
4371
+ // makes that guarantee — the `hasColumn` / `hasTable` idempotency guards
4372
+ // are belt-and-suspenders, not load-bearing.
4373
+ it("crash mid-migration rolls back to pre-migration state, then retry succeeds", async () => {
4374
+ const { Database } = await import("bun:sqlite");
4375
+ const { initSchema } = await import("./schema.ts");
4376
+
4377
+ const db = new Database(":memory:");
4378
+ db.exec("PRAGMA journal_mode = WAL");
4379
+ db.exec(`CREATE TABLE notes (
4380
+ id TEXT PRIMARY KEY, content TEXT DEFAULT '', path TEXT,
4381
+ metadata TEXT DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT
4382
+ )`);
4383
+ db.exec(`CREATE TABLE tags (name TEXT PRIMARY KEY)`);
4384
+ db.exec(`CREATE TABLE tag_schemas (
4385
+ tag_name TEXT PRIMARY KEY REFERENCES tags(name) ON DELETE CASCADE,
4386
+ description TEXT, fields TEXT
4387
+ )`);
4388
+
4389
+ db.prepare("INSERT INTO tags (name) VALUES (?)").run("voice");
4390
+ db.prepare("INSERT INTO tag_schemas (tag_name, description, fields) VALUES (?, ?, ?)")
4391
+ .run("voice", "voice notes", '{"recorded_at":{"type":"string"}}');
4392
+ db.prepare(`INSERT INTO notes (id, path, metadata, created_at) VALUES (?, ?, ?, ?)`)
4393
+ .run("n1", "_tags/voice", JSON.stringify({ parents: ["manual"] }), new Date().toISOString());
4394
+
4395
+ // Patch db.exec to simulate a crash on the final DROP TABLE step. That's
4396
+ // the right injection point: every ALTER + data copy has already landed
4397
+ // inside the transaction, so a successful rollback proves the wrap
4398
+ // covers the full migration body — not just the tail.
4399
+ const origExec = db.exec.bind(db);
4400
+ let crashOnDrop: boolean = true;
4401
+ (db as any).exec = function (sql: string) {
4402
+ if (crashOnDrop && sql.includes("DROP TABLE tag_schemas")) {
4403
+ throw new Error("simulated crash mid-migration");
4404
+ }
4405
+ return origExec(sql);
4406
+ };
4407
+
4408
+ expect(() => initSchema(db)).toThrow("simulated crash mid-migration");
4409
+
4410
+ // Pre-migration shape: tag_schemas table still exists with its row,
4411
+ // tags table back to (name) only — none of the v14 columns landed,
4412
+ // `_tags/voice` note untouched, schema_version row not yet written.
4413
+ const tagSchemasStill = db.prepare(
4414
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='tag_schemas'",
4415
+ ).get();
4416
+ expect(tagSchemasStill).toBeTruthy();
4417
+ const schemaRow = db.prepare(
4418
+ "SELECT description, fields FROM tag_schemas WHERE tag_name = 'voice'",
4419
+ ).get() as any;
4420
+ expect(schemaRow.description).toBe("voice notes");
4421
+
4422
+ const tagsCols = db.prepare("PRAGMA table_info(tags)").all() as { name: string }[];
4423
+ const colNames = tagsCols.map((c) => c.name).sort();
4424
+ expect(colNames).toEqual(["name"]);
4425
+
4426
+ const note = db.prepare("SELECT path, metadata FROM notes WHERE id = 'n1'").get() as any;
4427
+ expect(note.path).toBe("_tags/voice");
4428
+ expect(JSON.parse(note.metadata).parents).toEqual(["manual"]);
4429
+
4430
+ // No lingering open transaction — a SELECT after rollback succeeds, and
4431
+ // a fresh BEGIN IMMEDIATE doesn't fail with "cannot start a transaction
4432
+ // within a transaction".
4433
+ db.exec("BEGIN IMMEDIATE");
4434
+ db.exec("ROLLBACK");
4435
+
4436
+ // Retry: drop the crash injection, run initSchema again. It must
4437
+ // converge to the same final post-v14 state as a clean run.
4438
+ crashOnDrop = false;
4439
+ initSchema(db);
4440
+
4441
+ const tableGone = db.prepare(
4442
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='tag_schemas'",
4443
+ ).get();
4444
+ expect(tableGone).toBeNull();
4445
+ const post = db.prepare(
4446
+ "SELECT description, fields, parent_names FROM tags WHERE name = 'voice'",
4447
+ ).get() as any;
4448
+ expect(post.description).toBe("voice notes");
4449
+ expect(JSON.parse(post.fields).recorded_at.type).toBe("string");
4450
+ expect(JSON.parse(post.parent_names)).toEqual(["manual"]);
4451
+
4452
+ db.close();
4453
+ });
4454
+ });
4455
+
4456
+ // ---------------------------------------------------------------------------
4457
+ // Schema migration v16 → v17 — vault#267 (note_schemas + schema_mappings rip)
4458
+ // ---------------------------------------------------------------------------
4459
+
4460
+ describe("schema migration v16 → v17", async () => {
4461
+ // Build a v16-shape DB with the standalone note_schemas + schema_mappings
4462
+ // tables and a couple of rows, then run initSchema again. The v17 migration
4463
+ // should drop both tables.
4464
+ async function buildV16ShapeWithLegacyTables(): Promise<Database> {
4465
+ const { Database } = await import("bun:sqlite");
4466
+ const db = new Database(":memory:");
4467
+
4468
+ // Create the v16-era schema fragments by hand. We can't call the post-v17
4469
+ // initSchema to do this — SCHEMA_SQL no longer creates the dropped
4470
+ // tables. Build them manually here so the migration test exercises the
4471
+ // "upgrading from v16" path.
4472
+ db.exec("PRAGMA journal_mode = WAL");
4473
+ db.exec("PRAGMA foreign_keys = ON");
4474
+ db.exec(`CREATE TABLE note_schemas (
4475
+ name TEXT PRIMARY KEY,
4476
+ description TEXT,
4477
+ fields TEXT,
4478
+ required TEXT,
4479
+ created_at TEXT,
4480
+ updated_at TEXT
4481
+ )`);
4482
+ db.exec(`CREATE TABLE schema_mappings (
4483
+ schema_name TEXT NOT NULL REFERENCES note_schemas(name) ON DELETE CASCADE,
4484
+ match_kind TEXT NOT NULL CHECK (match_kind IN ('path_prefix', 'tag')),
4485
+ match_value TEXT NOT NULL,
4486
+ PRIMARY KEY (schema_name, match_kind, match_value)
4487
+ )`);
4488
+ db.exec("CREATE INDEX idx_schema_mappings_match ON schema_mappings(match_kind, match_value)");
4489
+
4490
+ const now = new Date().toISOString();
4491
+ db.prepare(
4492
+ "INSERT INTO note_schemas (name, description, fields, required, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
4493
+ ).run("task", "A task", '{"priority":{"type":"string"}}', '["priority"]', now, now);
4494
+ db.prepare(
4495
+ "INSERT INTO note_schemas (name, created_at, updated_at) VALUES (?, ?, ?)",
4496
+ ).run("journal-entry", now, now);
4497
+ db.prepare(
4498
+ "INSERT INTO schema_mappings (schema_name, match_kind, match_value) VALUES (?, ?, ?)",
4499
+ ).run("task", "tag", "task");
4500
+ db.prepare(
4501
+ "INSERT INTO schema_mappings (schema_name, match_kind, match_value) VALUES (?, ?, ?)",
4502
+ ).run("journal-entry", "path_prefix", "journal/");
4503
+
4504
+ return db;
4505
+ }
4506
+
4507
+ it("drops note_schemas + schema_mappings tables on upgrade", async () => {
4508
+ const db = await buildV16ShapeWithLegacyTables();
4509
+ const { initSchema } = await import("./schema.ts");
4510
+ initSchema(db);
4511
+
4512
+ const tables = db.prepare(
4513
+ "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('note_schemas','schema_mappings')",
4514
+ ).all() as { name: string }[];
4515
+ expect(tables).toEqual([]);
4516
+
4517
+ db.close();
4518
+ });
4519
+
4520
+ it("idempotent — running on an already-v17 vault is a no-op", async () => {
4521
+ const { Database } = await import("bun:sqlite");
4522
+ const { initSchema } = await import("./schema.ts");
4523
+ const db = new Database(":memory:");
4524
+ initSchema(db); // First run: fresh v17 shape.
4525
+
4526
+ // Sanity: the dropped tables don't exist on a fresh vault.
4527
+ let tables = db.prepare(
4528
+ "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('note_schemas','schema_mappings')",
4529
+ ).all() as { name: string }[];
4530
+ expect(tables).toEqual([]);
4531
+
4532
+ // Second run shouldn't crash and the tables stay absent.
4533
+ initSchema(db);
4534
+ tables = db.prepare(
4535
+ "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('note_schemas','schema_mappings')",
4536
+ ).all() as { name: string }[];
4537
+ expect(tables).toEqual([]);
4538
+
4539
+ db.close();
4540
+ });
4541
+
4542
+ it("preserves notes + tags + tokens across the rip", async () => {
4543
+ const db = await buildV16ShapeWithLegacyTables();
4544
+
4545
+ // Bring the rest of the schema up to v16 baseline so notes/tags/tokens
4546
+ // exist, then re-run initSchema (which finishes the v17 migration).
4547
+ const { initSchema } = await import("./schema.ts");
4548
+ initSchema(db);
4549
+
4550
+ // Populate some unrelated state and re-run; nothing else should move.
4551
+ await import("./store.ts").then(async ({ SqliteStore }) => {
4552
+ const s = new SqliteStore(db);
4553
+ await s.createNote("hello", { id: "n1", tags: ["task"] });
4554
+ await s.upsertTagRecord("task", { description: "still here" });
4555
+ });
4556
+ initSchema(db);
4557
+
4558
+ const note = db.prepare("SELECT id, content FROM notes WHERE id = ?").get("n1") as any;
4559
+ expect(note?.content).toBe("hello");
4560
+ const tag = db.prepare("SELECT description FROM tags WHERE name = ?").get("task") as any;
4561
+ expect(tag?.description).toBe("still here");
4562
+
4563
+ db.close();
4564
+ });
4565
+ });
4566
+
4567
+ // ---------------------------------------------------------------------------
4568
+ // Schema migration v15 → v16 — vault#257 (tokens.vault_name binding)
4569
+ // ---------------------------------------------------------------------------
4570
+
4571
+ describe("schema migration v15 → v16", async () => {
4572
+ // vault#257 — the v16 migration body (ALTER TABLE ADD COLUMN + CREATE
4573
+ // INDEX) is wrapped in BEGIN IMMEDIATE / COMMIT with a try/catch
4574
+ // ROLLBACK, mirroring the v14/v15 wrap shape from vault#251. The
4575
+ // individual statements are atomic in SQLite, so the wrap is mostly
4576
+ // belt-and-suspenders for THIS migration — but the test mirrors v14's
4577
+ // crash-rollback shape so anyone touching migrations finds the same
4578
+ // regression-pin pattern across versions.
4579
+ it("crash mid-migration rolls back to pre-v16 state, then retry succeeds", async () => {
4580
+ const { Database } = await import("bun:sqlite");
4581
+ const { initSchema } = await import("./schema.ts");
4582
+
4583
+ // Build the full post-v16 shape, plant a row, then drop the v16
4584
+ // additions so initSchema's migrateToV16 fires on the next call.
4585
+ const db = new Database(":memory:");
4586
+ db.exec("PRAGMA journal_mode = WAL");
4587
+ initSchema(db);
4588
+
4589
+ const now = new Date().toISOString();
4590
+ db.prepare(
4591
+ "INSERT INTO tokens (token_hash, label, created_at, vault_name) VALUES (?, ?, ?, ?)",
4592
+ ).run("sha256:abc123def456", "pre-existing", now, "work");
4593
+
4594
+ db.exec("DROP INDEX IF EXISTS idx_tokens_vault_name");
4595
+ db.exec("ALTER TABLE tokens DROP COLUMN vault_name");
4596
+
4597
+ // Pre-condition: the column is gone but the row is still there
4598
+ // (DROP COLUMN strips the column from existing rows).
4599
+ const preCols = db.prepare("PRAGMA table_info(tokens)").all() as { name: string }[];
4600
+ expect(preCols.map((c) => c.name)).not.toContain("vault_name");
4601
+ const preRow = db.prepare("SELECT label FROM tokens WHERE token_hash = ?")
4602
+ .get("sha256:abc123def456") as { label: string } | null;
4603
+ expect(preRow?.label).toBe("pre-existing");
4604
+
4605
+ // Patch db.exec to crash on CREATE INDEX — the second statement inside
4606
+ // the v16 transaction body. Crashing here proves the wrap covers the
4607
+ // post-ALTER state, not just the tail. The injection deliberately
4608
+ // doesn't match BEGIN/COMMIT/ROLLBACK so the catch's ROLLBACK still
4609
+ // runs through the patched exec.
4610
+ const origExec = db.exec.bind(db);
4611
+ let crashOnIndex: boolean = true;
4612
+ (db as any).exec = function (sql: string) {
4613
+ if (crashOnIndex && sql.includes("CREATE INDEX") && sql.includes("idx_tokens_vault_name")) {
4614
+ throw new Error("simulated crash mid-v16-migration");
4615
+ }
4616
+ return origExec(sql);
4617
+ };
4618
+
4619
+ expect(() => initSchema(db)).toThrow("simulated crash mid-v16-migration");
4620
+
4621
+ // Pre-v16 shape after rollback: vault_name column must not exist; the
4622
+ // pre-existing row must be untouched.
4623
+ const colsAfterRollback = db.prepare("PRAGMA table_info(tokens)").all() as { name: string }[];
4624
+ expect(colsAfterRollback.map((c) => c.name)).not.toContain("vault_name");
4625
+ const idxAfterRollback = db.prepare(
4626
+ "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_tokens_vault_name'",
4627
+ ).get();
4628
+ expect(idxAfterRollback).toBeNull();
4629
+ const rowAfterRollback = db.prepare("SELECT label FROM tokens WHERE token_hash = ?")
4630
+ .get("sha256:abc123def456") as { label: string } | null;
4631
+ expect(rowAfterRollback?.label).toBe("pre-existing");
4632
+
4633
+ // No lingering open transaction — a fresh BEGIN IMMEDIATE + ROLLBACK
4634
+ // doesn't fail with "cannot start a transaction within a transaction".
4635
+ db.exec("BEGIN IMMEDIATE");
4636
+ db.exec("ROLLBACK");
4637
+
4638
+ // Retry: drop the crash injection, run initSchema again. Must converge
4639
+ // to post-v16 shape (column added, index created, lenient NULL backfill
4640
+ // on the pre-existing row per the migration spec).
4641
+ crashOnIndex = false;
4642
+ initSchema(db);
4643
+
4644
+ const colsPost = db.prepare("PRAGMA table_info(tokens)").all() as { name: string }[];
4645
+ expect(colsPost.map((c) => c.name)).toContain("vault_name");
4646
+ const idxPost = db.prepare(
4647
+ "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_tokens_vault_name'",
4648
+ ).get();
4649
+ expect(idxPost).toBeTruthy();
4650
+ const rowPost = db.prepare("SELECT label, vault_name FROM tokens WHERE token_hash = ?")
4651
+ .get("sha256:abc123def456") as { label: string; vault_name: string | null };
4652
+ expect(rowPost.label).toBe("pre-existing");
4653
+ expect(rowPost.vault_name).toBeNull();
4654
+
4655
+ db.close();
4656
+ });
4657
+ });
4658
+
4659
+ // ---------------------------------------------------------------------------
4660
+ // Tag-scope auth post-v14 — patterns/tag-scoped-tokens.md
4661
+ // ---------------------------------------------------------------------------
4662
+
4663
+ describe("tag-scope auth (post-v14 hierarchy)", async () => {
4664
+ it("token allowlisted for `health` matches descendants declared via parent_names", async () => {
4665
+ await store.upsertTagRecord("health/food", { parent_names: ["health"] });
4666
+ await store.upsertTagRecord("health/food/breakfast", { parent_names: ["health/food"] });
4667
+
4668
+ const expanded = await store.expandTagsWithDescendants(["health"]);
4669
+ expect(expanded.has("health")).toBe(true);
4670
+ expect(expanded.has("health/food")).toBe(true);
4671
+ expect(expanded.has("health/food/breakfast")).toBe(true);
4672
+ });
4673
+
4674
+ it("orphan sub-tag fallback: token for `health` still sees `#health/food` even with no declared hierarchy", async () => {
4675
+ // Per patterns/tag-scoped-tokens.md §Storage details, the auth check
4676
+ // also splits on '/' and matches the root verbatim against the raw
4677
+ // allowlist. This survives the v14 source-of-truth swap because the
4678
+ // fallback lives in src/tag-scope.ts, not in the resolver.
4679
+ const { noteWithinTagScope } = await import("../../src/tag-scope.ts");
4680
+ const note = { id: "x", content: "", createdAt: "", tags: ["health/food"] };
4681
+ const allowed = await store.expandTagsWithDescendants(["health"]);
4682
+ // No declared hierarchy — the expansion returns just `health`.
4683
+ expect(allowed.has("health/food")).toBe(false);
4684
+ // But the string-form fallback still matches.
4685
+ expect(noteWithinTagScope(note, allowed, ["health"])).toBe(true);
4686
+ });
4687
+ });
4688
+
4689
+ // ---- Vault projection (vault#271) ----
4690
+
4691
+ describe("vault projection (vault#271)", async () => {
4692
+ it("projects tags-with-schemas with effective inheritance", async () => {
4693
+ const { buildVaultProjection } = await import("./vault-projection.ts");
4694
+
4695
+ // Universal `_default` parent declares `created_by`.
4696
+ await store.upsertTagRecord("_default", {
4697
+ fields: { created_by: { type: "string", description: "Origin agent" } },
4698
+ });
4699
+ // `person` declares `email` and inherits `created_by`.
4700
+ await store.upsertTagRecord("person", {
4701
+ description: "A person",
4702
+ fields: { email: { type: "string", indexed: true } },
4703
+ });
4704
+ // `employee` extends `person` — should inherit BOTH `email` and `created_by`.
4705
+ await store.upsertTagRecord("employee", {
4706
+ description: "Person who works here",
4707
+ fields: { title: { type: "string" } },
4708
+ parent_names: ["person"],
4709
+ });
4710
+
4711
+ const projection = buildVaultProjection(db);
4712
+
4713
+ const byName = Object.fromEntries(projection.tags.map((t) => [t.name, t]));
4714
+
4715
+ // _default appears (has fields).
4716
+ expect(byName._default).toBeTruthy();
4717
+ expect(byName._default.parents).toEqual([]);
4718
+ expect(byName._default.effective_parents).toEqual([]);
4719
+
4720
+ // person inherits _default's universal field.
4721
+ expect(byName.person.parents).toEqual([]);
4722
+ expect(byName.person.effective_parents).toEqual(["_default"]);
4723
+ expect(Object.keys(byName.person.effective_fields).sort()).toEqual([
4724
+ "created_by",
4725
+ "email",
4726
+ ]);
4727
+ // own fields stay separate
4728
+ expect(Object.keys(byName.person.fields ?? {})).toEqual(["email"]);
4729
+
4730
+ // employee walks person → _default.
4731
+ expect(byName.employee.parents).toEqual(["person"]);
4732
+ expect(byName.employee.effective_parents).toEqual(["person", "_default"]);
4733
+ expect(Object.keys(byName.employee.effective_fields).sort()).toEqual([
4734
+ "created_by",
4735
+ "email",
4736
+ "title",
4737
+ ]);
4738
+ });
4739
+
4740
+ it("catalogs indexed fields across declarers", async () => {
4741
+ const { buildVaultProjection } = await import("./vault-projection.ts");
4742
+
4743
+ // Indexed-field lifecycle is owned by the update-tag MCP tool, not
4744
+ // store.upsertTagRecord — go through the tool so the indexed_fields
4745
+ // table actually gets populated.
4746
+ const tools = generateMcpTools(store);
4747
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4748
+ await updateTag.execute({
4749
+ tag: "task",
4750
+ fields: { status: { type: "string", indexed: true } },
4751
+ });
4752
+ await updateTag.execute({
4753
+ tag: "project",
4754
+ fields: {
4755
+ status: { type: "string", indexed: true },
4756
+ priority: { type: "integer", indexed: true },
4757
+ },
4758
+ });
4759
+
4760
+ const projection = buildVaultProjection(db);
4761
+ const byName = Object.fromEntries(projection.indexed_fields.map((f) => [f.name, f]));
4762
+
4763
+ expect(byName.status).toBeTruthy();
4764
+ expect(byName.status.type).toBe("string");
4765
+ expect(byName.status.tags.sort()).toEqual(["project", "task"]);
4766
+
4767
+ expect(byName.priority).toBeTruthy();
4768
+ expect(byName.priority.type).toBe("integer");
4769
+ expect(byName.priority.tags).toEqual(["project"]);
4770
+ });
4771
+
4772
+ it("includes the static query-hint catalog", async () => {
4773
+ const { buildVaultProjection, QUERY_HINTS } = await import("./vault-projection.ts");
4774
+ const projection = buildVaultProjection(db);
4775
+ expect(projection.query_hints.length).toBe(QUERY_HINTS.length);
4776
+ expect(projection.query_hints.some((h) => h.startsWith("query-notes { tag:"))).toBe(true);
4777
+ expect(projection.query_hints.some((h) => h.includes("near:"))).toBe(true);
4778
+ });
4779
+
4780
+ it("includes stats only when requested", async () => {
4781
+ const { buildVaultProjection } = await import("./vault-projection.ts");
4782
+ await store.createNote("a", { tags: ["x"] });
4783
+ await store.createNote("b", { tags: ["x", "y"] });
4784
+
4785
+ const without = buildVaultProjection(db);
4786
+ expect(without.stats).toBeUndefined();
4787
+
4788
+ const withStats = buildVaultProjection(db, { includeStats: true });
4789
+ expect(withStats.stats).toBeTruthy();
4790
+ expect(withStats.stats!.totalNotes).toBe(2);
4791
+ expect(withStats.stats!.tagCount).toBe(2);
4792
+ });
4793
+
4794
+ it("degrades gracefully on an empty vault", async () => {
4795
+ const { buildVaultProjection } = await import("./vault-projection.ts");
4796
+ const projection = buildVaultProjection(db);
4797
+ expect(projection.tags).toEqual([]);
4798
+ expect(projection.indexed_fields).toEqual([]);
4799
+ // Query hints are static — present even on a blank vault.
4800
+ expect(projection.query_hints.length).toBeGreaterThan(0);
4801
+ });
4802
+
4803
+ it("renders a markdown brief listing tags-with-schemas and indexed fields", async () => {
4804
+ const { buildVaultProjection, projectionToMarkdown } = await import(
4805
+ "./vault-projection.ts"
4806
+ );
4807
+
4808
+ await store.createNote("a", { tags: ["person"] });
4809
+ const tools = generateMcpTools(store);
4810
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4811
+ await updateTag.execute({
4812
+ tag: "person",
4813
+ description: "A person",
4814
+ fields: { email: { type: "string", indexed: true } },
4815
+ });
4816
+
4817
+ const projection = buildVaultProjection(db, { includeStats: true });
4818
+ const md = projectionToMarkdown({
4819
+ vaultName: "test",
4820
+ description: "My vault",
4821
+ projection,
4822
+ });
4823
+
4824
+ expect(md).toContain('You are connected to Parachute Vault "test"');
4825
+ expect(md).toContain("My vault");
4826
+ expect(md).toContain("1 tag with schemas: person");
4827
+ expect(md).toContain("Indexed metadata fields");
4828
+ expect(md).toContain("email");
4829
+ expect(md).toContain("#person");
4830
+ expect(md).toContain("vault-info");
4831
+ expect(md).toContain("list-tags { include_schema: true }");
4832
+ });
4833
+
4834
+ it("markdown brief degrades gracefully when no schemas declared", async () => {
4835
+ const { buildVaultProjection, projectionToMarkdown } = await import(
4836
+ "./vault-projection.ts"
4837
+ );
4838
+
4839
+ const projection = buildVaultProjection(db, { includeStats: true });
4840
+ const md = projectionToMarkdown({
4841
+ vaultName: "fresh",
4842
+ description: null,
4843
+ projection,
4844
+ });
4845
+
4846
+ expect(md).toContain('Parachute Vault "fresh"');
4847
+ expect(md).toContain("No tag schemas declared");
4848
+ expect(md).toContain("No indexed metadata fields");
4849
+ expect(md).toContain("Querying");
4850
+ });
4851
+
4852
+ it("markdown brief stays under ~5K tokens for a 50-tags-with-schemas vault", async () => {
4853
+ const { buildVaultProjection, projectionToMarkdown } = await import(
4854
+ "./vault-projection.ts"
4855
+ );
4856
+
4857
+ for (let i = 0; i < 50; i++) {
4858
+ await store.upsertTagRecord(`schema_tag_${i}`, {
4859
+ description: `Description for tag ${i} — covers what this tag is used for in the vault.`,
4860
+ fields: {
4861
+ [`field_${i}_a`]: { type: "string", indexed: i % 3 === 0 },
4862
+ [`field_${i}_b`]: { type: "integer" },
4863
+ },
4864
+ });
4865
+ }
4866
+
4867
+ const projection = buildVaultProjection(db, { includeStats: true });
4868
+ const md = projectionToMarkdown({
4869
+ vaultName: "big",
4870
+ description: "Big test vault",
4871
+ projection,
4872
+ });
4873
+
4874
+ // Rough token approximation: 1 token ≈ 4 chars. Budget: 5K tokens.
4875
+ const approxTokens = md.length / 4;
4876
+ expect(approxTokens).toBeLessThan(5000);
4877
+ });
4878
+ });