@openparachute/vault 0.3.1 → 0.4.0

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 (82) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +9 -5
  3. package/core/src/core.test.ts +2252 -7
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +801 -67
  6. package/core/src/note-schemas.ts +232 -0
  7. package/core/src/notes.ts +313 -35
  8. package/core/src/obsidian.ts +3 -3
  9. package/core/src/paths.ts +1 -1
  10. package/core/src/query-operators.ts +23 -7
  11. package/core/src/schema-defaults.ts +287 -0
  12. package/core/src/schema.ts +393 -9
  13. package/core/src/store.ts +248 -6
  14. package/core/src/tag-hierarchy.ts +137 -0
  15. package/core/src/tag-schemas.ts +242 -42
  16. package/core/src/types.ts +100 -6
  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 +231 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +144 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +384 -78
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +296 -0
  30. package/src/hub-jwt.ts +79 -0
  31. package/src/init-summary.test.ts +133 -0
  32. package/src/init-summary.ts +90 -0
  33. package/src/init.test.ts +216 -0
  34. package/src/mcp-http.ts +30 -28
  35. package/src/mcp-install.ts +1 -1
  36. package/src/mcp-tools.ts +294 -6
  37. package/src/module-config.ts +1 -1
  38. package/src/oauth.test.ts +345 -0
  39. package/src/oauth.ts +85 -14
  40. package/src/owner-auth.ts +57 -1
  41. package/src/prompt.ts +31 -14
  42. package/src/routes.ts +686 -58
  43. package/src/routing.test.ts +466 -1
  44. package/src/routing.ts +108 -24
  45. package/src/scopes.test.ts +66 -8
  46. package/src/scopes.ts +163 -37
  47. package/src/server.ts +24 -2
  48. package/src/services-manifest.test.ts +20 -0
  49. package/src/services-manifest.ts +9 -2
  50. package/src/stop-signal.test.ts +85 -0
  51. package/src/storage.test.ts +92 -0
  52. package/src/tag-scope.ts +118 -0
  53. package/src/token-store.test.ts +47 -0
  54. package/src/token-store.ts +128 -13
  55. package/src/tokens-routes.test.ts +720 -0
  56. package/src/tokens-routes.ts +392 -0
  57. package/src/transcription-worker.test.ts +5 -0
  58. package/src/triggers.ts +1 -1
  59. package/src/two-factor.ts +2 -2
  60. package/src/vault-create.test.ts +193 -0
  61. package/src/vault-name.test.ts +123 -0
  62. package/src/vault-name.ts +80 -0
  63. package/src/vault.test.ts +868 -3
  64. package/tsconfig.json +8 -1
  65. package/.claude/settings.local.json +0 -8
  66. package/.dockerignore +0 -8
  67. package/.env.example +0 -9
  68. package/CHANGELOG.md +0 -175
  69. package/CLAUDE.md +0 -125
  70. package/Caddyfile +0 -3
  71. package/Dockerfile +0 -22
  72. package/bun.lock +0 -219
  73. package/bunfig.toml +0 -2
  74. package/deploy/parachute-vault.service +0 -20
  75. package/docker-compose.yml +0 -50
  76. package/docs/HTTP_API.md +0 -434
  77. package/docs/auth-model.md +0 -340
  78. package/fly.toml +0 -24
  79. package/package/package.json +0 -32
  80. package/railway.json +0 -14
  81. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  82. 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 ----
@@ -476,12 +587,24 @@ describe("vault stats", async () => {
476
587
  const result = await store.getVaultStats();
477
588
  expect(result.totalNotes).toBe(2);
478
589
  expect(result.tagCount).toBe(2);
590
+ expect(result.attachmentCount).toBe(0);
479
591
  expect(result.topTags[0].tag).toBe("x");
480
592
  expect(result.topTags[0].count).toBe(2);
481
593
  expect(result.notesByMonth).toHaveLength(2);
482
594
  expect(result.earliestNote!.createdAt).toBe("2025-05-01T00:00:00.000Z");
483
595
  expect(result.latestNote!.createdAt).toBe("2025-06-01T00:00:00.000Z");
484
596
  });
597
+
598
+ it("getVaultStats counts attachments", async () => {
599
+ const n1 = await store.createNote("one");
600
+ const n2 = await store.createNote("two");
601
+ await store.addAttachment(n1.id, "/tmp/a1.mp3", "audio/mp3");
602
+ await store.addAttachment(n1.id, "/tmp/i1.png", "image/png");
603
+ await store.addAttachment(n2.id, "/tmp/a2.mp3", "audio/mp3");
604
+
605
+ const result = await store.getVaultStats();
606
+ expect(result.attachmentCount).toBe(3);
607
+ });
485
608
  });
486
609
 
487
610
  // ---- Query ----
@@ -534,6 +657,126 @@ describe("queryNotes", async () => {
534
657
  expect(results.length).toBeGreaterThan(0);
535
658
  });
536
659
 
660
+ // ---- Generalized date_filter (vault#215) ----
661
+ //
662
+ // The legacy `dateFrom` / `dateTo` always filter on `n.created_at` (vault
663
+ // ingestion time). The new `dateFilter: { field, from, to }` shape lets a
664
+ // caller filter on any *content* date — an email's received date, a
665
+ // meeting's scheduled date — by pointing `field` at an indexed metadata
666
+ // field. `field` defaults to `created_at`, in which case the SQL is
667
+ // identical to the legacy path.
668
+ describe("dateFilter (generalized)", () => {
669
+ async function declareEmailDate() {
670
+ const { declareField } = await import("./indexed-fields.js");
671
+ declareField(db, "email_date", "TEXT", "email");
672
+ }
673
+
674
+ it("dateFilter with no field defaults to created_at (matches the legacy shorthand)", async () => {
675
+ await store.createNote("A", { created_at: "2026-01-15T00:00:00.000Z" });
676
+ await store.createNote("B", { created_at: "2026-02-15T00:00:00.000Z" });
677
+ await store.createNote("C", { created_at: "2026-03-15T00:00:00.000Z" });
678
+
679
+ const results = await store.queryNotes({
680
+ dateFilter: { from: "2026-02-01", to: "2026-03-01" },
681
+ });
682
+ expect(results.map((n) => n.content)).toEqual(["B"]);
683
+ });
684
+
685
+ it("dateFilter on an indexed metadata field filters on content date, not ingestion date", async () => {
686
+ await declareEmailDate();
687
+ // Ingestion order doesn't match email_date order — that's the whole
688
+ // point: the bug was that `dateFrom` returned rows by ingestion time.
689
+ await store.createNote("recently-synced old email", {
690
+ metadata: { email_date: "2025-12-01T00:00:00.000Z" },
691
+ });
692
+ await store.createNote("recently-synced new email", {
693
+ metadata: { email_date: "2026-04-25T00:00:00.000Z" },
694
+ });
695
+ await store.createNote("recently-synced ancient", {
696
+ metadata: { email_date: "2024-08-15T00:00:00.000Z" },
697
+ });
698
+
699
+ const results = await store.queryNotes({
700
+ dateFilter: { field: "email_date", from: "2026-04-01", to: "2026-05-01" },
701
+ });
702
+ expect(results.map((n) => n.content)).toEqual(["recently-synced new email"]);
703
+ });
704
+
705
+ it("dateFilter on a non-indexed field rejects with FIELD_NOT_INDEXED", async () => {
706
+ await store.createNote("X", { metadata: { meeting_date: "2026-04-25T00:00:00.000Z" } });
707
+ // Note: not declared via declareField, so the field has no generated
708
+ // column. The error mirrors the metadata-operator + order_by gate.
709
+ try {
710
+ await store.queryNotes({
711
+ dateFilter: { field: "meeting_date", from: "2026-04-01" },
712
+ });
713
+ throw new Error("expected QueryError");
714
+ } catch (err: any) {
715
+ expect(err.name).toBe("QueryError");
716
+ expect(err.code).toBe("FIELD_NOT_INDEXED");
717
+ expect(err.message).toContain("meeting_date");
718
+ }
719
+ });
720
+
721
+ it("dateFilter combined with top-level dateFrom rejects with INVALID_QUERY", async () => {
722
+ await declareEmailDate();
723
+ try {
724
+ await store.queryNotes({
725
+ dateFrom: "2026-01-01",
726
+ dateFilter: { field: "email_date", from: "2026-04-01" },
727
+ });
728
+ throw new Error("expected QueryError");
729
+ } catch (err: any) {
730
+ expect(err.name).toBe("QueryError");
731
+ expect(err.code).toBe("INVALID_QUERY");
732
+ expect(err.message).toMatch(/cannot combine/i);
733
+ }
734
+ });
735
+
736
+ it("dateFilter with only `from` is open-ended on the upper bound", async () => {
737
+ await declareEmailDate();
738
+ await store.createNote("old", { metadata: { email_date: "2025-01-01T00:00:00.000Z" } });
739
+ await store.createNote("middle", { metadata: { email_date: "2026-04-15T00:00:00.000Z" } });
740
+ await store.createNote("new", { metadata: { email_date: "2026-05-01T00:00:00.000Z" } });
741
+
742
+ const results = await store.queryNotes({
743
+ dateFilter: { field: "email_date", from: "2026-04-01" },
744
+ });
745
+ expect(results.map((n) => n.content).sort()).toEqual(["middle", "new"]);
746
+ });
747
+
748
+ it("dateFilter with explicit field='created_at' routes to the legacy SQL path", async () => {
749
+ // The implicit-default case is covered above; this asserts the explicit
750
+ // form behaves identically — no indexed-field gate, same n.created_at SQL.
751
+ await store.createNote("A", { created_at: "2026-01-15T00:00:00.000Z" });
752
+ await store.createNote("B", { created_at: "2026-02-15T00:00:00.000Z" });
753
+ await store.createNote("C", { created_at: "2026-03-15T00:00:00.000Z" });
754
+
755
+ const results = await store.queryNotes({
756
+ dateFilter: { field: "created_at", from: "2026-02-01", to: "2026-03-01" },
757
+ });
758
+ expect(results.map((n) => n.content)).toEqual(["B"]);
759
+ });
760
+
761
+ it("query-notes accepts date_filter on an indexed metadata field (vault#215)", async () => {
762
+ await declareEmailDate();
763
+ await store.createNote("old email", {
764
+ metadata: { email_date: "2025-12-01T00:00:00.000Z" },
765
+ });
766
+ await store.createNote("recent email", {
767
+ metadata: { email_date: "2026-04-25T00:00:00.000Z" },
768
+ });
769
+
770
+ const tools = generateMcpTools(store);
771
+ const query = tools.find((t) => t.name === "query-notes")!;
772
+ const results = await query.execute({
773
+ date_filter: { field: "email_date", from: "2026-04-01", to: "2026-05-01" },
774
+ include_content: true,
775
+ }) as any[];
776
+ expect(results.map((n) => n.content)).toEqual(["recent email"]);
777
+ });
778
+ });
779
+
537
780
  it("sorts ascending and descending", async () => {
538
781
  await store.createNote("First", { id: "first" });
539
782
  await store.createNote("Second", { id: "second" });
@@ -907,7 +1150,7 @@ describe("attachments", async () => {
907
1150
  // ---- MCP Tools ----
908
1151
 
909
1152
  describe("MCP tools", async () => {
910
- it("generates all 9 consolidated tools", () => {
1153
+ it("generates the consolidated tool set", () => {
911
1154
  const tools = generateMcpTools(store);
912
1155
  const names = tools.map((t) => t.name);
913
1156
 
@@ -918,9 +1161,16 @@ describe("MCP tools", async () => {
918
1161
  expect(names).toContain("list-tags");
919
1162
  expect(names).toContain("update-tag");
920
1163
  expect(names).toContain("delete-tag");
1164
+ expect(names).toContain("list-note-schemas");
1165
+ expect(names).toContain("update-note-schema");
1166
+ expect(names).toContain("delete-note-schema");
1167
+ expect(names).toContain("list-schema-mappings");
1168
+ expect(names).toContain("set-schema-mapping");
1169
+ expect(names).toContain("delete-schema-mapping");
921
1170
  expect(names).toContain("find-path");
1171
+ expect(names).toContain("synthesize-notes");
922
1172
  expect(names).toContain("vault-info");
923
- expect(tools).toHaveLength(9);
1173
+ expect(tools).toHaveLength(16);
924
1174
  });
925
1175
 
926
1176
  it("create-note tool works", async () => {
@@ -1267,6 +1517,276 @@ describe("MCP tools", async () => {
1267
1517
  expect((await store.getNote("source"))!.content).toBe("See [[People/Alice]] for details");
1268
1518
  });
1269
1519
 
1520
+ it("update-note append concatenates to end without precondition", async () => {
1521
+ const note = await store.createNote("first line\n", { id: "n1" });
1522
+ const tools = generateMcpTools(store);
1523
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1524
+
1525
+ // No if_updated_at and no force — append-only is precondition-exempt.
1526
+ const result = await updateNote.execute({ id: note.id, append: "second line\n" }) as any;
1527
+ expect(result.content).toBe("first line\nsecond line\n");
1528
+
1529
+ const persisted = await store.getNote(note.id);
1530
+ expect(persisted!.content).toBe("first line\nsecond line\n");
1531
+ });
1532
+
1533
+ it("update-note prepend concatenates to start without precondition", async () => {
1534
+ const note = await store.createNote("body", { id: "n1" });
1535
+ const tools = generateMcpTools(store);
1536
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1537
+
1538
+ const result = await updateNote.execute({ id: note.id, prepend: "header\n" }) as any;
1539
+ expect(result.content).toBe("header\nbody");
1540
+ });
1541
+
1542
+ it("update-note prepend on frontmatter-led content injects after closing --- (#203)", async () => {
1543
+ const original = "---\ntitle: Foo\ntags: [bar]\n---\nbody line 1\n";
1544
+ const note = await store.createNote(original, { id: "n1" });
1545
+ const tools = generateMcpTools(store);
1546
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1547
+
1548
+ const result = await updateNote.execute({ id: note.id, prepend: "preamble\n" }) as any;
1549
+ // Frontmatter still at byte 0 — parsers expecting `---\n` will find it.
1550
+ expect(result.content.startsWith("---\ntitle: Foo\ntags: [bar]\n---\n")).toBe(true);
1551
+ // Prepend lands immediately after the closing fence, before the body.
1552
+ expect(result.content).toBe(
1553
+ "---\ntitle: Foo\ntags: [bar]\n---\npreamble\nbody line 1\n",
1554
+ );
1555
+ });
1556
+
1557
+ it("update-note prepend on content lacking frontmatter injects at byte 0", async () => {
1558
+ const note = await store.createNote("# Heading\nbody\n", { id: "n1" });
1559
+ const tools = generateMcpTools(store);
1560
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1561
+
1562
+ const result = await updateNote.execute({ id: note.id, prepend: "preamble\n" }) as any;
1563
+ expect(result.content).toBe("preamble\n# Heading\nbody\n");
1564
+ });
1565
+
1566
+ it("update-note append+prepend in one call lands both contributions", async () => {
1567
+ const note = await store.createNote("middle", { id: "n1" });
1568
+ const tools = generateMcpTools(store);
1569
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1570
+
1571
+ const result = await updateNote.execute({
1572
+ id: note.id,
1573
+ prepend: "[start] ",
1574
+ append: " [end]",
1575
+ }) as any;
1576
+ expect(result.content).toBe("[start] middle [end]");
1577
+ });
1578
+
1579
+ it("update-note rejects content + append in same call", async () => {
1580
+ const note = await store.createNote("body", { id: "n1" });
1581
+ const tools = generateMcpTools(store);
1582
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1583
+
1584
+ let err: any;
1585
+ try {
1586
+ await updateNote.execute({ id: note.id, content: "new", append: "more", force: true });
1587
+ } catch (e) {
1588
+ err = e;
1589
+ }
1590
+ expect(err?.message).toMatch(/mutually exclusive/);
1591
+ });
1592
+
1593
+ it("update-note rejects content + content_edit in same call", async () => {
1594
+ const note = await store.createNote("hello world", { id: "n1" });
1595
+ const tools = generateMcpTools(store);
1596
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1597
+
1598
+ let err: any;
1599
+ try {
1600
+ await updateNote.execute({
1601
+ id: note.id,
1602
+ content: "replace",
1603
+ content_edit: { old_text: "hello", new_text: "hi" },
1604
+ force: true,
1605
+ });
1606
+ } catch (e) {
1607
+ err = e;
1608
+ }
1609
+ expect(err?.message).toMatch(/mutually exclusive/);
1610
+ });
1611
+
1612
+ it("update-note rejects append + content_edit in same call", async () => {
1613
+ const note = await store.createNote("hello world", { id: "n1" });
1614
+ const tools = generateMcpTools(store);
1615
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1616
+
1617
+ let err: any;
1618
+ try {
1619
+ await updateNote.execute({
1620
+ id: note.id,
1621
+ append: " more",
1622
+ content_edit: { old_text: "hello", new_text: "hi" },
1623
+ });
1624
+ } catch (e) {
1625
+ err = e;
1626
+ }
1627
+ expect(err?.message).toMatch(/mutually exclusive/);
1628
+ });
1629
+
1630
+ it("update-note append still requires precondition when combined with other fields", async () => {
1631
+ const note = await store.createNote("body", { id: "n1" });
1632
+ const tools = generateMcpTools(store);
1633
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1634
+
1635
+ // append + metadata is NOT precondition-exempt — metadata mutation
1636
+ // can lose data on a stale read, so the safety gate stays in.
1637
+ let err: any;
1638
+ try {
1639
+ await updateNote.execute({ id: note.id, append: " more", metadata: { x: 1 } });
1640
+ } catch (e) {
1641
+ err = e;
1642
+ }
1643
+ expect(err?.code).toBe("PRECONDITION_REQUIRED");
1644
+ });
1645
+
1646
+ it("update-note append is atomic under concurrent calls — both lands", async () => {
1647
+ const note = await store.createNote("seed:", { id: "n1" });
1648
+ const tools = generateMcpTools(store);
1649
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1650
+
1651
+ // Two concurrent appends. SQL-level concat means both contributions
1652
+ // land — neither overwrites the other.
1653
+ const results = await Promise.all([
1654
+ updateNote.execute({ id: note.id, append: " A" }),
1655
+ updateNote.execute({ id: note.id, append: " B" }),
1656
+ ]);
1657
+ expect(results).toHaveLength(2);
1658
+
1659
+ const persisted = await store.getNote(note.id);
1660
+ // Final content is one of "seed: A B" or "seed: B A" — the order
1661
+ // depends on which write got the lock first, but both contributions
1662
+ // are present.
1663
+ expect(persisted!.content === "seed: A B" || persisted!.content === "seed: B A").toBe(true);
1664
+ });
1665
+
1666
+ it("update-note append updates updated_at and respects if_updated_at when supplied", async () => {
1667
+ const note = await store.createNote("seed", { id: "n1" });
1668
+ const tools = generateMcpTools(store);
1669
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1670
+
1671
+ // With if_updated_at — succeeds because we're using the right token.
1672
+ const ok = await updateNote.execute({ id: note.id, append: " A", if_updated_at: note.updatedAt }) as any;
1673
+ expect(ok.content).toBe("seed A");
1674
+ expect(ok.updatedAt).not.toBe(note.updatedAt);
1675
+
1676
+ // Stale token — conflict.
1677
+ let err: any;
1678
+ try {
1679
+ await updateNote.execute({ id: note.id, append: " B", if_updated_at: note.updatedAt });
1680
+ } catch (e) {
1681
+ err = e;
1682
+ }
1683
+ expect(err?.code).toBe("CONFLICT");
1684
+ });
1685
+
1686
+ it("update-note append parses new wikilinks introduced via append", async () => {
1687
+ const target = await store.createNote("Alice's note", { id: "alice", path: "People/Alice" });
1688
+ const source = await store.createNote("intro\n", { id: "src" });
1689
+ const tools = generateMcpTools(store);
1690
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1691
+
1692
+ await updateNote.execute({ id: source.id, append: "see [[People/Alice]]" });
1693
+
1694
+ const links = await store.getLinks(source.id, { direction: "outbound" });
1695
+ expect(links.some((l) => l.targetId === target.id && l.relationship === "wikilink")).toBe(true);
1696
+ });
1697
+
1698
+ it("update-note content_edit replaces a single occurrence", async () => {
1699
+ const note = await store.createNote("hello world", { id: "n1" });
1700
+ const tools = generateMcpTools(store);
1701
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1702
+
1703
+ const result = await updateNote.execute({
1704
+ id: note.id,
1705
+ content_edit: { old_text: "hello", new_text: "hi" },
1706
+ if_updated_at: note.updatedAt,
1707
+ }) as any;
1708
+ expect(result.content).toBe("hi world");
1709
+ });
1710
+
1711
+ it("update-note content_edit errors when old_text is not found", async () => {
1712
+ const note = await store.createNote("hello world", { id: "n1" });
1713
+ const tools = generateMcpTools(store);
1714
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1715
+
1716
+ let err: any;
1717
+ try {
1718
+ await updateNote.execute({
1719
+ id: note.id,
1720
+ content_edit: { old_text: "missing", new_text: "x" },
1721
+ if_updated_at: note.updatedAt,
1722
+ });
1723
+ } catch (e) {
1724
+ err = e;
1725
+ }
1726
+ expect(err?.message).toMatch(/not found/);
1727
+ // Note must be untouched.
1728
+ const persisted = await store.getNote(note.id);
1729
+ expect(persisted!.content).toBe("hello world");
1730
+ });
1731
+
1732
+ it("update-note content_edit errors when old_text matches multiple times", async () => {
1733
+ const note = await store.createNote("hello hello", { id: "n1" });
1734
+ const tools = generateMcpTools(store);
1735
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1736
+
1737
+ let err: any;
1738
+ try {
1739
+ await updateNote.execute({
1740
+ id: note.id,
1741
+ content_edit: { old_text: "hello", new_text: "hi" },
1742
+ if_updated_at: note.updatedAt,
1743
+ });
1744
+ } catch (e) {
1745
+ err = e;
1746
+ }
1747
+ expect(err?.message).toMatch(/matches multiple times|exactly once/);
1748
+ const persisted = await store.getNote(note.id);
1749
+ expect(persisted!.content).toBe("hello hello");
1750
+ });
1751
+
1752
+ it("update-note content_edit requires precondition by default", async () => {
1753
+ const note = await store.createNote("hello world", { id: "n1" });
1754
+ const tools = generateMcpTools(store);
1755
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1756
+
1757
+ let err: any;
1758
+ try {
1759
+ await updateNote.execute({
1760
+ id: note.id,
1761
+ content_edit: { old_text: "hello", new_text: "hi" },
1762
+ });
1763
+ } catch (e) {
1764
+ err = e;
1765
+ }
1766
+ expect(err?.code).toBe("PRECONDITION_REQUIRED");
1767
+ });
1768
+
1769
+ it("update-note content_edit conflicts when if_updated_at is stale", async () => {
1770
+ const note = await store.createNote("hello world", { id: "n1" });
1771
+ const tools = generateMcpTools(store);
1772
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1773
+
1774
+ // Bump the note so a stale token will conflict at the SQL layer.
1775
+ await updateNote.execute({ id: note.id, content: "hello world", force: true });
1776
+
1777
+ let err: any;
1778
+ try {
1779
+ await updateNote.execute({
1780
+ id: note.id,
1781
+ content_edit: { old_text: "hello", new_text: "hi" },
1782
+ if_updated_at: "2020-01-01T00:00:00.000Z",
1783
+ });
1784
+ } catch (e) {
1785
+ err = e;
1786
+ }
1787
+ expect(err?.code).toBe("CONFLICT");
1788
+ });
1789
+
1270
1790
  it("query-notes single note by id", async () => {
1271
1791
  const note = await store.createNote("Hello", { path: "test/note" });
1272
1792
  const tools = generateMcpTools(store);
@@ -1529,12 +2049,41 @@ describe("MCP tools", async () => {
1529
2049
  expect(result[0].id).toBe("near");
1530
2050
  });
1531
2051
 
1532
- it("delete-note accepts path", async () => {
1533
- await store.createNote("To delete", { path: "Temp/note" });
2052
+ it("query-notes near returns neighborhood even when limit is small and unrelated notes were created first (#130)", async () => {
2053
+ // Repro of #130: anchor + linked notes get crowded out by unrelated notes
2054
+ // when the query runs ORDER BY created_at LIMIT 5 BEFORE the
2055
+ // neighborhood filter. With the SQL-pushed ids filter, LIMIT applies to
2056
+ // the neighborhood, not the whole notes table.
2057
+ //
2058
+ // Seed: 10 unrelated notes created first, THEN the anchor + 2 linked
2059
+ // notes. With limit=5 and ORDER BY created_at ASC, the unrelated ten
2060
+ // would fill the slate and the in-neighborhood notes would never appear.
2061
+ for (let i = 0; i < 10; i++) {
2062
+ await store.createNote(`Unrelated ${i}`, { id: `unrelated-${i}` });
2063
+ }
2064
+ await store.createNote("Anchor", { id: "anchor" });
2065
+ await store.createNote("Outbound target", { id: "outbound" });
2066
+ await store.createNote("Inbound source", { id: "inbound" });
2067
+ await store.createLink("anchor", "outbound", "wikilink");
2068
+ await store.createLink("inbound", "anchor", "wikilink");
2069
+
1534
2070
  const tools = generateMcpTools(store);
1535
- const deleteTool = tools.find((t) => t.name === "delete-note")!;
1536
- const result = await deleteTool.execute({ id: "Temp/note" }) as any;
1537
- expect(result.deleted).toBe(true);
2071
+ const query = tools.find((t) => t.name === "query-notes")!;
2072
+ const result = await query.execute({
2073
+ near: { note_id: "anchor", depth: 2 },
2074
+ limit: 5,
2075
+ }) as any[];
2076
+
2077
+ const ids = result.map((n: any) => n.id).sort();
2078
+ expect(ids).toEqual(["anchor", "inbound", "outbound"]);
2079
+ });
2080
+
2081
+ it("delete-note accepts path", async () => {
2082
+ await store.createNote("To delete", { path: "Temp/note" });
2083
+ const tools = generateMcpTools(store);
2084
+ const deleteTool = tools.find((t) => t.name === "delete-note")!;
2085
+ const result = await deleteTool.execute({ id: "Temp/note" }) as any;
2086
+ expect(result.deleted).toBe(true);
1538
2087
  expect(await store.getNoteByPath("Temp/note")).toBeNull();
1539
2088
  });
1540
2089
 
@@ -1656,6 +2205,183 @@ describe("MCP tools", async () => {
1656
2205
  expect(result.relationships).toEqual(["mentions", "related-to"]);
1657
2206
  });
1658
2207
 
2208
+ // ---- synthesize-notes ----
2209
+
2210
+ it("synthesize-notes rejects when neither anchor nor query is supplied", async () => {
2211
+ const tools = generateMcpTools(store);
2212
+ const synth = tools.find((t) => t.name === "synthesize-notes")!;
2213
+ const result = await synth.execute({}) as any;
2214
+ expect(result.error).toMatch(/at least one of `anchor` or `query`/);
2215
+ });
2216
+
2217
+ it("synthesize-notes rejects an unknown anchor", async () => {
2218
+ const tools = generateMcpTools(store);
2219
+ const synth = tools.find((t) => t.name === "synthesize-notes")!;
2220
+ const result = await synth.execute({ anchor: "does-not-exist" }) as any;
2221
+ expect(result.error).toMatch(/Anchor note not found/);
2222
+ });
2223
+
2224
+ it("synthesize-notes returns anchor + linked neighbors ranked", async () => {
2225
+ await store.createNote("Hub note", { id: "hub", tags: ["topic"] });
2226
+ await store.createNote("Direct neighbor", { id: "n1" });
2227
+ await store.createNote("Two-hop neighbor", { id: "n2" });
2228
+ await store.createNote("Unrelated", { id: "u1" });
2229
+ await store.createLink("hub", "n1", "mentions");
2230
+ await store.createLink("n1", "n2", "mentions");
2231
+
2232
+ const tools = generateMcpTools(store);
2233
+ const synth = tools.find((t) => t.name === "synthesize-notes")!;
2234
+ const result = await synth.execute({ anchor: "hub", depth: 2 }) as any;
2235
+
2236
+ const ids = result.notes.map((n: any) => n.id);
2237
+ expect(ids).toContain("hub");
2238
+ expect(ids).toContain("n1");
2239
+ expect(ids).toContain("n2");
2240
+ expect(ids).not.toContain("u1");
2241
+ // Anchor ranks first; one-hop beats two-hop.
2242
+ expect(ids[0]).toBe("hub");
2243
+ expect(ids.indexOf("n1")).toBeLessThan(ids.indexOf("n2"));
2244
+ expect(result.notes[0].sources).toContain("anchor");
2245
+ expect(result.notes[0].distance).toBe(0);
2246
+ expect(result.topic.anchor).toEqual({ id: "hub", path: null });
2247
+ });
2248
+
2249
+ it("synthesize-notes returns FTS hits when only query is supplied", async () => {
2250
+ await store.createNote("Octopus thoughts: ink and color", { id: "o1" });
2251
+ await store.createNote("Squid thoughts: deep blue", { id: "s1" });
2252
+ await store.createNote("Recipe for octopus salad", { id: "o2" });
2253
+
2254
+ const tools = generateMcpTools(store);
2255
+ const synth = tools.find((t) => t.name === "synthesize-notes")!;
2256
+ const result = await synth.execute({ query: "octopus" }) as any;
2257
+
2258
+ const ids = result.notes.map((n: any) => n.id);
2259
+ expect(ids).toContain("o1");
2260
+ expect(ids).toContain("o2");
2261
+ expect(ids).not.toContain("s1");
2262
+ expect(result.notes[0].sources).toContain("search");
2263
+ expect(result.notes[0]).toHaveProperty("fts_rank");
2264
+ expect(result.topic.query).toBe("octopus");
2265
+ });
2266
+
2267
+ it("synthesize-notes applies scope.tags filter (any-match)", async () => {
2268
+ await store.createNote("Anchor", { id: "a" });
2269
+ await store.createNote("Tagged neighbor", { id: "t1", tags: ["alpha"] });
2270
+ await store.createNote("Other neighbor", { id: "t2", tags: ["beta"] });
2271
+ await store.createLink("a", "t1", "mentions");
2272
+ await store.createLink("a", "t2", "mentions");
2273
+
2274
+ const tools = generateMcpTools(store);
2275
+ const synth = tools.find((t) => t.name === "synthesize-notes")!;
2276
+ const result = await synth.execute({ anchor: "a", scope: { tags: ["alpha"] } }) as any;
2277
+
2278
+ const ids = result.notes.map((n: any) => n.id);
2279
+ expect(ids).toContain("t1");
2280
+ expect(ids).not.toContain("t2");
2281
+ expect(ids).not.toContain("a"); // anchor itself has no tags
2282
+ });
2283
+
2284
+ it("synthesize-notes applies scope.path prefix filter", async () => {
2285
+ await store.createNote("Anchor", { id: "a" });
2286
+ await store.createNote("In scope", { id: "p1", path: "Projects/Alpha/notes" });
2287
+ await store.createNote("Out of scope", { id: "p2", path: "People/Bob" });
2288
+ await store.createLink("a", "p1", "mentions");
2289
+ await store.createLink("a", "p2", "mentions");
2290
+
2291
+ const tools = generateMcpTools(store);
2292
+ const synth = tools.find((t) => t.name === "synthesize-notes")!;
2293
+ const result = await synth.execute({ anchor: "a", scope: { path: "projects/" } }) as any;
2294
+
2295
+ const ids = result.notes.map((n: any) => n.id);
2296
+ expect(ids).toContain("p1");
2297
+ expect(ids).not.toContain("p2");
2298
+ });
2299
+
2300
+ it("synthesize-notes respects limit and sets truncated flag", async () => {
2301
+ await store.createNote("Anchor", { id: "anchor" });
2302
+ for (let i = 0; i < 5; i++) {
2303
+ await store.createNote(`Neighbor ${i}`, { id: `n${i}` });
2304
+ await store.createLink("anchor", `n${i}`, "mentions");
2305
+ }
2306
+
2307
+ const tools = generateMcpTools(store);
2308
+ const synth = tools.find((t) => t.name === "synthesize-notes")!;
2309
+ const result = await synth.execute({ anchor: "anchor", limit: 3 }) as any;
2310
+
2311
+ expect(result.notes).toHaveLength(3);
2312
+ expect(result.truncated).toBe(true);
2313
+ });
2314
+
2315
+ it("synthesize-notes connections include only links between returned notes", async () => {
2316
+ await store.createNote("Anchor", { id: "a" });
2317
+ await store.createNote("In set", { id: "b" });
2318
+ await store.createNote("Excluded", { id: "c" });
2319
+ await store.createLink("a", "b", "mentions");
2320
+ await store.createLink("a", "c", "mentions");
2321
+ await store.createLink("b", "c", "related-to");
2322
+
2323
+ const tools = generateMcpTools(store);
2324
+ const synth = tools.find((t) => t.name === "synthesize-notes")!;
2325
+ const result = await synth.execute({ anchor: "a", depth: 1, limit: 2 }) as any;
2326
+
2327
+ const ids = new Set(result.notes.map((n: any) => n.id));
2328
+ for (const c of result.connections) {
2329
+ expect(ids.has(c.source)).toBe(true);
2330
+ expect(ids.has(c.target)).toBe(true);
2331
+ }
2332
+ expect(result.connections.some((c: any) => c.target === "c")).toBe(false);
2333
+ });
2334
+
2335
+ it("synthesize-notes timeline orders oldest → newest", async () => {
2336
+ await store.createNote("Old", { id: "old", created_at: "2024-01-01T00:00:00.000Z" });
2337
+ await store.createNote("Mid", { id: "mid", created_at: "2025-01-01T00:00:00.000Z" });
2338
+ await store.createNote("New", { id: "new", created_at: "2026-01-01T00:00:00.000Z" });
2339
+ await store.createLink("new", "mid", "mentions");
2340
+ await store.createLink("mid", "old", "mentions");
2341
+
2342
+ const tools = generateMcpTools(store);
2343
+ const synth = tools.find((t) => t.name === "synthesize-notes")!;
2344
+ const result = await synth.execute({ anchor: "new", depth: 2 }) as any;
2345
+
2346
+ const ids = result.timeline.map((t: any) => t.id);
2347
+ expect(ids).toEqual(["old", "mid", "new"]);
2348
+ });
2349
+
2350
+ it("synthesize-notes tag distribution counts tags across results", async () => {
2351
+ await store.createNote("Anchor", { id: "a", tags: ["alpha", "beta"] });
2352
+ await store.createNote("N1", { id: "n1", tags: ["alpha"] });
2353
+ await store.createNote("N2", { id: "n2", tags: ["alpha", "gamma"] });
2354
+ await store.createLink("a", "n1", "mentions");
2355
+ await store.createLink("a", "n2", "mentions");
2356
+
2357
+ const tools = generateMcpTools(store);
2358
+ const synth = tools.find((t) => t.name === "synthesize-notes")!;
2359
+ const result = await synth.execute({ anchor: "a" }) as any;
2360
+
2361
+ const tagMap = new Map(result.tags.map((t: any) => [t.name, t.count]));
2362
+ expect(tagMap.get("alpha")).toBe(3);
2363
+ expect(tagMap.get("beta")).toBe(1);
2364
+ expect(tagMap.get("gamma")).toBe(1);
2365
+ expect(result.tags[0].name).toBe("alpha");
2366
+ });
2367
+
2368
+ it("synthesize-notes include_content controls snippet vs full body", async () => {
2369
+ const longBody = "x".repeat(500);
2370
+ await store.createNote(longBody, { id: "long", path: "Long" });
2371
+
2372
+ const tools = generateMcpTools(store);
2373
+ const synth = tools.find((t) => t.name === "synthesize-notes")!;
2374
+
2375
+ const snippetResult = await synth.execute({ anchor: "long" }) as any;
2376
+ expect(snippetResult.notes[0]).toHaveProperty("snippet");
2377
+ expect(snippetResult.notes[0]).not.toHaveProperty("content");
2378
+ expect(snippetResult.notes[0].snippet.length).toBeLessThanOrEqual(200);
2379
+
2380
+ const fullResult = await synth.execute({ anchor: "long", include_content: true }) as any;
2381
+ expect(fullResult.notes[0].content).toBe(longBody);
2382
+ expect(fullResult.notes[0]).not.toHaveProperty("snippet");
2383
+ });
2384
+
1659
2385
  it("create-note via store triggers wikilink sync", async () => {
1660
2386
  const tools = generateMcpTools(store);
1661
2387
  const createNote = tools.find((t) => t.name === "create-note")!;
@@ -1688,6 +2414,315 @@ describe("MCP tools", async () => {
1688
2414
  expect(fresh.metadata.priority).toBe(0);
1689
2415
  expect(fresh.metadata.status).toBe("active");
1690
2416
  });
2417
+
2418
+ // ---- query-notes input-shape tolerance (vault#214) ----
2419
+ //
2420
+ // The MCP framework drops top-level keys not in the inputSchema without
2421
+ // raising — so an LLM caller passing the wrong field name gets a silent
2422
+ // no-op rather than an error. We accept canonical + camelCase + singular
2423
+ // aliases so the most common LLM mistakes still apply the filter, and
2424
+ // we mirror the `tag` param's string-or-array shape so a single excluded
2425
+ // tag doesn't need a wrapping array.
2426
+
2427
+ it("query-notes accepts `excludeTags` (camelCase alias)", async () => {
2428
+ await store.createNote("a", { tags: ["email"] });
2429
+ await store.createNote("b", { tags: ["email", "urgent"] });
2430
+ const tools = generateMcpTools(store);
2431
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
2432
+ const r = await queryNotes.execute({ tag: "email", excludeTags: ["urgent"], include_content: true }) as any[];
2433
+ expect(r).toHaveLength(1);
2434
+ expect(r[0].content).toBe("a");
2435
+ });
2436
+
2437
+ it("query-notes accepts `exclude_tag` (singular alias)", async () => {
2438
+ await store.createNote("a", { tags: ["email"] });
2439
+ await store.createNote("b", { tags: ["email", "urgent"] });
2440
+ const tools = generateMcpTools(store);
2441
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
2442
+ const r = await queryNotes.execute({ tag: "email", exclude_tag: "urgent", include_content: true }) as any[];
2443
+ expect(r).toHaveLength(1);
2444
+ expect(r[0].content).toBe("a");
2445
+ });
2446
+
2447
+ it("query-notes `exclude_tags` accepts a single string (mirrors `tag`)", async () => {
2448
+ await store.createNote("a", { tags: ["email"] });
2449
+ await store.createNote("b", { tags: ["email", "urgent"] });
2450
+ const tools = generateMcpTools(store);
2451
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
2452
+ const r = await queryNotes.execute({ tag: "email", exclude_tags: "urgent", include_content: true }) as any[];
2453
+ expect(r).toHaveLength(1);
2454
+ expect(r[0].content).toBe("a");
2455
+ });
2456
+
2457
+ it("query-notes canonical `exclude_tags: [...]` still works (regression)", async () => {
2458
+ await store.createNote("a", { tags: ["email"] });
2459
+ await store.createNote("b", { tags: ["email", "urgent"] });
2460
+ const tools = generateMcpTools(store);
2461
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
2462
+ const r = await queryNotes.execute({ tag: "email", exclude_tags: ["urgent"], include_content: true }) as any[];
2463
+ expect(r).toHaveLength(1);
2464
+ expect(r[0].content).toBe("a");
2465
+ });
2466
+
2467
+ it("query-notes routes through store.queryNotes so tag-hierarchy expansion fires", async () => {
2468
+ // `voice` and `text` declare "manual" as their parent via the v14
2469
+ // tags.parent_names column. A query for `tag: "manual"` should match
2470
+ // notes tagged with either child — that expansion only happens when
2471
+ // the call goes through `store.queryNotes`, not `noteOps.queryNotes`
2472
+ // directly.
2473
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
2474
+ await store.upsertTagRecord("text", { parent_names: ["manual"] });
2475
+ await store.createNote("voice memo", { tags: ["voice"] });
2476
+ await store.createNote("text memo", { tags: ["text"] });
2477
+ await store.createNote("unrelated", { tags: ["other"] });
2478
+
2479
+ const tools = generateMcpTools(store);
2480
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
2481
+ const r = await queryNotes.execute({ tag: "manual", include_content: true }) as any[];
2482
+ expect(r).toHaveLength(2);
2483
+ expect(r.map((n) => n.content).sort()).toEqual(["text memo", "voice memo"]);
2484
+ });
2485
+
2486
+ it("query-notes FTS path routes through store.searchNotes so tag-hierarchy expansion fires (vault#227)", async () => {
2487
+ // Same fixture shape as the structured-query hierarchy test above, but
2488
+ // exercising the search branch. Pre-fix the FTS path called
2489
+ // `noteOps.searchNotes` directly and silently dropped descendant matches —
2490
+ // `tag: "manual"` would only return notes literally tagged #manual, not
2491
+ // notes tagged with the declared children #voice / #text.
2492
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
2493
+ await store.upsertTagRecord("text", { parent_names: ["manual"] });
2494
+ await store.createNote("voice handoff notes", { tags: ["voice"] });
2495
+ await store.createNote("text handoff notes", { tags: ["text"] });
2496
+ await store.createNote("unrelated handoff", { tags: ["other"] });
2497
+
2498
+ const tools = generateMcpTools(store);
2499
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
2500
+ const r = await queryNotes.execute({ search: "handoff", tag: "manual", include_content: true }) as any[];
2501
+ expect(r).toHaveLength(2);
2502
+ expect(r.map((n) => n.content).sort()).toEqual(["text handoff notes", "voice handoff notes"]);
2503
+ expect(r.map((n) => n.content)).not.toContain("unrelated handoff");
2504
+ });
2505
+
2506
+ it("query-notes does not mutate caller's params object across repeated calls", async () => {
2507
+ // normalizeTags returns a defensive copy of array inputs so the downstream
2508
+ // store layer can sort/dedupe without touching the caller's reference.
2509
+ // Without the copy, a caller reusing the same params object would see its
2510
+ // exclude_tags array reordered (or worse) on the second call.
2511
+ await store.createNote("a", { tags: ["email"] });
2512
+ await store.createNote("b", { tags: ["email", "urgent"] });
2513
+ await store.createNote("c", { tags: ["email", "spam"] });
2514
+
2515
+ const tools = generateMcpTools(store);
2516
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
2517
+ const params = { tag: "email", exclude_tags: ["urgent", "spam"], include_content: true };
2518
+
2519
+ const r1 = await queryNotes.execute(params) as any[];
2520
+ expect(params.exclude_tags).toEqual(["urgent", "spam"]);
2521
+
2522
+ const r2 = await queryNotes.execute(params) as any[];
2523
+ expect(params.exclude_tags).toEqual(["urgent", "spam"]);
2524
+
2525
+ expect(r1.map((n) => n.content).sort()).toEqual(["a"]);
2526
+ expect(r2.map((n) => n.content).sort()).toEqual(["a"]);
2527
+ });
2528
+
2529
+ // ---- empty-note + batch-cap MCP regressions (#213) ----
2530
+
2531
+ it("create-note rejects bare empty content with no path (EMPTY_NOTE)", async () => {
2532
+ const tools = generateMcpTools(store);
2533
+ const createNote = tools.find((t) => t.name === "create-note")!;
2534
+ let err: any;
2535
+ try {
2536
+ await createNote.execute({ content: "" });
2537
+ } catch (e) {
2538
+ err = e;
2539
+ }
2540
+ expect(err?.code).toBe("EMPTY_NOTE");
2541
+ });
2542
+
2543
+ it("create-note batch rejects when any entry is empty content + no path (atomic, with item_index)", async () => {
2544
+ const tools = generateMcpTools(store);
2545
+ const createNote = tools.find((t) => t.name === "create-note")!;
2546
+ const beforeCount = (await store.queryNotes({ search: "atomic-marker" })).length;
2547
+ let err: any;
2548
+ try {
2549
+ await createNote.execute({
2550
+ notes: [
2551
+ { content: "atomic-marker first" },
2552
+ { content: "" },
2553
+ ],
2554
+ });
2555
+ } catch (e) {
2556
+ err = e;
2557
+ }
2558
+ expect(err?.code).toBe("EMPTY_NOTE");
2559
+ // The first item must NOT have been created — pre-validation rolls
2560
+ // the whole batch back atomically. Partial-create would leak prefixes
2561
+ // on every runaway-client burst (#213).
2562
+ const afterCount = (await store.queryNotes({ search: "atomic-marker" })).length;
2563
+ expect(afterCount).toBe(beforeCount);
2564
+ // Parity with HTTP route: MCP callers with multi-item batches need to
2565
+ // know which entry triggered the rejection. The bad entry is at index 1.
2566
+ expect(err.item_index).toBe(1);
2567
+ });
2568
+
2569
+ it("create-note single empty has null item_index (not a batch position)", async () => {
2570
+ const tools = generateMcpTools(store);
2571
+ const createNote = tools.find((t) => t.name === "create-note")!;
2572
+ let err: any;
2573
+ try {
2574
+ await createNote.execute({ content: "" });
2575
+ } catch (e) {
2576
+ err = e;
2577
+ }
2578
+ expect(err?.code).toBe("EMPTY_NOTE");
2579
+ // Single-call (no `notes` array) — there's no batch position to report.
2580
+ expect(err.item_index).toBeNull();
2581
+ });
2582
+
2583
+ it("create-note batch over MAX_BATCH_SIZE rejects with BATCH_TOO_LARGE", async () => {
2584
+ const tools = generateMcpTools(store);
2585
+ const createNote = tools.find((t) => t.name === "create-note")!;
2586
+ const notes = Array.from({ length: 501 }, (_, i) => ({ content: `n${i}` }));
2587
+ let err: any;
2588
+ try {
2589
+ await createNote.execute({ notes });
2590
+ } catch (e) {
2591
+ err = e;
2592
+ }
2593
+ expect(err?.code).toBe("BATCH_TOO_LARGE");
2594
+ expect(err.limit).toBe(500);
2595
+ expect(err.got).toBe(501);
2596
+ });
2597
+
2598
+ it("update-note batch over MAX_BATCH_SIZE rejects with BATCH_TOO_LARGE", async () => {
2599
+ const tools = generateMcpTools(store);
2600
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2601
+ const notes = Array.from({ length: 501 }, (_, i) => ({
2602
+ id: `id${i}`,
2603
+ content: "x",
2604
+ force: true,
2605
+ }));
2606
+ let err: any;
2607
+ try {
2608
+ await updateNote.execute({ notes });
2609
+ } catch (e) {
2610
+ err = e;
2611
+ }
2612
+ expect(err?.code).toBe("BATCH_TOO_LARGE");
2613
+ expect(err.limit).toBe(500);
2614
+ expect(err.got).toBe(501);
2615
+ });
2616
+
2617
+ it("create-note batch where mid-item triggers PATH_CONFLICT rolls back prefix items (#236)", async () => {
2618
+ // The empty-note pre-walk (#213) catches `{}` before any DB write; a
2619
+ // path-conflict can only surface on the actual INSERT, mid-loop. Without
2620
+ // the BEGIN/COMMIT wrap the prefix items would have already landed.
2621
+ await store.createNote("seed", { path: "taken-236" });
2622
+ const tools = generateMcpTools(store);
2623
+ const createNote = tools.find((t) => t.name === "create-note")!;
2624
+ const beforeIds = (await store.queryNotes({})).map((n) => n.id).sort();
2625
+
2626
+ let err: any;
2627
+ try {
2628
+ await createNote.execute({
2629
+ notes: [
2630
+ { content: "ok-1", path: "fresh-236-1" },
2631
+ { content: "ok-2", path: "fresh-236-2" },
2632
+ { content: "boom", path: "taken-236" },
2633
+ ],
2634
+ });
2635
+ } catch (e) {
2636
+ err = e;
2637
+ }
2638
+ expect(err).toBeTruthy();
2639
+
2640
+ // The two prefix items must NOT have been created — atomic rollback.
2641
+ const afterIds = (await store.queryNotes({})).map((n) => n.id).sort();
2642
+ expect(afterIds).toEqual(beforeIds);
2643
+ expect(await store.queryNotes({ path: "fresh-236-1" })).toHaveLength(0);
2644
+ expect(await store.queryNotes({ path: "fresh-236-2" })).toHaveLength(0);
2645
+ });
2646
+
2647
+ it("update-note batch rolls back prefix tag mutation when a later item path-conflicts (#236)", async () => {
2648
+ await store.createNote("A", { id: "a236" });
2649
+ await store.createNote("B", { id: "b236" });
2650
+ await store.createNote("C", { id: "c236", path: "occupied-236" });
2651
+ const tools = generateMcpTools(store);
2652
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2653
+
2654
+ const aBefore = await store.getNote("a236");
2655
+
2656
+ let err: any;
2657
+ try {
2658
+ await updateNote.execute({
2659
+ notes: [
2660
+ // Item 0 mutates a236's content + adds a tag. force=true skips
2661
+ // the if_updated_at precondition.
2662
+ { id: "a236", content: "A mutated", force: true, tags: { add: ["should-rollback"] } },
2663
+ // Item 1 tries to take a path already owned by c236 — PATH_CONFLICT.
2664
+ { id: "b236", path: "occupied-236", force: true },
2665
+ ],
2666
+ });
2667
+ } catch (e) {
2668
+ err = e;
2669
+ }
2670
+ expect(err?.code).toBe("PATH_CONFLICT");
2671
+
2672
+ // Item 0's tag-add + content change must be rolled back — the batch
2673
+ // transaction reverted them when item 1 path-conflicted (#236).
2674
+ const aAfter = await store.getNote("a236");
2675
+ expect(aAfter!.content).toBe(aBefore!.content);
2676
+ expect(aAfter!.tags ?? []).not.toContain("should-rollback");
2677
+ });
2678
+
2679
+ it("update-note batch rolls back prefix mutation when a later item if_updated_at-conflicts (#261)", async () => {
2680
+ // The companion to the PATH_CONFLICT test above. Item 1's stale
2681
+ // `if_updated_at` must surface as a ConflictError so the batch's
2682
+ // BEGIN/COMMIT wrap can roll back item 0's mutation.
2683
+ //
2684
+ // Pre-fix (vault#261): `noteOps.updateNote` checked `res.changes === 0`
2685
+ // to detect the precondition miss. Inside this multi-statement batch
2686
+ // transaction, `.changes` reported a stale non-zero value from prior
2687
+ // writes, so the conflict was silently missed and item 0's mutation
2688
+ // committed with item 1 ignored.
2689
+ //
2690
+ // Post-fix: the conditional UPDATE uses `RETURNING id` and detects the
2691
+ // miss directly from row presence. ConflictError fires; batch rolls back.
2692
+ //
2693
+ // Standalone bun:sqlite repro is pending — six attempted reductions
2694
+ // (basic txn, async/microtask, prepared-statement reuse, mcp-loop
2695
+ // mirror, hook-dispatch mirror, syncWikilinks-style writes) failed to
2696
+ // hit the .changes-stale path outside the full BunStore plumbing. The
2697
+ // bug surfaces only through `BunStore.updateNote` → hook dispatch.
2698
+ // See vault#261 for the audit trail.
2699
+ await store.createNote("A", { id: "a261" });
2700
+ await store.createNote("B", { id: "b261" });
2701
+ const tools = generateMcpTools(store);
2702
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2703
+
2704
+ const aBefore = await store.getNote("a261");
2705
+
2706
+ let err: any;
2707
+ try {
2708
+ await updateNote.execute({
2709
+ notes: [
2710
+ // Item 0 mutates a261's content + adds a tag (force, no precondition).
2711
+ { id: "a261", content: "A mutated", force: true, tags: { add: ["should-rollback"] } },
2712
+ // Item 1 has a stale if_updated_at on b261 — should ConflictError.
2713
+ { id: "b261", content: "B should-not-land", if_updated_at: "2020-01-01T00:00:00.000Z" },
2714
+ ],
2715
+ });
2716
+ } catch (e) {
2717
+ err = e;
2718
+ }
2719
+ expect(err?.code).toBe("CONFLICT");
2720
+
2721
+ // Item 0's tag-add + content change must be rolled back.
2722
+ const aAfter = await store.getNote("a261");
2723
+ expect(aAfter!.content).toBe(aBefore!.content);
2724
+ expect(aAfter!.tags ?? []).not.toContain("should-rollback");
2725
+ });
1691
2726
  });
1692
2727
 
1693
2728
  // ---- query-notes link expansion ----
@@ -1978,3 +3013,1213 @@ describe("query-notes link expansion", async () => {
1978
3013
  expect(result.content).not.toContain("unsummarized body");
1979
3014
  });
1980
3015
  });
3016
+
3017
+ // ---------------------------------------------------------------------------
3018
+ // Tag hierarchy via tags.parent_names (post-v14, patterns/tag-data-model.md)
3019
+ // ---------------------------------------------------------------------------
3020
+
3021
+ describe("tag hierarchy (tags.parent_names)", async () => {
3022
+ it("query for parent tag returns notes tagged with declared child", async () => {
3023
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3024
+ await store.createNote("voice note", { tags: ["voice"] });
3025
+ await store.createNote("text note", { tags: ["text"] });
3026
+
3027
+ const results = await store.queryNotes({ tags: ["manual"] });
3028
+ expect(results.length).toBe(1);
3029
+ expect(results[0]!.content).toBe("voice note");
3030
+ });
3031
+
3032
+ it("expands transitively across multiple levels", async () => {
3033
+ await store.upsertTagRecord("manual", { parent_names: ["note"] });
3034
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3035
+ await store.createNote("voice note", { tags: ["voice"] });
3036
+ await store.createNote("manual-only note", { tags: ["manual"] });
3037
+ await store.createNote("note-only note", { tags: ["note"] });
3038
+
3039
+ // #note matches all three (note + manual + voice).
3040
+ const noteResults = await store.queryNotes({ tags: ["note"] });
3041
+ expect(noteResults.length).toBe(3);
3042
+
3043
+ // #manual matches voice + manual-only, not note-only.
3044
+ const manualResults = await store.queryNotes({ tags: ["manual"] });
3045
+ expect(manualResults.map((n) => n.content).sort()).toEqual([
3046
+ "manual-only note",
3047
+ "voice note",
3048
+ ]);
3049
+ });
3050
+
3051
+ it("query for child does not match parent-tagged notes", async () => {
3052
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3053
+ await store.createNote("voice note", { tags: ["voice"] });
3054
+ await store.createNote("manual-only note", { tags: ["manual"] });
3055
+
3056
+ const results = await store.queryNotes({ tags: ["voice"] });
3057
+ expect(results.length).toBe(1);
3058
+ expect(results[0]!.content).toBe("voice note");
3059
+ });
3060
+
3061
+ it("supports multiple parents (diamond inheritance)", async () => {
3062
+ await store.upsertTagRecord("voice-meeting", { parent_names: ["voice", "meeting"] });
3063
+ await store.createNote("vm", { tags: ["voice-meeting"] });
3064
+ await store.createNote("v", { tags: ["voice"] });
3065
+ await store.createNote("m", { tags: ["meeting"] });
3066
+
3067
+ expect((await store.queryNotes({ tags: ["voice"] })).length).toBe(2); // v + vm
3068
+ expect((await store.queryNotes({ tags: ["meeting"] })).length).toBe(2); // m + vm
3069
+ });
3070
+
3071
+ it("hierarchy is invalidated when parent_names is set", async () => {
3072
+ await store.createNote("voice note", { tags: ["voice"] });
3073
+ // Before the parents are declared, #manual matches nothing.
3074
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
3075
+
3076
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3077
+
3078
+ // After upsert, the cache invalidates and the next query expands.
3079
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
3080
+ });
3081
+
3082
+ it("hierarchy is invalidated when parent_names is repointed", async () => {
3083
+ await store.createNote("voice note", { tags: ["voice"] });
3084
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3085
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
3086
+
3087
+ // Repoint the parent.
3088
+ await store.upsertTagRecord("voice", { parent_names: ["audio"] });
3089
+
3090
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
3091
+ expect((await store.queryNotes({ tags: ["audio"] })).length).toBe(1);
3092
+ });
3093
+
3094
+ it("hierarchy is invalidated when parent_names is cleared", async () => {
3095
+ await store.createNote("voice note", { tags: ["voice"] });
3096
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3097
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
3098
+
3099
+ await store.upsertTagRecord("voice", { parent_names: null });
3100
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
3101
+ });
3102
+
3103
+ it("hierarchy is invalidated when a parent tag is deleted", async () => {
3104
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3105
+ await store.createNote("voice note", { tags: ["voice"] });
3106
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
3107
+
3108
+ // Drop the child tag — the row holding the parent_names declaration
3109
+ // disappears, so the hierarchy edge goes with it.
3110
+ await store.deleteTag("voice");
3111
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
3112
+ });
3113
+
3114
+ it("tagMatch=any flattens all expansions across input tags", async () => {
3115
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3116
+ await store.createNote("v", { tags: ["voice"] });
3117
+ await store.createNote("p", { tags: ["project"] });
3118
+ await store.createNote("o", { tags: ["other"] });
3119
+
3120
+ const results = await store.queryNotes({
3121
+ tags: ["manual", "project"],
3122
+ tagMatch: "any",
3123
+ });
3124
+ expect(results.map((n) => n.content).sort()).toEqual(["p", "v"]);
3125
+ });
3126
+
3127
+ it("tagMatch=all (default) requires each input tag's expanded set to be present", async () => {
3128
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3129
+ // Note has both #voice (which satisfies #manual via expansion) AND #project.
3130
+ await store.createNote("vp", { tags: ["voice", "project"] });
3131
+ await store.createNote("p-only", { tags: ["project"] });
3132
+
3133
+ const results = await store.queryNotes({
3134
+ tags: ["manual", "project"], // default tagMatch=all
3135
+ });
3136
+ expect(results.length).toBe(1);
3137
+ expect(results[0]!.content).toBe("vp");
3138
+ });
3139
+
3140
+ it("tolerates a cycle without infinite-looping", async () => {
3141
+ await store.upsertTagRecord("a", { parent_names: ["b"] });
3142
+ await store.upsertTagRecord("b", { parent_names: ["a"] });
3143
+ await store.createNote("note-a", { tags: ["a"] });
3144
+
3145
+ // Both a and b should resolve without hanging; both reach the same set {a, b}.
3146
+ expect((await store.queryNotes({ tags: ["a"] })).length).toBe(1);
3147
+ expect((await store.queryNotes({ tags: ["b"] })).length).toBe(1);
3148
+ });
3149
+
3150
+ it("malformed parent_names JSON is ignored silently", async () => {
3151
+ // Stuff a malformed value into the column directly to simulate an
3152
+ // out-of-band write. The resolver should drop it without throwing.
3153
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3154
+ (store as any).db.prepare("UPDATE tags SET parent_names = ? WHERE name = ?")
3155
+ .run("not valid json {{{", "voice");
3156
+ // Force cache reload.
3157
+ (store as any)._tagHierarchy = null;
3158
+ await store.createNote("v", { tags: ["voice"] });
3159
+
3160
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
3161
+ expect((await store.queryNotes({ tags: ["voice"] })).length).toBe(1);
3162
+ });
3163
+
3164
+ it("a tag with no parent_names is a hierarchy no-op", async () => {
3165
+ await store.upsertTagRecord("voice", { description: "voice notes" });
3166
+ await store.createNote("v", { tags: ["voice"] });
3167
+ await store.createNote("m", { tags: ["manual"] });
3168
+
3169
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
3170
+ expect((await store.queryNotes({ tags: ["voice"] })).length).toBe(1);
3171
+ });
3172
+
3173
+ it("legacy `_tags/<name>` notes left in place do not affect the hierarchy", async () => {
3174
+ // Post-v14, the resolver reads tags.parent_names — not notes. A leftover
3175
+ // `_tags/*` note from a pre-v14 vault is harmless historical record.
3176
+ await store.createNote("legacy", {
3177
+ path: "_tags/voice",
3178
+ metadata: { parents: ["manual"] },
3179
+ });
3180
+ await store.createNote("voice note", { tags: ["voice"] });
3181
+
3182
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
3183
+ });
3184
+ });
3185
+
3186
+ // ---------------------------------------------------------------------------
3187
+ // Note schemas — table-driven (post-v15: `note_schemas` + `schema_mappings`)
3188
+ // ---------------------------------------------------------------------------
3189
+ // Originally written against the `_schemas/<name>` + `_schema_defaults`
3190
+ // notes-as-config convention (issue #177). Rewritten for vault#246: the
3191
+ // authoring surface is now `store.upsertNoteSchema` + `store.setSchemaMapping`
3192
+ // and the legacy notes are inert (left in place as audit trail).
3193
+
3194
+ describe("note schemas (note_schemas + schema_mappings)", async () => {
3195
+ it("returns no validation_status when no schemas are configured", async () => {
3196
+ const tools = generateMcpTools(store);
3197
+ const create = tools.find((t) => t.name === "create-note")!;
3198
+ const result = await create.execute({ content: "plain note" }) as any;
3199
+ expect(result.validation_status).toBeUndefined();
3200
+ });
3201
+
3202
+ it("attaches validation_status when a schema matches by tag", async () => {
3203
+ await store.upsertNoteSchema("task", {
3204
+ description: "A task",
3205
+ fields: { priority: { type: "string", enum: ["high", "medium", "low"] } },
3206
+ required: ["priority"],
3207
+ });
3208
+ await store.setSchemaMapping("task", "tag", "task");
3209
+
3210
+ const tools = generateMcpTools(store);
3211
+ const create = tools.find((t) => t.name === "create-note")!;
3212
+ const result = await create.execute({ content: "do thing", tags: ["task"] }) as any;
3213
+
3214
+ expect(result.validation_status).toBeTruthy();
3215
+ expect(result.validation_status.schemas).toEqual(["task"]);
3216
+ expect(result.validation_status.warnings.length).toBe(1);
3217
+ expect(result.validation_status.warnings[0].reason).toBe("missing_required");
3218
+ expect(result.validation_status.warnings[0].field).toBe("priority");
3219
+ });
3220
+
3221
+ it("attaches validation_status when a schema matches by path prefix", async () => {
3222
+ await store.upsertNoteSchema("journal-entry", {
3223
+ fields: { mood: { type: "string" } },
3224
+ required: ["mood"],
3225
+ });
3226
+ await store.setSchemaMapping("journal-entry", "path_prefix", "journal/");
3227
+
3228
+ const tools = generateMcpTools(store);
3229
+ const create = tools.find((t) => t.name === "create-note")!;
3230
+ const result = await create.execute({ content: "today", path: "journal/2026-04-29" }) as any;
3231
+
3232
+ expect(result.validation_status.schemas).toEqual(["journal-entry"]);
3233
+ expect(result.validation_status.warnings[0].reason).toBe("missing_required");
3234
+ });
3235
+
3236
+ it("validation passes (empty warnings) when required fields are present and types match", async () => {
3237
+ await store.upsertNoteSchema("task", {
3238
+ fields: {
3239
+ priority: { type: "string", enum: ["high", "medium", "low"] },
3240
+ done: { type: "boolean" },
3241
+ },
3242
+ required: ["priority"],
3243
+ });
3244
+ await store.setSchemaMapping("task", "tag", "task");
3245
+
3246
+ const tools = generateMcpTools(store);
3247
+ const create = tools.find((t) => t.name === "create-note")!;
3248
+ const result = await create.execute({
3249
+ content: "ok",
3250
+ tags: ["task"],
3251
+ metadata: { priority: "high", done: false },
3252
+ }) as any;
3253
+
3254
+ expect(result.validation_status.schemas).toEqual(["task"]);
3255
+ expect(result.validation_status.warnings).toEqual([]);
3256
+ });
3257
+
3258
+ it("type_mismatch warning when a field's value is the wrong type", async () => {
3259
+ await store.upsertNoteSchema("task", {
3260
+ fields: { priority: { type: "string" }, done: { type: "boolean" } },
3261
+ });
3262
+ await store.setSchemaMapping("task", "tag", "task");
3263
+
3264
+ const tools = generateMcpTools(store);
3265
+ const create = tools.find((t) => t.name === "create-note")!;
3266
+ const result = await create.execute({
3267
+ content: "x",
3268
+ tags: ["task"],
3269
+ metadata: { priority: "high", done: "yes" }, // wrong: should be boolean
3270
+ }) as any;
3271
+
3272
+ expect(result.validation_status.warnings.length).toBe(1);
3273
+ expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
3274
+ expect(result.validation_status.warnings[0].field).toBe("done");
3275
+ });
3276
+
3277
+ it("enum_mismatch warning when a field's value is outside the declared enum", async () => {
3278
+ await store.upsertNoteSchema("task", {
3279
+ fields: { priority: { type: "string", enum: ["high", "medium", "low"] } },
3280
+ });
3281
+ await store.setSchemaMapping("task", "tag", "task");
3282
+
3283
+ const tools = generateMcpTools(store);
3284
+ const create = tools.find((t) => t.name === "create-note")!;
3285
+ const result = await create.execute({
3286
+ content: "x",
3287
+ tags: ["task"],
3288
+ metadata: { priority: "ULTRA" },
3289
+ }) as any;
3290
+
3291
+ expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
3292
+ });
3293
+
3294
+ it("validation never blocks the write — note exists with warnings attached", async () => {
3295
+ await store.upsertNoteSchema("task", { required: ["priority"] });
3296
+ await store.setSchemaMapping("task", "tag", "task");
3297
+
3298
+ const tools = generateMcpTools(store);
3299
+ const create = tools.find((t) => t.name === "create-note")!;
3300
+ const result = await create.execute({ content: "x", tags: ["task"] }) as any;
3301
+
3302
+ expect(result.id).toBeTruthy();
3303
+ expect(result.validation_status.warnings.length).toBe(1);
3304
+
3305
+ const fetched = await store.getNote(result.id);
3306
+ expect(fetched).not.toBeNull();
3307
+ expect(fetched!.content).toBe("x");
3308
+ });
3309
+
3310
+ it("update-note also surfaces validation_status", async () => {
3311
+ await store.upsertNoteSchema("task", {
3312
+ fields: { priority: { type: "string", enum: ["high", "low"] } },
3313
+ required: ["priority"],
3314
+ });
3315
+ await store.setSchemaMapping("task", "tag", "task");
3316
+ const note = await store.createNote("body", { tags: ["task"], metadata: { priority: "high" } });
3317
+
3318
+ const tools = generateMcpTools(store);
3319
+ const update = tools.find((t) => t.name === "update-note")!;
3320
+ const result = await update.execute({
3321
+ id: note.id,
3322
+ metadata: { priority: "ULTRA" },
3323
+ if_updated_at: note.updatedAt,
3324
+ }) as any;
3325
+
3326
+ expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
3327
+ });
3328
+
3329
+ it("cache invalidates when a schema mapping is removed", async () => {
3330
+ await store.upsertNoteSchema("task", { required: ["a"] });
3331
+ await store.setSchemaMapping("task", "tag", "task");
3332
+
3333
+ const tools = generateMcpTools(store);
3334
+ const create = tools.find((t) => t.name === "create-note")!;
3335
+ let result = await create.execute({ content: "x", tags: ["task"] }) as any;
3336
+ expect(result.validation_status.warnings[0].field).toBe("a");
3337
+
3338
+ await store.deleteSchemaMapping("task", "tag", "task");
3339
+
3340
+ result = await create.execute({ content: "y", tags: ["task"] }) as any;
3341
+ expect(result.validation_status).toBeUndefined();
3342
+ });
3343
+
3344
+ it("cache invalidates when a schema is updated", async () => {
3345
+ await store.upsertNoteSchema("task", { required: ["a"] });
3346
+ await store.setSchemaMapping("task", "tag", "task");
3347
+
3348
+ const tools = generateMcpTools(store);
3349
+ const create = tools.find((t) => t.name === "create-note")!;
3350
+ let result = await create.execute({ content: "x", tags: ["task"] }) as any;
3351
+ expect(result.validation_status.warnings[0].field).toBe("a");
3352
+
3353
+ // Re-declare with a different required field; cache must reflect the change.
3354
+ await store.upsertNoteSchema("task", { required: ["b"] });
3355
+ result = await create.execute({ content: "y", tags: ["task"] }) as any;
3356
+ expect(result.validation_status.warnings[0].field).toBe("b");
3357
+ });
3358
+
3359
+ it("cache invalidates when a schema is deleted (cascades mappings)", async () => {
3360
+ await store.upsertNoteSchema("task", { required: ["a"] });
3361
+ await store.setSchemaMapping("task", "tag", "task");
3362
+
3363
+ const tools = generateMcpTools(store);
3364
+ const create = tools.find((t) => t.name === "create-note")!;
3365
+ let result = await create.execute({ content: "x", tags: ["task"] }) as any;
3366
+ expect(result.validation_status.warnings[0].field).toBe("a");
3367
+
3368
+ await store.deleteNoteSchema("task");
3369
+ // FK CASCADE drops the mapping too.
3370
+ expect(await store.listSchemaMappings({ schema_name: "task" })).toEqual([]);
3371
+
3372
+ result = await create.execute({ content: "y", tags: ["task"] }) as any;
3373
+ expect(result.validation_status).toBeUndefined();
3374
+ });
3375
+
3376
+ it("longest path prefix wins when multiple match", async () => {
3377
+ await store.upsertNoteSchema("journal-day", { required: ["mood"] });
3378
+ await store.upsertNoteSchema("journal-broad", { required: ["topic"] });
3379
+ await store.setSchemaMapping("journal-broad", "path_prefix", "journal/");
3380
+ await store.setSchemaMapping("journal-day", "path_prefix", "journal/2026/");
3381
+
3382
+ const tools = generateMcpTools(store);
3383
+ const create = tools.find((t) => t.name === "create-note")!;
3384
+ const result = await create.execute({ content: "x", path: "journal/2026/april" }) as any;
3385
+ expect(result.validation_status.schemas).toEqual(["journal-day"]);
3386
+ });
3387
+
3388
+ it("multiple schemas can apply (path + tag combine warnings)", async () => {
3389
+ await store.upsertNoteSchema("journal-entry", { required: ["mood"] });
3390
+ await store.upsertNoteSchema("task", { required: ["priority"] });
3391
+ await store.setSchemaMapping("journal-entry", "path_prefix", "journal/");
3392
+ await store.setSchemaMapping("task", "tag", "task");
3393
+
3394
+ const tools = generateMcpTools(store);
3395
+ const create = tools.find((t) => t.name === "create-note")!;
3396
+ const result = await create.execute({
3397
+ content: "x",
3398
+ path: "journal/today",
3399
+ tags: ["task"],
3400
+ }) as any;
3401
+
3402
+ expect(result.validation_status.schemas.sort()).toEqual(["journal-entry", "task"]);
3403
+ expect(result.validation_status.warnings.length).toBe(2);
3404
+ });
3405
+
3406
+ it("a schema with no mappings is harmless (no validation)", async () => {
3407
+ await store.upsertNoteSchema("orphan", { required: ["x"] });
3408
+
3409
+ const tools = generateMcpTools(store);
3410
+ const create = tools.find((t) => t.name === "create-note")!;
3411
+ const result = await create.execute({ content: "anything" }) as any;
3412
+ expect(result.validation_status).toBeUndefined();
3413
+ });
3414
+
3415
+ it("legacy `_schemas/<name>` notes are inert post-v15", async () => {
3416
+ // The notes still write/read fine — they're just no longer interpreted
3417
+ // as schema config. Nothing in note_schemas/schema_mappings → no validation.
3418
+ await store.createNote("", {
3419
+ path: "_schemas/task",
3420
+ metadata: { required: ["priority"] },
3421
+ });
3422
+ await store.createNote("", {
3423
+ path: "_schema_defaults",
3424
+ metadata: { tags: { task: "task" } },
3425
+ });
3426
+
3427
+ const tools = generateMcpTools(store);
3428
+ const create = tools.find((t) => t.name === "create-note")!;
3429
+ const result = await create.execute({ content: "x", tags: ["task"] }) as any;
3430
+ expect(result.validation_status).toBeUndefined();
3431
+ });
3432
+ });
3433
+
3434
+ // ---------------------------------------------------------------------------
3435
+ // note_schemas + schema_mappings — direct CRUD (vault#246)
3436
+ // ---------------------------------------------------------------------------
3437
+
3438
+ describe("note_schemas / schema_mappings CRUD", async () => {
3439
+ it("upsertNoteSchema partial-merge: undefined preserves, null clears", async () => {
3440
+ await store.upsertNoteSchema("task", {
3441
+ description: "A task",
3442
+ fields: { priority: { type: "string" } },
3443
+ required: ["priority"],
3444
+ });
3445
+
3446
+ // description omitted → preserved
3447
+ await store.upsertNoteSchema("task", { required: ["priority", "due"] });
3448
+ let row = await store.getNoteSchema("task");
3449
+ expect(row?.description).toBe("A task");
3450
+ expect(row?.required).toEqual(["priority", "due"]);
3451
+
3452
+ // description null → cleared
3453
+ await store.upsertNoteSchema("task", { description: null });
3454
+ row = await store.getNoteSchema("task");
3455
+ expect(row?.description).toBeNull();
3456
+ expect(row?.required).toEqual(["priority", "due"]); // still preserved
3457
+ });
3458
+
3459
+ it("empty `required: []` collapses to null", async () => {
3460
+ await store.upsertNoteSchema("task", { required: ["a", "b"] });
3461
+ await store.upsertNoteSchema("task", { required: [] });
3462
+ const row = await store.getNoteSchema("task");
3463
+ expect(row?.required).toBeNull();
3464
+ });
3465
+
3466
+ it("setSchemaMapping is idempotent (composite PK)", async () => {
3467
+ await store.upsertNoteSchema("task", {});
3468
+ await store.setSchemaMapping("task", "tag", "task");
3469
+ await store.setSchemaMapping("task", "tag", "task"); // re-set same triple
3470
+ const mappings = await store.listSchemaMappings({ schema_name: "task" });
3471
+ expect(mappings.length).toBe(1);
3472
+ });
3473
+
3474
+ it("setSchemaMapping rejects unknown match_kind", async () => {
3475
+ await store.upsertNoteSchema("task", {});
3476
+ await expect(
3477
+ store.setSchemaMapping("task", "BOGUS" as any, "task"),
3478
+ ).rejects.toThrow(/match_kind/);
3479
+ });
3480
+
3481
+ it("setSchemaMapping fails (FK) when schema doesn't exist", async () => {
3482
+ await expect(
3483
+ store.setSchemaMapping("missing", "tag", "x"),
3484
+ ).rejects.toThrow();
3485
+ });
3486
+
3487
+ it("deleteNoteSchema cascades schema_mappings (FK ON DELETE CASCADE)", async () => {
3488
+ await store.upsertNoteSchema("task", {});
3489
+ await store.setSchemaMapping("task", "tag", "task");
3490
+ await store.setSchemaMapping("task", "path_prefix", "Tasks/");
3491
+ expect((await store.listSchemaMappings({ schema_name: "task" })).length).toBe(2);
3492
+
3493
+ expect(await store.deleteNoteSchema("task")).toBe(true);
3494
+ expect(await store.listSchemaMappings({ schema_name: "task" })).toEqual([]);
3495
+ expect(await store.deleteNoteSchema("task")).toBe(false); // already gone
3496
+ });
3497
+
3498
+ it("listSchemaMappings filters by schema_name and match_kind", async () => {
3499
+ await store.upsertNoteSchema("a", {});
3500
+ await store.upsertNoteSchema("b", {});
3501
+ await store.setSchemaMapping("a", "tag", "a-tag");
3502
+ await store.setSchemaMapping("a", "path_prefix", "A/");
3503
+ await store.setSchemaMapping("b", "tag", "b-tag");
3504
+
3505
+ expect((await store.listSchemaMappings({ schema_name: "a" })).length).toBe(2);
3506
+ expect((await store.listSchemaMappings({ match_kind: "tag" })).length).toBe(2);
3507
+ expect((await store.listSchemaMappings({ schema_name: "a", match_kind: "tag" })).length).toBe(1);
3508
+ });
3509
+ });
3510
+
3511
+ describe("expandTagsWithDescendants (tag-scoped tokens — patterns/tag-scoped-tokens.md)", async () => {
3512
+ it("returns the union of root + every descendant per tags.parent_names", async () => {
3513
+ await store.upsertTagRecord("health/food", { parent_names: ["health"] });
3514
+ await store.upsertTagRecord("health/food/breakfast", { parent_names: ["health/food"] });
3515
+ await store.upsertTagRecord("work", { description: "work things" });
3516
+
3517
+ const expanded = await store.expandTagsWithDescendants(["health"]);
3518
+ expect(expanded.has("health")).toBe(true);
3519
+ expect(expanded.has("health/food")).toBe(true);
3520
+ expect(expanded.has("health/food/breakfast")).toBe(true);
3521
+ expect(expanded.has("work")).toBe(false);
3522
+ });
3523
+
3524
+ it("returns an empty set for an empty input (no allowlist = nothing to expand)", async () => {
3525
+ const expanded = await store.expandTagsWithDescendants([]);
3526
+ expect(expanded.size).toBe(0);
3527
+ });
3528
+
3529
+ it("includes the root verbatim even when the tag has no declared descendants", async () => {
3530
+ await store.createNote("solo", { tags: ["loner"] });
3531
+ const expanded = await store.expandTagsWithDescendants(["loner"]);
3532
+ expect([...expanded]).toEqual(["loner"]);
3533
+ });
3534
+
3535
+ it("unions descendants from multiple roots", async () => {
3536
+ await store.upsertTagRecord("health/food", { parent_names: ["health"] });
3537
+ await store.upsertTagRecord("work/standup", { parent_names: ["work"] });
3538
+ const expanded = await store.expandTagsWithDescendants(["health", "work"]);
3539
+ expect(expanded.has("health")).toBe(true);
3540
+ expect(expanded.has("health/food")).toBe(true);
3541
+ expect(expanded.has("work")).toBe(true);
3542
+ expect(expanded.has("work/standup")).toBe(true);
3543
+ });
3544
+ });
3545
+
3546
+ // ---------------------------------------------------------------------------
3547
+ // Tag record API — patterns/tag-data-model.md
3548
+ // ---------------------------------------------------------------------------
3549
+
3550
+ describe("tag record API (patterns/tag-data-model.md)", async () => {
3551
+ it("upsertTagRecord persists description + fields + relationships + parent_names", async () => {
3552
+ await store.upsertTagRecord("project", {
3553
+ description: "long-running deliverable",
3554
+ fields: { status: { type: "string", enum: ["active", "shipped"] } },
3555
+ relationships: {
3556
+ owned_by: { target_tag: "person", cardinality: "one", description: "DRI" },
3557
+ },
3558
+ parent_names: ["work"],
3559
+ });
3560
+ const r = await store.getTagRecord("project");
3561
+ expect(r?.description).toBe("long-running deliverable");
3562
+ expect(r?.fields?.status?.type).toBe("string");
3563
+ expect(r?.relationships?.owned_by?.target_tag).toBe("person");
3564
+ expect(r?.relationships?.owned_by?.cardinality).toBe("one");
3565
+ expect(r?.parent_names).toEqual(["work"]);
3566
+ expect(r?.created_at).toBeDefined();
3567
+ expect(r?.updated_at).toBeDefined();
3568
+ });
3569
+
3570
+ it("upsertTagRecord preserves columns left undefined in the patch", async () => {
3571
+ await store.upsertTagRecord("project", {
3572
+ description: "first",
3573
+ fields: { status: { type: "string" } },
3574
+ parent_names: ["work"],
3575
+ });
3576
+ await store.upsertTagRecord("project", { description: "second" });
3577
+ const r = await store.getTagRecord("project");
3578
+ expect(r?.description).toBe("second");
3579
+ expect(r?.fields?.status?.type).toBe("string");
3580
+ expect(r?.parent_names).toEqual(["work"]);
3581
+ });
3582
+
3583
+ it("upsertTagRecord clears a column when patch passes null", async () => {
3584
+ await store.upsertTagRecord("project", {
3585
+ description: "deliverable",
3586
+ parent_names: ["work"],
3587
+ });
3588
+ await store.upsertTagRecord("project", { parent_names: null });
3589
+ const r = await store.getTagRecord("project");
3590
+ expect(r?.description).toBe("deliverable");
3591
+ expect(r?.parent_names).toBeUndefined();
3592
+ });
3593
+
3594
+ it("listTagRecords returns every tag row, sorted by name", async () => {
3595
+ await store.upsertTagRecord("zebra", { description: "z" });
3596
+ await store.upsertTagRecord("alpha", { description: "a" });
3597
+ const records = await store.listTagRecords();
3598
+ const names = records.map((r) => r.tag);
3599
+ const idxAlpha = names.indexOf("alpha");
3600
+ const idxZebra = names.indexOf("zebra");
3601
+ expect(idxAlpha).toBeGreaterThanOrEqual(0);
3602
+ expect(idxZebra).toBeGreaterThan(idxAlpha);
3603
+ });
3604
+
3605
+ it("update-tag MCP rejects an invalid cardinality", async () => {
3606
+ const tools = generateMcpTools(store);
3607
+ const update = tools.find((t) => t.name === "update-tag")!;
3608
+ await expect(
3609
+ update.execute({
3610
+ tag: "project",
3611
+ relationships: {
3612
+ owned_by: { target_tag: "person", cardinality: "bogus" },
3613
+ },
3614
+ }),
3615
+ ).rejects.toThrow(/cardinality/);
3616
+ });
3617
+
3618
+ it("update-tag MCP accepts every cardinality in the named vocabulary", async () => {
3619
+ const tools = generateMcpTools(store);
3620
+ const update = tools.find((t) => t.name === "update-tag")!;
3621
+ for (const card of ["one", "optional", "many", "many-required"]) {
3622
+ await update.execute({
3623
+ tag: `tag-${card}`,
3624
+ relationships: {
3625
+ rel: { target_tag: "other", cardinality: card },
3626
+ },
3627
+ });
3628
+ const r = await store.getTagRecord(`tag-${card}`);
3629
+ expect(r?.relationships?.rel?.cardinality).toBe(card);
3630
+ }
3631
+ });
3632
+
3633
+ it("update-tag MCP rejects a relationship missing target_tag", async () => {
3634
+ const tools = generateMcpTools(store);
3635
+ const update = tools.find((t) => t.name === "update-tag")!;
3636
+ await expect(
3637
+ update.execute({
3638
+ tag: "project",
3639
+ relationships: { owned_by: { cardinality: "one" } },
3640
+ }),
3641
+ ).rejects.toThrow(/target_tag/);
3642
+ });
3643
+
3644
+ it("update-tag MCP sets parent_names and the hierarchy invalidates", async () => {
3645
+ const tools = generateMcpTools(store);
3646
+ const update = tools.find((t) => t.name === "update-tag")!;
3647
+
3648
+ await store.createNote("v note", { tags: ["voice"] });
3649
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
3650
+
3651
+ await update.execute({
3652
+ tag: "voice",
3653
+ parent_names: ["manual"],
3654
+ });
3655
+
3656
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
3657
+ });
3658
+
3659
+ it("update-tag MCP empty parent_names array clears the column", async () => {
3660
+ const tools = generateMcpTools(store);
3661
+ const update = tools.find((t) => t.name === "update-tag")!;
3662
+ await store.upsertTagRecord("voice", { parent_names: ["manual"] });
3663
+ await update.execute({ tag: "voice", parent_names: [] });
3664
+ const r = await store.getTagRecord("voice");
3665
+ expect(r?.parent_names).toBeUndefined();
3666
+ });
3667
+
3668
+ it("list-tags MCP single-tag detail includes relationships + parent_names", async () => {
3669
+ await store.upsertTagRecord("project", {
3670
+ description: "p",
3671
+ relationships: { owned_by: { target_tag: "person", cardinality: "one" } },
3672
+ parent_names: ["work"],
3673
+ });
3674
+ const tools = generateMcpTools(store);
3675
+ const listTags = tools.find((t) => t.name === "list-tags")!;
3676
+ const result = await listTags.execute({ tag: "project" }) as any;
3677
+ expect(result.relationships?.owned_by?.target_tag).toBe("person");
3678
+ expect(result.parent_names).toEqual(["work"]);
3679
+ expect(result.created_at).toBeDefined();
3680
+ });
3681
+
3682
+ it("list-tags MCP include_schema returns relationships + parent_names per tag", async () => {
3683
+ await store.upsertTagRecord("project", {
3684
+ relationships: { owned_by: { target_tag: "person", cardinality: "one" } },
3685
+ parent_names: ["work"],
3686
+ });
3687
+ await store.createNote("p note", { tags: ["project"] });
3688
+ const tools = generateMcpTools(store);
3689
+ const listTags = tools.find((t) => t.name === "list-tags")!;
3690
+ const all = await listTags.execute({ include_schema: true }) as any[];
3691
+ const project = all.find((t) => t.name === "project")!;
3692
+ expect(project.relationships?.owned_by?.target_tag).toBe("person");
3693
+ expect(project.parent_names).toEqual(["work"]);
3694
+ });
3695
+
3696
+ it("renameTag carries description + fields + relationships + parent_names onto the new row", async () => {
3697
+ await store.upsertTagRecord("old-name", {
3698
+ description: "before",
3699
+ fields: { status: { type: "string" } },
3700
+ relationships: { owned_by: { target_tag: "person", cardinality: "one" } },
3701
+ parent_names: ["work"],
3702
+ });
3703
+ const result = await store.renameTag("old-name", "new-name");
3704
+ expect("renamed" in result).toBe(true);
3705
+
3706
+ const renamed = await store.getTagRecord("new-name");
3707
+ expect(renamed?.description).toBe("before");
3708
+ expect(renamed?.fields?.status?.type).toBe("string");
3709
+ expect(renamed?.relationships?.owned_by?.target_tag).toBe("person");
3710
+ expect(renamed?.parent_names).toEqual(["work"]);
3711
+
3712
+ const old = await store.getTagRecord("old-name");
3713
+ expect(old).toBeNull();
3714
+ });
3715
+
3716
+ it("deleteTag drops the identity row + invalidates the hierarchy", async () => {
3717
+ await store.upsertTagRecord("voice", {
3718
+ description: "voice notes",
3719
+ parent_names: ["manual"],
3720
+ });
3721
+ await store.createNote("v", { tags: ["voice"] });
3722
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
3723
+
3724
+ await store.deleteTag("voice");
3725
+ expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
3726
+ expect(await store.getTagRecord("voice")).toBeNull();
3727
+ });
3728
+ });
3729
+
3730
+ // ---------------------------------------------------------------------------
3731
+ // Schema migration v13 → v14 — patterns/tag-data-model.md
3732
+ // ---------------------------------------------------------------------------
3733
+
3734
+ describe("schema migration v13 → v14", async () => {
3735
+ it("backfills tags.parent_names from `_tags/<name>` notes", async () => {
3736
+ // Simulate a pre-v14 vault by writing a `_tags/<name>` note + the
3737
+ // legacy tag_schemas row directly via a fresh DB at v13 shape.
3738
+ const { Database } = await import("bun:sqlite");
3739
+ const db = new Database(":memory:");
3740
+
3741
+ // Build the v13 shape inline: tags(name PK only), separate tag_schemas
3742
+ // table, plus a notes row at `_tags/voice`.
3743
+ db.exec("PRAGMA journal_mode = WAL");
3744
+ db.exec(`CREATE TABLE notes (
3745
+ id TEXT PRIMARY KEY, content TEXT DEFAULT '', path TEXT,
3746
+ metadata TEXT DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT
3747
+ )`);
3748
+ db.exec(`CREATE TABLE tags (name TEXT PRIMARY KEY)`);
3749
+ db.exec(`CREATE TABLE tag_schemas (
3750
+ tag_name TEXT PRIMARY KEY REFERENCES tags(name) ON DELETE CASCADE,
3751
+ description TEXT, fields TEXT
3752
+ )`);
3753
+
3754
+ db.prepare("INSERT INTO tags (name) VALUES (?)").run("voice");
3755
+ db.prepare("INSERT INTO tag_schemas (tag_name, description, fields) VALUES (?, ?, ?)")
3756
+ .run("voice", "voice notes", '{"recorded_at":{"type":"string"}}');
3757
+ db.prepare(`INSERT INTO notes (id, path, metadata, created_at) VALUES (?, ?, ?, ?)`)
3758
+ .run("n1", "_tags/voice", JSON.stringify({ parents: ["manual"] }), new Date().toISOString());
3759
+
3760
+ // Now run initSchema — it should add the v14 columns, copy schema +
3761
+ // hierarchy data onto the tags row, and drop tag_schemas.
3762
+ const { initSchema } = await import("./schema.ts");
3763
+ initSchema(db);
3764
+
3765
+ // tag_schemas should be gone.
3766
+ const tableExists = db.prepare(
3767
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='tag_schemas'",
3768
+ ).get();
3769
+ expect(tableExists).toBeNull();
3770
+
3771
+ // tags row should carry the migrated fields.
3772
+ const row = db.prepare(
3773
+ "SELECT name, description, fields, parent_names FROM tags WHERE name = 'voice'",
3774
+ ).get() as any;
3775
+ expect(row.description).toBe("voice notes");
3776
+ expect(JSON.parse(row.fields).recorded_at.type).toBe("string");
3777
+ expect(JSON.parse(row.parent_names)).toEqual(["manual"]);
3778
+
3779
+ // The `_tags/voice` note is left in place as harmless historical record.
3780
+ const note = db.prepare("SELECT id FROM notes WHERE path = '_tags/voice'").get();
3781
+ expect(note).toBeDefined();
3782
+
3783
+ db.close();
3784
+ });
3785
+
3786
+ it("is idempotent — running initSchema twice is a no-op the second time", async () => {
3787
+ const { Database } = await import("bun:sqlite");
3788
+ const { initSchema } = await import("./schema.ts");
3789
+ const db = new Database(":memory:");
3790
+ initSchema(db);
3791
+ db.prepare(`
3792
+ INSERT INTO tags (name, description, parent_names, created_at, updated_at)
3793
+ VALUES (?, ?, ?, ?, ?)
3794
+ `).run(
3795
+ "voice",
3796
+ "voice notes",
3797
+ JSON.stringify(["manual"]),
3798
+ new Date().toISOString(),
3799
+ new Date().toISOString(),
3800
+ );
3801
+
3802
+ // Second run must not throw, must not perturb the row, must not
3803
+ // reintroduce tag_schemas.
3804
+ initSchema(db);
3805
+
3806
+ const row = db.prepare("SELECT description, parent_names FROM tags WHERE name = 'voice'").get() as any;
3807
+ expect(row.description).toBe("voice notes");
3808
+ expect(JSON.parse(row.parent_names)).toEqual(["manual"]);
3809
+
3810
+ const tableExists = db.prepare(
3811
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='tag_schemas'",
3812
+ ).get();
3813
+ expect(tableExists).toBeNull();
3814
+ db.close();
3815
+ });
3816
+
3817
+ // vault#248 — the migration body is wrapped in BEGIN IMMEDIATE / COMMIT
3818
+ // with a try/catch ROLLBACK. A crash mid-migration must leave the DB in
3819
+ // its pre-migration state (NOT half-migrated), and a retry must converge
3820
+ // to the same final state as a clean run. The transaction wrap is what
3821
+ // makes that guarantee — the `hasColumn` / `hasTable` idempotency guards
3822
+ // are belt-and-suspenders, not load-bearing.
3823
+ it("crash mid-migration rolls back to pre-migration state, then retry succeeds", async () => {
3824
+ const { Database } = await import("bun:sqlite");
3825
+ const { initSchema } = await import("./schema.ts");
3826
+
3827
+ const db = new Database(":memory:");
3828
+ db.exec("PRAGMA journal_mode = WAL");
3829
+ db.exec(`CREATE TABLE notes (
3830
+ id TEXT PRIMARY KEY, content TEXT DEFAULT '', path TEXT,
3831
+ metadata TEXT DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT
3832
+ )`);
3833
+ db.exec(`CREATE TABLE tags (name TEXT PRIMARY KEY)`);
3834
+ db.exec(`CREATE TABLE tag_schemas (
3835
+ tag_name TEXT PRIMARY KEY REFERENCES tags(name) ON DELETE CASCADE,
3836
+ description TEXT, fields TEXT
3837
+ )`);
3838
+
3839
+ db.prepare("INSERT INTO tags (name) VALUES (?)").run("voice");
3840
+ db.prepare("INSERT INTO tag_schemas (tag_name, description, fields) VALUES (?, ?, ?)")
3841
+ .run("voice", "voice notes", '{"recorded_at":{"type":"string"}}');
3842
+ db.prepare(`INSERT INTO notes (id, path, metadata, created_at) VALUES (?, ?, ?, ?)`)
3843
+ .run("n1", "_tags/voice", JSON.stringify({ parents: ["manual"] }), new Date().toISOString());
3844
+
3845
+ // Patch db.exec to simulate a crash on the final DROP TABLE step. That's
3846
+ // the right injection point: every ALTER + data copy has already landed
3847
+ // inside the transaction, so a successful rollback proves the wrap
3848
+ // covers the full migration body — not just the tail.
3849
+ const origExec = db.exec.bind(db);
3850
+ let crashOnDrop: boolean = true;
3851
+ (db as any).exec = function (sql: string) {
3852
+ if (crashOnDrop && sql.includes("DROP TABLE tag_schemas")) {
3853
+ throw new Error("simulated crash mid-migration");
3854
+ }
3855
+ return origExec(sql);
3856
+ };
3857
+
3858
+ expect(() => initSchema(db)).toThrow("simulated crash mid-migration");
3859
+
3860
+ // Pre-migration shape: tag_schemas table still exists with its row,
3861
+ // tags table back to (name) only — none of the v14 columns landed,
3862
+ // `_tags/voice` note untouched, schema_version row not yet written.
3863
+ const tagSchemasStill = db.prepare(
3864
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='tag_schemas'",
3865
+ ).get();
3866
+ expect(tagSchemasStill).toBeTruthy();
3867
+ const schemaRow = db.prepare(
3868
+ "SELECT description, fields FROM tag_schemas WHERE tag_name = 'voice'",
3869
+ ).get() as any;
3870
+ expect(schemaRow.description).toBe("voice notes");
3871
+
3872
+ const tagsCols = db.prepare("PRAGMA table_info(tags)").all() as { name: string }[];
3873
+ const colNames = tagsCols.map((c) => c.name).sort();
3874
+ expect(colNames).toEqual(["name"]);
3875
+
3876
+ const note = db.prepare("SELECT path, metadata FROM notes WHERE id = 'n1'").get() as any;
3877
+ expect(note.path).toBe("_tags/voice");
3878
+ expect(JSON.parse(note.metadata).parents).toEqual(["manual"]);
3879
+
3880
+ // No lingering open transaction — a SELECT after rollback succeeds, and
3881
+ // a fresh BEGIN IMMEDIATE doesn't fail with "cannot start a transaction
3882
+ // within a transaction".
3883
+ db.exec("BEGIN IMMEDIATE");
3884
+ db.exec("ROLLBACK");
3885
+
3886
+ // Retry: drop the crash injection, run initSchema again. It must
3887
+ // converge to the same final post-v14 state as a clean run.
3888
+ crashOnDrop = false;
3889
+ initSchema(db);
3890
+
3891
+ const tableGone = db.prepare(
3892
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='tag_schemas'",
3893
+ ).get();
3894
+ expect(tableGone).toBeNull();
3895
+ const post = db.prepare(
3896
+ "SELECT description, fields, parent_names FROM tags WHERE name = 'voice'",
3897
+ ).get() as any;
3898
+ expect(post.description).toBe("voice notes");
3899
+ expect(JSON.parse(post.fields).recorded_at.type).toBe("string");
3900
+ expect(JSON.parse(post.parent_names)).toEqual(["manual"]);
3901
+
3902
+ db.close();
3903
+ });
3904
+ });
3905
+
3906
+ // ---------------------------------------------------------------------------
3907
+ // Schema migration v14 → v15 — vault#246 (_schemas/* + _schema_defaults retired)
3908
+ // ---------------------------------------------------------------------------
3909
+
3910
+ describe("schema migration v14 → v15", async () => {
3911
+ // Build a v14-shape DB by:
3912
+ // 1. Calling initSchema (so we get the full v15 shape, including
3913
+ // note_schemas / schema_mappings tables).
3914
+ // 2. Manually wiping the new tables, then writing the legacy
3915
+ // `_schemas/<name>` and `_schema_defaults` notes.
3916
+ // 3. Re-running initSchema — the migration's short-circuit (empty
3917
+ // destination tables) should kick in and copy the data over.
3918
+ async function buildV14ShapeWithLegacyNotes(): Promise<Database> {
3919
+ const { Database } = await import("bun:sqlite");
3920
+ const { initSchema } = await import("./schema.ts");
3921
+ const db = new Database(":memory:");
3922
+ initSchema(db);
3923
+
3924
+ // Write the legacy notes that the v14 vaults stored config in.
3925
+ const now = new Date().toISOString();
3926
+ const insertNote = db.prepare(
3927
+ "INSERT INTO notes (id, content, path, metadata, created_at) VALUES (?, ?, ?, ?, ?)",
3928
+ );
3929
+ insertNote.run(
3930
+ "s1",
3931
+ "",
3932
+ "_schemas/task",
3933
+ JSON.stringify({
3934
+ description: "A task",
3935
+ fields: { priority: { type: "string", enum: ["high", "low"] } },
3936
+ required: ["priority"],
3937
+ }),
3938
+ now,
3939
+ );
3940
+ insertNote.run(
3941
+ "s2",
3942
+ "",
3943
+ "_schemas/journal-entry",
3944
+ JSON.stringify({ fields: { mood: { type: "string" } } }),
3945
+ now,
3946
+ );
3947
+ insertNote.run(
3948
+ "d1",
3949
+ "",
3950
+ "_schema_defaults",
3951
+ JSON.stringify({
3952
+ path_prefixes: { "journal/": "journal-entry" },
3953
+ tags: { task: "task", "follow-up": "task" },
3954
+ }),
3955
+ now,
3956
+ );
3957
+
3958
+ // Wipe the destination tables so the migration short-circuit doesn't
3959
+ // fire (otherwise initSchema treats them as already migrated).
3960
+ db.exec("DELETE FROM schema_mappings");
3961
+ db.exec("DELETE FROM note_schemas");
3962
+
3963
+ return db;
3964
+ }
3965
+
3966
+ it("copies `_schemas/<name>` notes → note_schemas with description / fields / required", async () => {
3967
+ const db = await buildV14ShapeWithLegacyNotes();
3968
+ const { initSchema } = await import("./schema.ts");
3969
+ initSchema(db);
3970
+
3971
+ const taskRow = db.prepare(
3972
+ "SELECT description, fields, required FROM note_schemas WHERE name = 'task'",
3973
+ ).get() as any;
3974
+ expect(taskRow.description).toBe("A task");
3975
+ expect(JSON.parse(taskRow.fields).priority.enum).toEqual(["high", "low"]);
3976
+ expect(JSON.parse(taskRow.required)).toEqual(["priority"]);
3977
+
3978
+ const journalRow = db.prepare(
3979
+ "SELECT description, fields FROM note_schemas WHERE name = 'journal-entry'",
3980
+ ).get() as any;
3981
+ expect(journalRow.description).toBeNull();
3982
+ expect(JSON.parse(journalRow.fields).mood.type).toBe("string");
3983
+
3984
+ db.close();
3985
+ });
3986
+
3987
+ it("copies `_schema_defaults` → schema_mappings (path_prefixes + tags)", async () => {
3988
+ const db = await buildV14ShapeWithLegacyNotes();
3989
+ const { initSchema } = await import("./schema.ts");
3990
+ initSchema(db);
3991
+
3992
+ const mappings = db.prepare(
3993
+ "SELECT schema_name, match_kind, match_value FROM schema_mappings ORDER BY match_kind, match_value",
3994
+ ).all() as { schema_name: string; match_kind: string; match_value: string }[];
3995
+ expect(mappings).toEqual([
3996
+ { schema_name: "journal-entry", match_kind: "path_prefix", match_value: "journal/" },
3997
+ { schema_name: "task", match_kind: "tag", match_value: "follow-up" },
3998
+ { schema_name: "task", match_kind: "tag", match_value: "task" },
3999
+ ]);
4000
+
4001
+ db.close();
4002
+ });
4003
+
4004
+ it("auto-creates a stub schema row when a mapping references an undeclared schema (FK)", async () => {
4005
+ const { Database } = await import("bun:sqlite");
4006
+ const { initSchema } = await import("./schema.ts");
4007
+ const db = new Database(":memory:");
4008
+ initSchema(db);
4009
+
4010
+ // Only a defaults note — no `_schemas/orphan` definition.
4011
+ db.prepare(
4012
+ "INSERT INTO notes (id, content, path, metadata, created_at) VALUES (?, ?, ?, ?, ?)",
4013
+ ).run(
4014
+ "d",
4015
+ "",
4016
+ "_schema_defaults",
4017
+ JSON.stringify({ tags: { orphan: "orphan" } }),
4018
+ new Date().toISOString(),
4019
+ );
4020
+ db.exec("DELETE FROM schema_mappings");
4021
+ db.exec("DELETE FROM note_schemas");
4022
+
4023
+ initSchema(db);
4024
+
4025
+ // Stub schema row was created so the FK on schema_mappings holds.
4026
+ const stub = db.prepare("SELECT name FROM note_schemas WHERE name = 'orphan'").get();
4027
+ expect(stub).toBeTruthy();
4028
+ const mapping = db.prepare(
4029
+ "SELECT match_kind, match_value FROM schema_mappings WHERE schema_name = 'orphan'",
4030
+ ).get() as any;
4031
+ expect(mapping).toEqual({ match_kind: "tag", match_value: "orphan" });
4032
+
4033
+ db.close();
4034
+ });
4035
+
4036
+ it("legacy `_schemas/*` and `_schema_defaults` notes are left in place (audit trail)", async () => {
4037
+ const db = await buildV14ShapeWithLegacyNotes();
4038
+ const { initSchema } = await import("./schema.ts");
4039
+ initSchema(db);
4040
+
4041
+ const schemaNote = db.prepare("SELECT id FROM notes WHERE path = '_schemas/task'").get();
4042
+ expect(schemaNote).toBeTruthy();
4043
+ const defaultsNote = db.prepare("SELECT id FROM notes WHERE path = '_schema_defaults'").get();
4044
+ expect(defaultsNote).toBeTruthy();
4045
+
4046
+ db.close();
4047
+ });
4048
+
4049
+ it("is idempotent — second initSchema doesn't re-copy or duplicate rows", async () => {
4050
+ const db = await buildV14ShapeWithLegacyNotes();
4051
+ const { initSchema } = await import("./schema.ts");
4052
+ initSchema(db);
4053
+
4054
+ const schemaCount1 = (db.prepare("SELECT COUNT(*) as c FROM note_schemas").get() as any).c;
4055
+ const mappingCount1 = (db.prepare("SELECT COUNT(*) as c FROM schema_mappings").get() as any).c;
4056
+
4057
+ initSchema(db);
4058
+
4059
+ const schemaCount2 = (db.prepare("SELECT COUNT(*) as c FROM note_schemas").get() as any).c;
4060
+ const mappingCount2 = (db.prepare("SELECT COUNT(*) as c FROM schema_mappings").get() as any).c;
4061
+ expect(schemaCount2).toBe(schemaCount1);
4062
+ expect(mappingCount2).toBe(mappingCount1);
4063
+
4064
+ db.close();
4065
+ });
4066
+
4067
+ it("no legacy notes → migration is a no-op (empty tables)", async () => {
4068
+ const { Database } = await import("bun:sqlite");
4069
+ const { initSchema } = await import("./schema.ts");
4070
+ const db = new Database(":memory:");
4071
+ initSchema(db);
4072
+
4073
+ expect((db.prepare("SELECT COUNT(*) as c FROM note_schemas").get() as any).c).toBe(0);
4074
+ expect((db.prepare("SELECT COUNT(*) as c FROM schema_mappings").get() as any).c).toBe(0);
4075
+
4076
+ db.close();
4077
+ });
4078
+
4079
+ // Short-circuit uses `||` not `&&`: a vault with schemas-but-no-mappings
4080
+ // is a valid post-v15 state. Legacy `_schemas/*` notes left around from
4081
+ // a prior import shouldn't get re-folded on every boot. (The mirror case —
4082
+ // mappings-but-no-schemas — is structurally impossible because the
4083
+ // schema_mappings FK to note_schemas has ON DELETE CASCADE.)
4084
+ it("doesn't re-run when only one destination table is non-empty", async () => {
4085
+ const db = await buildV14ShapeWithLegacyNotes();
4086
+ const { initSchema } = await import("./schema.ts");
4087
+ initSchema(db);
4088
+
4089
+ // After first run: tables populated, legacy notes still in `notes`.
4090
+ expect((db.prepare("SELECT COUNT(*) as c FROM note_schemas").get() as any).c).toBeGreaterThan(0);
4091
+ expect((db.prepare("SELECT COUNT(*) as c FROM schema_mappings").get() as any).c).toBeGreaterThan(0);
4092
+ expect((db.prepare("SELECT COUNT(*) as c FROM notes WHERE path GLOB '_schemas/*'").get() as any).c).toBeGreaterThan(0);
4093
+
4094
+ // Wipe mappings only; schemas remain non-empty. With the buggy `&&`
4095
+ // short-circuit, the migration would re-scan `_schema_defaults` and
4096
+ // rebuild the mappings table. With `||` it correctly no-ops.
4097
+ db.exec("DELETE FROM schema_mappings");
4098
+ initSchema(db);
4099
+ expect((db.prepare("SELECT COUNT(*) as c FROM schema_mappings").get() as any).c).toBe(0);
4100
+
4101
+ db.close();
4102
+ });
4103
+ });
4104
+
4105
+ // ---------------------------------------------------------------------------
4106
+ // Schema migration v15 → v16 — vault#257 (tokens.vault_name binding)
4107
+ // ---------------------------------------------------------------------------
4108
+
4109
+ describe("schema migration v15 → v16", async () => {
4110
+ // vault#257 — the v16 migration body (ALTER TABLE ADD COLUMN + CREATE
4111
+ // INDEX) is wrapped in BEGIN IMMEDIATE / COMMIT with a try/catch
4112
+ // ROLLBACK, mirroring the v14/v15 wrap shape from vault#251. The
4113
+ // individual statements are atomic in SQLite, so the wrap is mostly
4114
+ // belt-and-suspenders for THIS migration — but the test mirrors v14's
4115
+ // crash-rollback shape so anyone touching migrations finds the same
4116
+ // regression-pin pattern across versions.
4117
+ it("crash mid-migration rolls back to pre-v16 state, then retry succeeds", async () => {
4118
+ const { Database } = await import("bun:sqlite");
4119
+ const { initSchema } = await import("./schema.ts");
4120
+
4121
+ // Build the full post-v16 shape, plant a row, then drop the v16
4122
+ // additions so initSchema's migrateToV16 fires on the next call.
4123
+ const db = new Database(":memory:");
4124
+ db.exec("PRAGMA journal_mode = WAL");
4125
+ initSchema(db);
4126
+
4127
+ const now = new Date().toISOString();
4128
+ db.prepare(
4129
+ "INSERT INTO tokens (token_hash, label, created_at, vault_name) VALUES (?, ?, ?, ?)",
4130
+ ).run("sha256:abc123def456", "pre-existing", now, "work");
4131
+
4132
+ db.exec("DROP INDEX IF EXISTS idx_tokens_vault_name");
4133
+ db.exec("ALTER TABLE tokens DROP COLUMN vault_name");
4134
+
4135
+ // Pre-condition: the column is gone but the row is still there
4136
+ // (DROP COLUMN strips the column from existing rows).
4137
+ const preCols = db.prepare("PRAGMA table_info(tokens)").all() as { name: string }[];
4138
+ expect(preCols.map((c) => c.name)).not.toContain("vault_name");
4139
+ const preRow = db.prepare("SELECT label FROM tokens WHERE token_hash = ?")
4140
+ .get("sha256:abc123def456") as { label: string } | null;
4141
+ expect(preRow?.label).toBe("pre-existing");
4142
+
4143
+ // Patch db.exec to crash on CREATE INDEX — the second statement inside
4144
+ // the v16 transaction body. Crashing here proves the wrap covers the
4145
+ // post-ALTER state, not just the tail. The injection deliberately
4146
+ // doesn't match BEGIN/COMMIT/ROLLBACK so the catch's ROLLBACK still
4147
+ // runs through the patched exec.
4148
+ const origExec = db.exec.bind(db);
4149
+ let crashOnIndex: boolean = true;
4150
+ (db as any).exec = function (sql: string) {
4151
+ if (crashOnIndex && sql.includes("CREATE INDEX") && sql.includes("idx_tokens_vault_name")) {
4152
+ throw new Error("simulated crash mid-v16-migration");
4153
+ }
4154
+ return origExec(sql);
4155
+ };
4156
+
4157
+ expect(() => initSchema(db)).toThrow("simulated crash mid-v16-migration");
4158
+
4159
+ // Pre-v16 shape after rollback: vault_name column must not exist; the
4160
+ // pre-existing row must be untouched.
4161
+ const colsAfterRollback = db.prepare("PRAGMA table_info(tokens)").all() as { name: string }[];
4162
+ expect(colsAfterRollback.map((c) => c.name)).not.toContain("vault_name");
4163
+ const idxAfterRollback = db.prepare(
4164
+ "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_tokens_vault_name'",
4165
+ ).get();
4166
+ expect(idxAfterRollback).toBeNull();
4167
+ const rowAfterRollback = db.prepare("SELECT label FROM tokens WHERE token_hash = ?")
4168
+ .get("sha256:abc123def456") as { label: string } | null;
4169
+ expect(rowAfterRollback?.label).toBe("pre-existing");
4170
+
4171
+ // No lingering open transaction — a fresh BEGIN IMMEDIATE + ROLLBACK
4172
+ // doesn't fail with "cannot start a transaction within a transaction".
4173
+ db.exec("BEGIN IMMEDIATE");
4174
+ db.exec("ROLLBACK");
4175
+
4176
+ // Retry: drop the crash injection, run initSchema again. Must converge
4177
+ // to post-v16 shape (column added, index created, lenient NULL backfill
4178
+ // on the pre-existing row per the migration spec).
4179
+ crashOnIndex = false;
4180
+ initSchema(db);
4181
+
4182
+ const colsPost = db.prepare("PRAGMA table_info(tokens)").all() as { name: string }[];
4183
+ expect(colsPost.map((c) => c.name)).toContain("vault_name");
4184
+ const idxPost = db.prepare(
4185
+ "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_tokens_vault_name'",
4186
+ ).get();
4187
+ expect(idxPost).toBeTruthy();
4188
+ const rowPost = db.prepare("SELECT label, vault_name FROM tokens WHERE token_hash = ?")
4189
+ .get("sha256:abc123def456") as { label: string; vault_name: string | null };
4190
+ expect(rowPost.label).toBe("pre-existing");
4191
+ expect(rowPost.vault_name).toBeNull();
4192
+
4193
+ db.close();
4194
+ });
4195
+ });
4196
+
4197
+ // ---------------------------------------------------------------------------
4198
+ // Tag-scope auth post-v14 — patterns/tag-scoped-tokens.md
4199
+ // ---------------------------------------------------------------------------
4200
+
4201
+ describe("tag-scope auth (post-v14 hierarchy)", async () => {
4202
+ it("token allowlisted for `health` matches descendants declared via parent_names", async () => {
4203
+ await store.upsertTagRecord("health/food", { parent_names: ["health"] });
4204
+ await store.upsertTagRecord("health/food/breakfast", { parent_names: ["health/food"] });
4205
+
4206
+ const expanded = await store.expandTagsWithDescendants(["health"]);
4207
+ expect(expanded.has("health")).toBe(true);
4208
+ expect(expanded.has("health/food")).toBe(true);
4209
+ expect(expanded.has("health/food/breakfast")).toBe(true);
4210
+ });
4211
+
4212
+ it("orphan sub-tag fallback: token for `health` still sees `#health/food` even with no declared hierarchy", async () => {
4213
+ // Per patterns/tag-scoped-tokens.md §Storage details, the auth check
4214
+ // also splits on '/' and matches the root verbatim against the raw
4215
+ // allowlist. This survives the v14 source-of-truth swap because the
4216
+ // fallback lives in src/tag-scope.ts, not in the resolver.
4217
+ const { noteWithinTagScope } = await import("../../src/tag-scope.ts");
4218
+ const note = { id: "x", content: "", createdAt: "", tags: ["health/food"] };
4219
+ const allowed = await store.expandTagsWithDescendants(["health"]);
4220
+ // No declared hierarchy — the expansion returns just `health`.
4221
+ expect(allowed.has("health/food")).toBe(false);
4222
+ // But the string-form fallback still matches.
4223
+ expect(noteWithinTagScope(note, allowed, ["health"])).toBe(true);
4224
+ });
4225
+ });