@openparachute/vault 0.7.0-rc.8 → 0.7.1

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 (38) hide show
  1. package/core/src/aggregate.test.ts +260 -0
  2. package/core/src/contract-taxonomy.test.ts +26 -2
  3. package/core/src/contract-typed-index.test.ts +4 -2
  4. package/core/src/core.test.ts +1151 -0
  5. package/core/src/cursor.ts +1 -0
  6. package/core/src/doctor.ts +23 -1
  7. package/core/src/indexed-fields.test.ts +4 -1
  8. package/core/src/indexed-fields.ts +6 -1
  9. package/core/src/links.ts +22 -0
  10. package/core/src/mcp.ts +587 -79
  11. package/core/src/notes.ts +371 -18
  12. package/core/src/query-warnings.ts +60 -12
  13. package/core/src/schema-defaults.ts +7 -1
  14. package/core/src/seed-packs.test.ts +14 -0
  15. package/core/src/seed-packs.ts +42 -5
  16. package/core/src/store.ts +129 -5
  17. package/core/src/tag-schemas.ts +20 -7
  18. package/core/src/types.ts +98 -0
  19. package/core/src/ulid.test.ts +56 -0
  20. package/core/src/ulid.ts +116 -0
  21. package/core/src/vault-projection.ts +19 -1
  22. package/core/src/wikilinks.test.ts +205 -1
  23. package/core/src/wikilinks.ts +244 -35
  24. package/package.json +1 -1
  25. package/src/aggregate-routes.test.ts +230 -0
  26. package/src/contract-errors.test.ts +2 -1
  27. package/src/contract-search.test.ts +17 -0
  28. package/src/mcp-link-warnings-scope.test.ts +144 -0
  29. package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
  30. package/src/mcp-tools.ts +100 -19
  31. package/src/routes.ts +469 -39
  32. package/src/routing.test.ts +167 -0
  33. package/src/routing.ts +47 -11
  34. package/src/tag-integrity-mcp.test.ts +45 -6
  35. package/src/tag-scope.ts +10 -1
  36. package/src/vault.test.ts +923 -21
  37. package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
  38. package/web/ui/dist/index.html +1 -1
@@ -6,6 +6,9 @@ import { initSchema } from "./schema.js";
6
6
  import { decodeCursor } from "./cursor.js";
7
7
  import { traverseLinks } from "./links.js";
8
8
  import * as indexedFieldOps from "./indexed-fields.js";
9
+ import { resolveLinkTarget } from "./wikilinks.js";
10
+ import { generateUlid, ULID_REGEX } from "./ulid.js";
11
+ import { getVaultMap, extractH1Title, findNotesByTitle, getNoteByTitle } from "./notes.js";
9
12
 
10
13
  let store: SqliteStore;
11
14
  let db: Database;
@@ -461,6 +464,66 @@ describe("notes.extension (vault#328)", async () => {
461
464
  });
462
465
  });
463
466
 
467
+ // ---- Title fallback (extractH1Title / findNotesByTitle / getNoteByTitle) ----
468
+
469
+ describe("extractH1Title", () => {
470
+ it("extracts the first level-1 heading", () => {
471
+ expect(extractH1Title("# My Title\n\nBody text.")).toBe("My Title");
472
+ });
473
+
474
+ it("finds the H1 even when it isn't the first line", () => {
475
+ expect(extractH1Title("---\nid: x\n---\n\n# My Title\n\nBody.")).toBe("My Title");
476
+ });
477
+
478
+ it("trims surrounding whitespace", () => {
479
+ expect(extractH1Title("# Spaced Out \nBody.")).toBe("Spaced Out");
480
+ });
481
+
482
+ it("does not match a level-2+ heading", () => {
483
+ expect(extractH1Title("## Not H1\n\nBody.")).toBeNull();
484
+ });
485
+
486
+ it("returns null when there is no heading", () => {
487
+ expect(extractH1Title("Just a paragraph, no heading.")).toBeNull();
488
+ });
489
+
490
+ it("returns null when the heading marker has no space", () => {
491
+ expect(extractH1Title("#NoSpace\n\nBody.")).toBeNull();
492
+ });
493
+
494
+ it("returns null for a blank heading", () => {
495
+ expect(extractH1Title("# \nBody.")).toBeNull();
496
+ });
497
+ });
498
+
499
+ describe("findNotesByTitle / getNoteByTitle", async () => {
500
+ it("finds a single note by its H1 title", async () => {
501
+ const note = await store.createNote("# Weekly Review\n\nBody.", { path: "Inbox/a1" });
502
+ const matches = findNotesByTitle(db, "Weekly Review");
503
+ expect(matches).toHaveLength(1);
504
+ expect(matches[0]!.id).toBe(note.id);
505
+
506
+ const found = getNoteByTitle(db, "Weekly Review");
507
+ expect(found?.id).toBe(note.id);
508
+ });
509
+
510
+ it("matches case-insensitively", async () => {
511
+ const note = await store.createNote("# Weekly Review\n\nBody.", { path: "Inbox/a2" });
512
+ expect(getNoteByTitle(db, "weekly review")?.id).toBe(note.id);
513
+ });
514
+
515
+ it("returns no match (not throw) when 2+ notes share a title — ambiguous", async () => {
516
+ await store.createNote("# Shared\n\nA.", { path: "Inbox/b1" });
517
+ await store.createNote("# Shared\n\nB.", { path: "Inbox/b2" });
518
+ expect(findNotesByTitle(db, "Shared")).toHaveLength(2);
519
+ expect(getNoteByTitle(db, "Shared")).toBeNull();
520
+ });
521
+
522
+ it("returns null for an unknown title", async () => {
523
+ expect(getNoteByTitle(db, "Nothing Like This")).toBeNull();
524
+ });
525
+ });
526
+
464
527
  // ---- Tags ----
465
528
 
466
529
  describe("tags", async () => {
@@ -1126,6 +1189,116 @@ describe("vault stats", async () => {
1126
1189
  });
1127
1190
  });
1128
1191
 
1192
+ // ---- Vault Map (front-door structural orientation) ----
1193
+
1194
+ describe("vault map", async () => {
1195
+ it("handles an empty vault gracefully", async () => {
1196
+ const map = getVaultMap(db);
1197
+ expect(map.total_notes).toBe(0);
1198
+ expect(map.tags).toEqual([]);
1199
+ expect(map.path_buckets).toEqual([]);
1200
+ expect(map.unfiled_notes).toBe(0);
1201
+ });
1202
+
1203
+ it("counts total notes, tag membership, and top-level path buckets", async () => {
1204
+ await store.createNote("a", { tags: ["person"], path: "People/Alice" });
1205
+ await store.createNote("b", { tags: ["person"], path: "People/Bob" });
1206
+ await store.createNote("c", { tags: ["project"], path: "Projects/Acme" });
1207
+ await store.createNote("d", { tags: ["project", "person"] }); // no path
1208
+
1209
+ const map = getVaultMap(db);
1210
+ expect(map.total_notes).toBe(4);
1211
+
1212
+ const tagByName = Object.fromEntries(map.tags.map((t) => [t.name, t.count]));
1213
+ expect(tagByName.person).toBe(3);
1214
+ expect(tagByName.project).toBe(2);
1215
+
1216
+ const bucketByName = Object.fromEntries(map.path_buckets.map((b) => [b.name, b.count]));
1217
+ expect(bucketByName.People).toBe(2);
1218
+ expect(bucketByName.Projects).toBe(1);
1219
+
1220
+ expect(map.unfiled_notes).toBe(1);
1221
+ // Invariant: unfiled + every bucket's count == total_notes (a note has at
1222
+ // most one path, so buckets + unfiled partition the vault exactly).
1223
+ const bucketSum = map.path_buckets.reduce((sum, b) => sum + b.count, 0);
1224
+ expect(bucketSum + map.unfiled_notes).toBe(map.total_notes);
1225
+ });
1226
+
1227
+ it("buckets a top-level (no-slash) path under its own full name", async () => {
1228
+ await store.createNote("solo", { path: "Welcome" });
1229
+ const map = getVaultMap(db);
1230
+ expect(map.path_buckets).toEqual([{ name: "Welcome", count: 1 }]);
1231
+ expect(map.unfiled_notes).toBe(0);
1232
+ });
1233
+
1234
+ it("tags are sorted by count desc, then name asc", async () => {
1235
+ for (let i = 0; i < 3; i++) await store.createNote(`m-${i}`, { tags: ["popular"] });
1236
+ await store.createNote("z", { tags: ["zzz-rare"] });
1237
+ await store.createNote("a", { tags: ["aaa-rare"] });
1238
+
1239
+ const map = getVaultMap(db);
1240
+ expect(map.tags).toEqual([
1241
+ { name: "popular", count: 3 },
1242
+ { name: "aaa-rare", count: 1 },
1243
+ { name: "zzz-rare", count: 1 },
1244
+ ]);
1245
+ });
1246
+
1247
+ describe("tagFilter (scope-aware path)", () => {
1248
+ it("omitting tagFilter computes the vault-wide map (unchanged default)", async () => {
1249
+ await store.createNote("a", { tags: ["work"], path: "Work/One" });
1250
+ await store.createNote("b", { tags: ["personal"], path: "Personal/Two" });
1251
+
1252
+ const map = getVaultMap(db);
1253
+ expect(map.total_notes).toBe(2);
1254
+ expect(map.tags.map((t) => t.name).sort()).toEqual(["personal", "work"]);
1255
+ expect(map.path_buckets.map((b) => b.name).sort()).toEqual(["Personal", "Work"]);
1256
+ });
1257
+
1258
+ it("an empty tagFilter array means nothing is in scope — all-zero map, NOT the full vault", async () => {
1259
+ await store.createNote("a", { tags: ["work"], path: "Work/One" });
1260
+
1261
+ const map = getVaultMap(db, { tagFilter: [] });
1262
+ expect(map).toEqual({ total_notes: 0, tags: [], path_buckets: [], unfiled_notes: 0 });
1263
+ });
1264
+
1265
+ it("a non-empty tagFilter restricts every count to notes reachable through that tag set", async () => {
1266
+ await store.createNote("a", { tags: ["work"], path: "Work/One" });
1267
+ await store.createNote("b", { tags: ["work", "urgent"], path: "Work/Two" });
1268
+ await store.createNote("c", { tags: ["personal"], path: "Personal/Three" });
1269
+ await store.createNote("d", { tags: ["personal"] }); // no path
1270
+
1271
+ const map = getVaultMap(db, { tagFilter: ["work", "urgent"] });
1272
+ expect(map.total_notes).toBe(2); // a, b — "personal" notes excluded
1273
+ expect(map.tags).toEqual([
1274
+ { name: "work", count: 2 },
1275
+ { name: "urgent", count: 1 },
1276
+ ]);
1277
+ expect(map.path_buckets).toEqual([{ name: "Work", count: 2 }]);
1278
+ expect(map.unfiled_notes).toBe(0);
1279
+ });
1280
+
1281
+ it("a note carrying both an in-scope and out-of-scope tag is counted once, not double", async () => {
1282
+ await store.createNote("a", { tags: ["work", "personal"], path: "Work/One" });
1283
+
1284
+ const map = getVaultMap(db, { tagFilter: ["work"] });
1285
+ expect(map.total_notes).toBe(1);
1286
+ expect(map.path_buckets).toEqual([{ name: "Work", count: 1 }]);
1287
+ // "personal" is out of scope — must not leak into the tags list.
1288
+ expect(map.tags.map((t) => t.name)).not.toContain("personal");
1289
+ });
1290
+
1291
+ it("scoped unfiled_notes counts only in-scope path-less notes", async () => {
1292
+ await store.createNote("a", { tags: ["work"] }); // no path, in scope
1293
+ await store.createNote("b", { tags: ["personal"] }); // no path, out of scope
1294
+
1295
+ const map = getVaultMap(db, { tagFilter: ["work"] });
1296
+ expect(map.unfiled_notes).toBe(1);
1297
+ expect(map.total_notes).toBe(1);
1298
+ });
1299
+ });
1300
+ });
1301
+
1129
1302
  // ---- Query ----
1130
1303
 
1131
1304
  describe("queryNotes", async () => {
@@ -1615,6 +1788,119 @@ describe("queryNotes", async () => {
1615
1788
  });
1616
1789
  });
1617
1790
 
1791
+ describe("ULID ids for new notes (existing IDs unchanged)", () => {
1792
+ function pinUpdatedAt(id: string, iso: string) {
1793
+ db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?").run(iso, id);
1794
+ }
1795
+
1796
+ it("new notes get ULID-format ids (26-char Crockford base32)", async () => {
1797
+ const note = await store.createNote("fresh note");
1798
+ expect(note.id).toHaveLength(26);
1799
+ expect(note.id).toMatch(ULID_REGEX);
1800
+ });
1801
+
1802
+ it("generateUlid() output matches the ULID format directly", () => {
1803
+ const id = generateUlid();
1804
+ expect(id).toHaveLength(26);
1805
+ expect(id).toMatch(ULID_REGEX);
1806
+ });
1807
+
1808
+ it("ULIDs minted in the same millisecond are monotonically increasing", () => {
1809
+ const ids = Array.from({ length: 200 }, () => generateUlid());
1810
+ // No duplicates, and strictly increasing under plain string compare —
1811
+ // this is the monotonic-within-a-ms guarantee, not just uniqueness.
1812
+ const unique = new Set(ids);
1813
+ expect(unique.size).toBe(ids.length);
1814
+ for (let i = 1; i < ids.length; i++) {
1815
+ expect(ids[i]! > ids[i - 1]!).toBe(true);
1816
+ }
1817
+ });
1818
+
1819
+ it("an explicitly-supplied old-format id still round-trips (create/read/link/resolve)", async () => {
1820
+ const oldId = "2020-01-01-00-00-00-000001";
1821
+
1822
+ // create — old-format id passed explicitly via opts.id.
1823
+ const oldNote = await store.createNote("legacy note", { id: oldId, path: "legacy-note" });
1824
+ expect(oldNote.id).toBe(oldId);
1825
+
1826
+ // read — by id and by path.
1827
+ const byId = await store.getNote(oldId);
1828
+ expect(byId?.id).toBe(oldId);
1829
+ expect(byId?.content).toBe("legacy note");
1830
+ const byPath = await store.getNoteByPath("legacy-note");
1831
+ expect(byPath?.id).toBe(oldId);
1832
+
1833
+ // link — a brand-new (ULID) note links to the old-format note, and
1834
+ // vice versa; both directions traverse the graph correctly.
1835
+ const newNote = await store.createNote("modern note");
1836
+ expect(newNote.id).toMatch(ULID_REGEX);
1837
+ await store.createLink(newNote.id, oldId, "references");
1838
+ await store.createLink(oldId, newNote.id, "referenced-by");
1839
+
1840
+ const outbound = await store.getLinks(newNote.id, { direction: "outbound" });
1841
+ expect(outbound.some((l) => l.targetId === oldId)).toBe(true);
1842
+ const inbound = await store.getLinks(newNote.id, { direction: "inbound" });
1843
+ expect(inbound.some((l) => l.sourceId === oldId)).toBe(true);
1844
+
1845
+ // resolve — resolveLinkTarget's id-then-path-then-title chain works
1846
+ // identically for an old-format id as it does for a ULID.
1847
+ expect(resolveLinkTarget(db, oldId)).toBe(oldId);
1848
+ expect(resolveLinkTarget(db, "legacy-note")).toBe(oldId);
1849
+ expect(resolveLinkTarget(db, newNote.id)).toBe(newNote.id);
1850
+ });
1851
+
1852
+ it("mixed-format cursor pagination is stable under a shared updated_at (no miss/dupe)", async () => {
1853
+ const ts = "2026-05-01T00:00:00.000Z";
1854
+ const ids: string[] = [];
1855
+
1856
+ // Two "old" notes with explicit timestamp-format ids...
1857
+ for (const oldId of ["2020-01-01-00-00-00-000001", "2020-06-15-12-30-00-500000"]) {
1858
+ const n = await store.createNote(`old ${oldId}`, { id: oldId });
1859
+ pinUpdatedAt(n.id, ts);
1860
+ ids.push(n.id);
1861
+ }
1862
+ // ...and three "new" notes with auto-generated ULIDs, all sharing the
1863
+ // exact same updated_at — id is the ONLY thing that can order them.
1864
+ for (let i = 0; i < 3; i++) {
1865
+ const n = await store.createNote(`new ${i}`);
1866
+ expect(n.id).toMatch(ULID_REGEX);
1867
+ pinUpdatedAt(n.id, ts);
1868
+ ids.push(n.id);
1869
+ }
1870
+
1871
+ // The expected stable total order is plain string comparison over the
1872
+ // id column — same rule the SQL keyset ORDER BY uses (BINARY/ASCII
1873
+ // collation), which holds regardless of id format.
1874
+ const expectedOrder = [...ids].sort();
1875
+
1876
+ // Paginate with a small limit to force several pages across the
1877
+ // format boundary and confirm no note is skipped or repeated.
1878
+ //
1879
+ // The first call MUST pass `cursor: ""` (the documented bootstrap
1880
+ // value — see queryNotes's cursor-mode doc comment in
1881
+ // core/src/notes.ts), NOT omit `cursor` entirely. Omitting it opts
1882
+ // OUT of cursor/keyset mode altogether, which orders the first page
1883
+ // by `created_at` (insertion order) instead of `updated_at` — a
1884
+ // DIFFERENT order than every subsequent (real-cursor) page uses. That
1885
+ // mismatch produces a watermark from a page in the wrong order, which
1886
+ // can then skip rows once keyset mode takes over on page 2 — exactly
1887
+ // the bug this test exists to catch, so the harness itself must use
1888
+ // the correct bootstrap contract or it can flake based on whether
1889
+ // `created_at` happens to tie across the fixture's notes.
1890
+ const seen: string[] = [];
1891
+ let cursor = "";
1892
+ for (let page = 0; page < 10; page++) {
1893
+ const result = await store.queryNotesPaged({ limit: 2, cursor });
1894
+ if (result.notes.length === 0) break;
1895
+ seen.push(...result.notes.map((n) => n.id));
1896
+ cursor = result.next_cursor;
1897
+ }
1898
+
1899
+ expect(seen).toEqual(expectedOrder);
1900
+ expect(new Set(seen).size).toBe(seen.length);
1901
+ });
1902
+ });
1903
+
1618
1904
  it("limits results", async () => {
1619
1905
  for (let i = 0; i < 5; i++) await store.createNote(`Note ${i}`);
1620
1906
  const results = await store.queryNotes({ limit: 3 });
@@ -2355,6 +2641,164 @@ describe("MCP tools", async () => {
2355
2641
  expect(links.some((l) => l.targetId === target.id && l.relationship === "knows")).toBe(true);
2356
2642
  });
2357
2643
 
2644
+ // vault#570 — content-parsed [[wikilinks]] to a missing target used to
2645
+ // fire NO write-time warning at all, even though the equivalent
2646
+ // structured `links` entry (tested above) already did. This closes that
2647
+ // asymmetry: the SAME `unresolved_link` warning shape, sourced from
2648
+ // content instead of `links`.
2649
+ it("create-note with a content [[wikilink]] to a missing target: warns (unresolved_link), same as structured links", async () => {
2650
+ const tools = generateMcpTools(store);
2651
+ const createNote = tools.find((t) => t.name === "create-note")!;
2652
+ const source = await createNote.execute({
2653
+ content: "See [[Missing Note]] for details.",
2654
+ }) as any;
2655
+
2656
+ expect(source.warnings).toBeDefined();
2657
+ expect(source.warnings).toHaveLength(1);
2658
+ expect(source.warnings[0].code).toBe("unresolved_link");
2659
+ expect(source.warnings[0].target).toBe("Missing Note");
2660
+ expect(source.warnings[0].relationship).toBe("wikilink");
2661
+ expect(await store.getLinks(source.id, { direction: "outbound" })).toHaveLength(0);
2662
+
2663
+ // Backfills automatically, same as the structured-link contract.
2664
+ const target = await store.createNote("here now", { path: "Missing Note" });
2665
+ const links = await store.getLinks(source.id, { direction: "outbound" });
2666
+ expect(links).toHaveLength(1);
2667
+ expect(links[0]!.targetId).toBe(target.id);
2668
+ });
2669
+
2670
+ // vault#570 — a target matching ≥2 notes (e.g. two notes sharing a
2671
+ // basename/title) is a factually different situation from "no note
2672
+ // matches" — reporting it as `unresolved_link` ("did not resolve to any
2673
+ // note") would be WRONG (it matched two, not zero). Must surface a
2674
+ // distinct `ambiguous_link` warning instead, and must NOT create an edge
2675
+ // (there's no principled way to pick one of the two candidates).
2676
+ it("create-note with a content [[wikilink]] to an AMBIGUOUS target (2 same-title notes): ambiguous_link, not unresolved_link, no edge", async () => {
2677
+ const a = await store.createNote("A", { path: "Folder1/Shared Title" });
2678
+ const b = await store.createNote("B", { path: "Folder2/Shared Title" });
2679
+
2680
+ const tools = generateMcpTools(store);
2681
+ const createNote = tools.find((t) => t.name === "create-note")!;
2682
+ const source = await createNote.execute({
2683
+ content: "See [[Shared Title]] for details.",
2684
+ }) as any;
2685
+
2686
+ expect(source.warnings).toBeDefined();
2687
+ expect(source.warnings).toHaveLength(1);
2688
+ expect(source.warnings[0].code).toBe("ambiguous_link");
2689
+ expect(source.warnings[0].target).toBe("Shared Title");
2690
+ expect(source.warnings[0].relationship).toBe("wikilink");
2691
+ expect(source.warnings[0].candidate_count).toBe(2);
2692
+
2693
+ // No edge created to EITHER candidate.
2694
+ const links = await store.getLinks(source.id, { direction: "outbound" });
2695
+ expect(links).toHaveLength(0);
2696
+ expect(links.some((l) => l.targetId === a.id || l.targetId === b.id)).toBe(false);
2697
+
2698
+ // Not queued for lazy resolution either — a future note being created
2699
+ // can't retroactively resolve an ambiguity between two EXISTING notes.
2700
+ // The table itself may not even exist yet (nothing else in this test
2701
+ // ever queued anything) — `PRAGMA table_info` on a nonexistent table
2702
+ // returns zero rows rather than throwing, same tolerance the wikilinks
2703
+ // module itself uses.
2704
+ const tableExists = (db.prepare("PRAGMA table_info(unresolved_wikilinks)").all() as unknown[]).length > 0;
2705
+ if (tableExists) {
2706
+ const pending = db.prepare(
2707
+ "SELECT * FROM unresolved_wikilinks WHERE source_id = ?",
2708
+ ).all(source.id) as unknown[];
2709
+ expect(pending).toHaveLength(0);
2710
+ }
2711
+ });
2712
+
2713
+ // vault#570 — the same ambiguous-target treatment applies to structured
2714
+ // `links`, not just content [[wikilinks]] (resolveOrQueueLink is the
2715
+ // shared implementation both paths call).
2716
+ it("create-note with a structured link to an AMBIGUOUS target: ambiguous_link, no edge", async () => {
2717
+ await store.createNote("A", { path: "Folder1/Dup" });
2718
+ await store.createNote("B", { path: "Folder2/Dup" });
2719
+
2720
+ const tools = generateMcpTools(store);
2721
+ const createNote = tools.find((t) => t.name === "create-note")!;
2722
+ const source = await createNote.execute({
2723
+ content: "no wikilinks here",
2724
+ links: [{ target: "Dup", relationship: "knows" }],
2725
+ }) as any;
2726
+
2727
+ expect(source.warnings).toBeDefined();
2728
+ expect(source.warnings).toHaveLength(1);
2729
+ expect(source.warnings[0].code).toBe("ambiguous_link");
2730
+ expect(source.warnings[0].target).toBe("Dup");
2731
+ expect(source.warnings[0].relationship).toBe("knows");
2732
+ expect(source.warnings[0].candidate_count).toBe(2);
2733
+ expect(await store.getLinks(source.id, { direction: "outbound" })).toHaveLength(0);
2734
+ });
2735
+
2736
+ // vault#570 — a content [[wikilink]] to a note created LATER in the same
2737
+ // create-note batch must resolve silently (forward-ref), exactly like
2738
+ // structured `links` already do — no spurious `unresolved_link` warning
2739
+ // just because the classification pass runs before this fix would have.
2740
+ it("create-note batch: a content [[wikilink]] to a note created LATER in the same batch resolves without warning", async () => {
2741
+ const tools = generateMcpTools(store);
2742
+ const createNote = tools.find((t) => t.name === "create-note")!;
2743
+ const result = await createNote.execute({
2744
+ notes: [
2745
+ { path: "WikiA", content: "links to [[WikiB]]" },
2746
+ { path: "WikiB", content: "the target" },
2747
+ ],
2748
+ }) as any[];
2749
+ const a = result.find((n: any) => n.path === "WikiA");
2750
+ expect(a.warnings).toBeUndefined();
2751
+ const links = await store.getLinks(a.id, { direction: "outbound" });
2752
+ expect(links).toHaveLength(1);
2753
+ expect(links[0]!.relationship).toBe("wikilink");
2754
+ });
2755
+
2756
+ // vault#570 — update-note's content replace path gets the same
2757
+ // unresolved_link/ambiguous_link treatment as create-note.
2758
+ it("update-note content replace with a [[wikilink]] to a missing target: warns (unresolved_link)", async () => {
2759
+ const note = await store.createNote("plain content", { path: "Updatable" });
2760
+ const tools = generateMcpTools(store);
2761
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2762
+
2763
+ const result = await updateNote.execute({
2764
+ id: note.id,
2765
+ content: "now references [[Not Yet Real]]",
2766
+ force: true,
2767
+ }) as any;
2768
+
2769
+ expect(result.warnings).toBeDefined();
2770
+ expect(result.warnings[0].code).toBe("unresolved_link");
2771
+ expect(result.warnings[0].target).toBe("Not Yet Real");
2772
+
2773
+ // A tags/links-only update on the SAME note must NOT re-surface the
2774
+ // warning — nothing about content changed on this call.
2775
+ const tagOnly = await updateNote.execute({
2776
+ id: note.id,
2777
+ tags: { add: ["x"] },
2778
+ force: true,
2779
+ }) as any;
2780
+ expect(tagOnly.warnings).toBeUndefined();
2781
+ });
2782
+
2783
+ it("update-note content replace with a [[wikilink]] to an AMBIGUOUS target: ambiguous_link, no edge", async () => {
2784
+ await store.createNote("A", { path: "Folder1/Both" });
2785
+ await store.createNote("B", { path: "Folder2/Both" });
2786
+ const note = await store.createNote("plain content", { path: "Updatable2" });
2787
+ const tools = generateMcpTools(store);
2788
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2789
+
2790
+ const result = await updateNote.execute({
2791
+ id: note.id,
2792
+ content: "now references [[Both]]",
2793
+ force: true,
2794
+ }) as any;
2795
+
2796
+ expect(result.warnings).toBeDefined();
2797
+ expect(result.warnings[0].code).toBe("ambiguous_link");
2798
+ expect(result.warnings[0].candidate_count).toBe(2);
2799
+ expect(await store.getLinks(note.id, { direction: "outbound" })).toHaveLength(0);
2800
+ });
2801
+
2358
2802
  it("update-note tool updates created_at", async () => {
2359
2803
  const note = await store.createNote("Test");
2360
2804
  const tools = generateMcpTools(store);
@@ -3347,6 +3791,36 @@ describe("MCP tools", async () => {
3347
3791
  expect(result.content).toBe("By Path");
3348
3792
  });
3349
3793
 
3794
+ // ---- Title-fallback id resolution (additive — vault "title-fallback link + id resolution") ----
3795
+
3796
+ it("query-notes single note by H1 title when id AND path both miss", async () => {
3797
+ await store.createNote("# My Great Note\n\nBody.", { path: "Inbox/2026-07-10-xyz" });
3798
+ const tools = generateMcpTools(store);
3799
+ const query = tools.find((t) => t.name === "query-notes")!;
3800
+ const result = await query.execute({ id: "My Great Note" }) as any;
3801
+ expect(result.content).toBe("# My Great Note\n\nBody.");
3802
+ expect(result.path).toBe("Inbox/2026-07-10-xyz");
3803
+ });
3804
+
3805
+ it("query-notes exact path still wins over a same-named title on another note", async () => {
3806
+ const byPath = await store.createNote("Path note", { path: "My Great Note" });
3807
+ await store.createNote("# My Great Note\n\nOther body.", { path: "Inbox/other" });
3808
+ const tools = generateMcpTools(store);
3809
+ const query = tools.find((t) => t.name === "query-notes")!;
3810
+ const result = await query.execute({ id: "My Great Note" }) as any;
3811
+ expect(result.id).toBe(byPath.id);
3812
+ expect(result.content).toBe("Path note");
3813
+ });
3814
+
3815
+ it("query-notes stays unresolved (not_found) when 2+ notes share the same H1 title", async () => {
3816
+ await store.createNote("# Dup Note\n\nA.", { path: "Inbox/dup-a" });
3817
+ await store.createNote("# Dup Note\n\nB.", { path: "Inbox/dup-b" });
3818
+ const tools = generateMcpTools(store);
3819
+ const query = tools.find((t) => t.name === "query-notes")!;
3820
+ const result = await query.execute({ id: "Dup Note" }) as any;
3821
+ expect(result.error_type).toBe("not_found");
3822
+ });
3823
+
3350
3824
  it("query-notes by tag", async () => {
3351
3825
  await store.createNote("Test", { tags: ["daily"] });
3352
3826
  const tools = generateMcpTools(store);
@@ -3376,6 +3850,93 @@ describe("MCP tools", async () => {
3376
3850
  expect(result.map((n) => n.content)).toEqual(["Orphan"]);
3377
3851
  });
3378
3852
 
3853
+ // ---- has_broken_links / include_broken_links (vault#555) ----
3854
+
3855
+ it("query-notes has_broken_links=true surfaces only notes with a dangling wikilink", async () => {
3856
+ await store.createNote("[[Nonexistent Target]]", { id: "mq-broken", path: "mq-broken" });
3857
+ await store.createNote("no links here", { id: "mq-clean", path: "mq-clean" });
3858
+
3859
+ const tools = generateMcpTools(store);
3860
+ const query = tools.find((t) => t.name === "query-notes")!;
3861
+ const result = await query.execute({ has_broken_links: true, include_content: true }) as any[];
3862
+ expect(result.map((n) => n.path)).toEqual(["mq-broken"]);
3863
+ });
3864
+
3865
+ it("query-notes has_broken_links=false excludes notes with a dangling link", async () => {
3866
+ await store.createNote("[[Nonexistent Target]]", { id: "mq-broken2", path: "mq-broken2" });
3867
+ await store.createNote("no links here", { id: "mq-clean2", path: "mq-clean2" });
3868
+
3869
+ const tools = generateMcpTools(store);
3870
+ const query = tools.find((t) => t.name === "query-notes")!;
3871
+ const result = await query.execute({ has_broken_links: false, include_content: true }) as any[];
3872
+ expect(result.map((n) => n.path).sort()).toEqual(["mq-clean2"]);
3873
+ });
3874
+
3875
+ it("query-notes has_broken_links is safe on a vault where no link has ever gone unresolved", async () => {
3876
+ // Fresh store, beforeEach — the unresolved_wikilinks table has never
3877
+ // been created. true should match nothing (not throw); false should
3878
+ // be a no-op (matches everything).
3879
+ await store.createNote("plain note", { id: "mq-nolinks", path: "mq-nolinks" });
3880
+ const tools = generateMcpTools(store);
3881
+ const query = tools.find((t) => t.name === "query-notes")!;
3882
+ const truthy = await query.execute({ has_broken_links: true, include_content: true }) as any[];
3883
+ expect(truthy).toEqual([]);
3884
+ const falsy = await query.execute({ has_broken_links: false, include_content: true }) as any[];
3885
+ expect(falsy.map((n) => n.path)).toEqual(["mq-nolinks"]);
3886
+ });
3887
+
3888
+ it("a target created later resolves the wikilink and the note drops out of has_broken_links:true", async () => {
3889
+ await store.createNote("[[Later Target]]", { id: "mq-fixable", path: "mq-fixable" });
3890
+ const tools = generateMcpTools(store);
3891
+ const query = tools.find((t) => t.name === "query-notes")!;
3892
+ expect((await query.execute({ has_broken_links: true }) as any[]).length).toBe(1);
3893
+
3894
+ await store.createNote("here now", { path: "Later Target" });
3895
+ expect((await query.execute({ has_broken_links: true }) as any[]).length).toBe(0);
3896
+ });
3897
+
3898
+ it("query-notes include_broken_links surfaces {target, relationship} for a single note", async () => {
3899
+ await store.createNote("[[Missing Note]]", { id: "mq-single-broken", path: "mq-single-broken" });
3900
+ const tools = generateMcpTools(store);
3901
+ const query = tools.find((t) => t.name === "query-notes")!;
3902
+ const result = await query.execute({ id: "mq-single-broken", include_broken_links: true }) as any;
3903
+ expect(result.broken_links).toEqual([{ target: "Missing Note", relationship: "wikilink" }]);
3904
+ });
3905
+
3906
+ it("query-notes include_broken_links surfaces a structured link's unresolved_link entry too", async () => {
3907
+ const created = await generateMcpTools(store).find((t) => t.name === "create-note")!.execute({
3908
+ content: "body",
3909
+ path: "mq-structured-broken",
3910
+ links: [{ target: "Does Not Exist", relationship: "depends-on" }],
3911
+ }) as any;
3912
+ const tools = generateMcpTools(store);
3913
+ const query = tools.find((t) => t.name === "query-notes")!;
3914
+ const result = await query.execute({ id: created.id, include_broken_links: true }) as any;
3915
+ expect(result.broken_links).toEqual([{ target: "Does Not Exist", relationship: "depends-on" }]);
3916
+ });
3917
+
3918
+ it("query-notes include_broken_links is [] for a note with no dangling links", async () => {
3919
+ await store.createNote("clean", { id: "mq-clean-single", path: "mq-clean-single" });
3920
+ const tools = generateMcpTools(store);
3921
+ const query = tools.find((t) => t.name === "query-notes")!;
3922
+ const result = await query.execute({ id: "mq-clean-single", include_broken_links: true }) as any;
3923
+ expect(result.broken_links).toEqual([]);
3924
+ });
3925
+
3926
+ it("query-notes include_broken_links works in list mode, batched across the page", async () => {
3927
+ await store.createNote("[[Ghost A]]", { id: "mq-list-a", path: "mq-list-a" });
3928
+ await store.createNote("[[Ghost B]]", { id: "mq-list-b", path: "mq-list-b" });
3929
+ await store.createNote("clean", { id: "mq-list-c", path: "mq-list-c" });
3930
+
3931
+ const tools = generateMcpTools(store);
3932
+ const query = tools.find((t) => t.name === "query-notes")!;
3933
+ const result = await query.execute({ include_broken_links: true, include_content: true }) as any[];
3934
+ const byId = new Map(result.map((n: any) => [n.id, n.broken_links]));
3935
+ expect(byId.get("mq-list-a")).toEqual([{ target: "Ghost A", relationship: "wikilink" }]);
3936
+ expect(byId.get("mq-list-b")).toEqual([{ target: "Ghost B", relationship: "wikilink" }]);
3937
+ expect(byId.get("mq-list-c")).toEqual([]);
3938
+ });
3939
+
3379
3940
  it("query-notes metadata operator query routes through the indexed column", async () => {
3380
3941
  const { declareField } = await import("./indexed-fields.js");
3381
3942
  declareField(db, "priority", "INTEGER", "project");
@@ -3788,10 +4349,205 @@ describe("MCP tools", async () => {
3788
4349
  d: { type: "boolean" },
3789
4350
  e: { type: "array" },
3790
4351
  f: { type: "object" },
4352
+ g: { type: "reference" },
3791
4353
  },
3792
4354
  }) as any;
3793
4355
  expect(result.fields.a.type).toBe("string");
3794
4356
  expect(result.fields.f.type).toBe("object");
4357
+ expect(result.fields.g.type).toBe("reference");
4358
+ });
4359
+
4360
+ // ---- Typed reference field: indexed value + auto-link (vault#typed-reference-field) ----
4361
+
4362
+ describe("typed reference field", () => {
4363
+ it("create-note with a reference-typed field indexes the value AND creates the link", async () => {
4364
+ const target = await store.createNote("Alice", { path: "People/Alice" });
4365
+ await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
4366
+ const tools = generateMcpTools(store);
4367
+ const createNote = tools.find((t) => t.name === "create-note")!;
4368
+ const result = await createNote.execute({
4369
+ content: "Ship it",
4370
+ tags: ["task"],
4371
+ metadata: { assignee: "People/Alice" },
4372
+ }) as any;
4373
+
4374
+ // (a) the value half — stored + readable like a string field.
4375
+ expect(result.metadata.assignee).toBe("People/Alice");
4376
+
4377
+ // (b) the link half — a graph edge to the resolved target, relationship = field name.
4378
+ const links = await store.getLinks(result.id, { direction: "outbound" });
4379
+ const assigneeLink = links.find((l) => l.relationship === "assignee");
4380
+ expect(assigneeLink).toBeDefined();
4381
+ expect(assigneeLink!.targetId).toBe(target.id);
4382
+ });
4383
+
4384
+ it("resolves a reference field by note ID as well as path/title", async () => {
4385
+ const target = await store.createNote("Bob", { id: "bob-id" });
4386
+ await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
4387
+ const tools = generateMcpTools(store);
4388
+ const createNote = tools.find((t) => t.name === "create-note")!;
4389
+ const result = await createNote.execute({
4390
+ content: "Ship it",
4391
+ tags: ["task"],
4392
+ metadata: { assignee: "bob-id" },
4393
+ }) as any;
4394
+
4395
+ const links = await store.getLinks(result.id, { direction: "outbound" });
4396
+ expect(links.find((l) => l.relationship === "assignee")?.targetId).toBe(target.id);
4397
+ });
4398
+
4399
+ it("querying by a reference field's value works (plain metadata filter and, when indexed, the eq operator)", async () => {
4400
+ await store.createNote("Alice", { path: "People/Alice", id: "alice" });
4401
+ const tools = generateMcpTools(store);
4402
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4403
+ await updateTag.execute({
4404
+ tag: "task",
4405
+ fields: { assignee: { type: "reference", indexed: true } },
4406
+ });
4407
+ const createNote = tools.find((t) => t.name === "create-note")!;
4408
+ const created = await createNote.execute({
4409
+ content: "Ship it",
4410
+ tags: ["task"],
4411
+ metadata: { assignee: "alice" },
4412
+ }) as any;
4413
+
4414
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
4415
+ const plain = await queryNotes.execute({ tags: ["task"], metadata: { assignee: "alice" } }) as any[];
4416
+ expect(plain.map((n: any) => n.id)).toContain(created.id);
4417
+
4418
+ const viaOperator = await queryNotes.execute({
4419
+ tags: ["task"],
4420
+ metadata: { assignee: { eq: "alice" } },
4421
+ }) as any[];
4422
+ expect(viaOperator.map((n: any) => n.id)).toContain(created.id);
4423
+ });
4424
+
4425
+ it("update-note changing a reference field's value moves the link (old edge dropped, new edge created)", async () => {
4426
+ const alice = await store.createNote("Alice", { id: "alice" });
4427
+ const bob = await store.createNote("Bob", { id: "bob" });
4428
+ await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
4429
+ const tools = generateMcpTools(store);
4430
+ const createNote = tools.find((t) => t.name === "create-note")!;
4431
+ const note = await createNote.execute({
4432
+ content: "Ship it",
4433
+ tags: ["task"],
4434
+ metadata: { assignee: "alice" },
4435
+ }) as any;
4436
+
4437
+ let links = await store.getLinks(note.id, { direction: "outbound" });
4438
+ expect(links.filter((l) => l.relationship === "assignee").map((l) => l.targetId)).toEqual([alice.id]);
4439
+
4440
+ const updateNote = tools.find((t) => t.name === "update-note")!;
4441
+ await updateNote.execute({ id: note.id, metadata: { assignee: "bob" }, force: true });
4442
+
4443
+ links = await store.getLinks(note.id, { direction: "outbound" });
4444
+ const assigneeLinks = links.filter((l) => l.relationship === "assignee");
4445
+ expect(assigneeLinks).toHaveLength(1);
4446
+ expect(assigneeLinks[0]!.targetId).toBe(bob.id);
4447
+
4448
+ const fresh = await store.getNote(note.id);
4449
+ expect(fresh!.metadata!.assignee).toBe("bob");
4450
+ });
4451
+
4452
+ it("update-note clearing a reference field drops the link", async () => {
4453
+ await store.createNote("Alice", { id: "alice" });
4454
+ await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
4455
+ const tools = generateMcpTools(store);
4456
+ const createNote = tools.find((t) => t.name === "create-note")!;
4457
+ const note = await createNote.execute({
4458
+ content: "Ship it",
4459
+ tags: ["task"],
4460
+ metadata: { assignee: "alice" },
4461
+ }) as any;
4462
+
4463
+ let links = await store.getLinks(note.id, { direction: "outbound" });
4464
+ expect(links.some((l) => l.relationship === "assignee")).toBe(true);
4465
+
4466
+ const updateNote = tools.find((t) => t.name === "update-note")!;
4467
+ // RFC 7386 tombstone — null deletes the key.
4468
+ await updateNote.execute({ id: note.id, metadata: { assignee: null }, force: true });
4469
+
4470
+ links = await store.getLinks(note.id, { direction: "outbound" });
4471
+ expect(links.some((l) => l.relationship === "assignee")).toBe(false);
4472
+
4473
+ const fresh = await store.getNote(note.id);
4474
+ expect(fresh!.metadata).not.toHaveProperty("assignee");
4475
+ });
4476
+
4477
+ it("re-writing a reference field with the SAME value is a no-op (link untouched)", async () => {
4478
+ const alice = await store.createNote("Alice", { id: "alice" });
4479
+ await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
4480
+ const tools = generateMcpTools(store);
4481
+ const createNote = tools.find((t) => t.name === "create-note")!;
4482
+ const note = await createNote.execute({
4483
+ content: "Ship it",
4484
+ tags: ["task"],
4485
+ metadata: { assignee: "alice" },
4486
+ }) as any;
4487
+
4488
+ const before = await store.getLinks(note.id, { direction: "outbound" });
4489
+ const beforeLink = before.find((l) => l.relationship === "assignee");
4490
+ expect(beforeLink).toBeDefined();
4491
+
4492
+ const updateNote = tools.find((t) => t.name === "update-note")!;
4493
+ await updateNote.execute({ id: note.id, metadata: { assignee: "alice" }, force: true });
4494
+
4495
+ const after = await store.getLinks(note.id, { direction: "outbound" });
4496
+ const afterLinks = after.filter((l) => l.relationship === "assignee");
4497
+ expect(afterLinks).toHaveLength(1);
4498
+ expect(afterLinks[0]!.targetId).toBe(alice.id);
4499
+ // Same createdAt — proves the edge was never dropped and recreated.
4500
+ expect(afterLinks[0]!.createdAt).toBe(beforeLink!.createdAt);
4501
+ });
4502
+
4503
+ it("a reference field's unresolved target queues and backfills when the target note is created later", async () => {
4504
+ await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
4505
+ const tools = generateMcpTools(store);
4506
+ const createNote = tools.find((t) => t.name === "create-note")!;
4507
+ const note = await createNote.execute({
4508
+ content: "Ship it",
4509
+ tags: ["task"],
4510
+ metadata: { assignee: "Not Yet Created" },
4511
+ }) as any;
4512
+
4513
+ let links = await store.getLinks(note.id, { direction: "outbound" });
4514
+ expect(links.some((l) => l.relationship === "assignee")).toBe(false);
4515
+
4516
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
4517
+ const broken = await queryNotes.execute({ has_broken_links: true }) as any[];
4518
+ expect(broken.map((n: any) => n.id)).toContain(note.id);
4519
+
4520
+ const target = await store.createNote("here now", { path: "Not Yet Created" });
4521
+
4522
+ links = await store.getLinks(note.id, { direction: "outbound" });
4523
+ const assigneeLink = links.find((l) => l.relationship === "assignee");
4524
+ expect(assigneeLink).toBeDefined();
4525
+ expect(assigneeLink!.targetId).toBe(target.id);
4526
+ });
4527
+
4528
+ it("a content-only or tags-only update does not touch a note's reference-field link", async () => {
4529
+ const alice = await store.createNote("Alice", { id: "alice" });
4530
+ await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
4531
+ const tools = generateMcpTools(store);
4532
+ const createNote = tools.find((t) => t.name === "create-note")!;
4533
+ const note = await createNote.execute({
4534
+ content: "Ship it",
4535
+ tags: ["task"],
4536
+ metadata: { assignee: "alice" },
4537
+ }) as any;
4538
+
4539
+ const before = await store.getLinks(note.id, { direction: "outbound" });
4540
+ const beforeLink = before.find((l) => l.relationship === "assignee");
4541
+
4542
+ // Content-only update — no `metadata` key in the call at all.
4543
+ await store.updateNote(note.id, { content: "Ship it faster" });
4544
+
4545
+ const after = await store.getLinks(note.id, { direction: "outbound" });
4546
+ const afterLink = after.find((l) => l.relationship === "assignee");
4547
+ expect(afterLink).toBeDefined();
4548
+ expect(afterLink!.targetId).toBe(alice.id);
4549
+ expect(afterLink!.createdAt).toBe(beforeLink!.createdAt);
4550
+ });
3795
4551
  });
3796
4552
 
3797
4553
  it("find-path works with ID/path resolution", async () => {
@@ -5188,6 +5944,401 @@ describe("update-note if_missing=create (vault#309)", async () => {
5188
5944
  });
5189
5945
  });
5190
5946
 
5947
+ // ---------------------------------------------------------------------------
5948
+ // create-note `if_exists` — idempotent upsert on a path conflict (vault#555)
5949
+ // ---------------------------------------------------------------------------
5950
+
5951
+ describe("create-note if_exists (vault#555)", async () => {
5952
+ let store: SqliteStore;
5953
+ beforeEach(() => {
5954
+ store = new SqliteStore(new Database(":memory:"));
5955
+ });
5956
+
5957
+ it("default (no if_exists) still throws path_conflict — back-compat", async () => {
5958
+ const tools = generateMcpTools(store);
5959
+ const create = tools.find((t) => t.name === "create-note")!;
5960
+ await create.execute({ content: "first", path: "Inbox/dup" });
5961
+ await expect(create.execute({ content: "second", path: "Inbox/dup" }))
5962
+ .rejects.toThrow(/path_conflict/);
5963
+ });
5964
+
5965
+ it('if_exists:"error" behaves identically to omitting it', async () => {
5966
+ const tools = generateMcpTools(store);
5967
+ const create = tools.find((t) => t.name === "create-note")!;
5968
+ await create.execute({ content: "first", path: "Inbox/dup-explicit" });
5969
+ await expect(create.execute({ content: "second", path: "Inbox/dup-explicit", if_exists: "error" }))
5970
+ .rejects.toThrow(/path_conflict/);
5971
+ });
5972
+
5973
+ it('if_exists:"ignore" returns the existing note untouched, no mutation', async () => {
5974
+ const tools = generateMcpTools(store);
5975
+ const create = tools.find((t) => t.name === "create-note")!;
5976
+ const first = await create.execute({
5977
+ content: "original",
5978
+ path: "Inbox/ignore-me",
5979
+ tags: ["daily"],
5980
+ metadata: { v: 1 },
5981
+ }) as any;
5982
+
5983
+ const second = await create.execute({
5984
+ content: "attempted overwrite",
5985
+ path: "Inbox/ignore-me",
5986
+ tags: ["other"],
5987
+ metadata: { v: 2 },
5988
+ if_exists: "ignore",
5989
+ }) as any;
5990
+
5991
+ expect(second.existed).toBe(true);
5992
+ expect(second.id).toBe(first.id);
5993
+ expect(second.content).toBe("original"); // untouched
5994
+ expect(second.metadata?.v).toBe(1); // untouched
5995
+ expect(second.tags).toEqual(["daily"]); // "other" was never applied
5996
+
5997
+ // Confirm on disk too — no side effects at all.
5998
+ const onDisk = await store.getNoteByPath("Inbox/ignore-me");
5999
+ expect(onDisk!.content).toBe("original");
6000
+ expect(onDisk!.tags).toEqual(["daily"]);
6001
+ });
6002
+
6003
+ it('if_exists:"ignore" does not run schema-default backfill (genuinely zero mutation)', async () => {
6004
+ const tools = generateMcpTools(store);
6005
+ const create = tools.find((t) => t.name === "create-note")!;
6006
+ // Create the note BEFORE the schema declares a default, so the
6007
+ // existing row is missing the field the schema would now backfill.
6008
+ const first = await create.execute({ content: "x", path: "Inbox/predates-schema", tags: ["task"] }) as any;
6009
+ expect(first.metadata?.priority).toBeUndefined();
6010
+
6011
+ await store.upsertTagSchema("task", {
6012
+ fields: { priority: { type: "string", enum: ["high", "low"], default: "high" } },
6013
+ });
6014
+
6015
+ const second = await create.execute({
6016
+ content: "y",
6017
+ path: "Inbox/predates-schema",
6018
+ tags: ["task"],
6019
+ if_exists: "ignore",
6020
+ }) as any;
6021
+ expect(second.existed).toBe(true);
6022
+ expect(second.metadata?.priority).toBeUndefined(); // no backfill — zero mutation promise
6023
+ });
6024
+
6025
+ it('if_exists:"update" full-replaces content, RFC-7386-merges metadata, and unions tags/links', async () => {
6026
+ await store.createNote("target body", { id: "t-upd-target", path: "Targets/upd" });
6027
+
6028
+ const tools = generateMcpTools(store);
6029
+ const create = tools.find((t) => t.name === "create-note")!;
6030
+ const first = await create.execute({
6031
+ content: "v1",
6032
+ path: "Inbox/update-me",
6033
+ tags: ["daily"],
6034
+ metadata: { a: 1, b: 2 },
6035
+ }) as any;
6036
+
6037
+ const second = await create.execute({
6038
+ content: "v2",
6039
+ path: "Inbox/update-me",
6040
+ tags: ["extra"],
6041
+ metadata: { b: null, c: 3 }, // b deleted (RFC 7386), c added, a untouched
6042
+ links: [{ target: "t-upd-target", relationship: "relates-to" }],
6043
+ if_exists: "update",
6044
+ }) as any;
6045
+
6046
+ expect(second.existed).toBe(true);
6047
+ expect(second.id).toBe(first.id);
6048
+ expect(second.content).toBe("v2"); // full replace
6049
+ expect(second.metadata).toEqual({ a: 1, c: 3 }); // merged, b deleted
6050
+ expect(second.tags?.sort()).toEqual(["daily", "extra"]); // union, nothing removed
6051
+
6052
+ const links = await store.getLinks(second.id, { direction: "outbound" });
6053
+ expect(links.find((l) => l.relationship === "relates-to")?.targetId).toBe("t-upd-target");
6054
+ });
6055
+
6056
+ it('if_exists:"update" with an omitted field leaves it untouched', async () => {
6057
+ const tools = generateMcpTools(store);
6058
+ const create = tools.find((t) => t.name === "create-note")!;
6059
+ await create.execute({ content: "v1", path: "Inbox/partial-update", metadata: { keep: "me" } });
6060
+
6061
+ const second = await create.execute({
6062
+ path: "Inbox/partial-update", // no `content` — should stay "v1"
6063
+ metadata: { added: true },
6064
+ if_exists: "update",
6065
+ }) as any;
6066
+ expect(second.content).toBe("v1");
6067
+ expect(second.metadata).toEqual({ keep: "me", added: true });
6068
+ });
6069
+
6070
+ it('if_exists:"replace" wholesale-overwrites content + metadata but keeps id/createdAt and unions tags', async () => {
6071
+ const tools = generateMcpTools(store);
6072
+ const create = tools.find((t) => t.name === "create-note")!;
6073
+ const first = await create.execute({
6074
+ content: "v1",
6075
+ path: "Inbox/replace-me",
6076
+ tags: ["daily"],
6077
+ metadata: { a: 1, b: 2 },
6078
+ }) as any;
6079
+
6080
+ const second = await create.execute({
6081
+ content: "v2",
6082
+ path: "Inbox/replace-me",
6083
+ tags: ["extra"],
6084
+ metadata: { c: 3 }, // a and b are NOT merged in — wholesale replace
6085
+ if_exists: "replace",
6086
+ }) as any;
6087
+
6088
+ expect(second.existed).toBe(true);
6089
+ expect(second.id).toBe(first.id);
6090
+ expect(second.createdAt).toBe(first.createdAt);
6091
+ expect(second.content).toBe("v2");
6092
+ expect(second.metadata).toEqual({ c: 3 }); // a/b dropped, not merged
6093
+ expect(second.tags?.sort()).toEqual(["daily", "extra"]); // tags stay additive
6094
+ });
6095
+
6096
+ it('if_exists:"replace" with omitted content/metadata clears them to empty defaults', async () => {
6097
+ const tools = generateMcpTools(store);
6098
+ const create = tools.find((t) => t.name === "create-note")!;
6099
+ await create.execute({ content: "v1", path: "Inbox/replace-blank", metadata: { a: 1 } });
6100
+
6101
+ const second = await create.execute({
6102
+ path: "Inbox/replace-blank",
6103
+ if_exists: "replace",
6104
+ }) as any;
6105
+ expect(second.content).toBe("");
6106
+ // An empty `{}` metadata collapses to `undefined` on read — the same
6107
+ // convention every note with no metadata follows (rowToNote), not
6108
+ // something special about `replace`.
6109
+ expect(second.metadata).toBeUndefined();
6110
+ });
6111
+
6112
+ it("if_exists is a no-op without a path — a pathless create can never conflict", async () => {
6113
+ const tools = generateMcpTools(store);
6114
+ const create = tools.find((t) => t.name === "create-note")!;
6115
+ const a = await create.execute({ content: "one", if_exists: "ignore" }) as any;
6116
+ const b = await create.execute({ content: "two", if_exists: "ignore" }) as any;
6117
+ expect(a.id).not.toBe(b.id);
6118
+ // `if_exists` was still passed, so `existed` is attached (there was
6119
+ // simply nothing to conflict with) — `false`, not absent.
6120
+ expect(a.existed).toBe(false);
6121
+ expect(b.existed).toBe(false);
6122
+ });
6123
+
6124
+ it("plain create-note calls (no if_exists) see zero response-shape change", async () => {
6125
+ const tools = generateMcpTools(store);
6126
+ const create = tools.find((t) => t.name === "create-note")!;
6127
+ const result = await create.execute({ content: "plain" }) as any;
6128
+ expect("existed" in result).toBe(false);
6129
+ });
6130
+
6131
+ it("existed:false when if_exists is set but no conflict occurs (fresh insert)", async () => {
6132
+ const tools = generateMcpTools(store);
6133
+ const create = tools.find((t) => t.name === "create-note")!;
6134
+ const result = await create.execute({ content: "fresh", path: "Inbox/fresh-one", if_exists: "update" }) as any;
6135
+ expect(result.existed).toBe(false);
6136
+ });
6137
+
6138
+ it("respects a per-item extension when disambiguating the conflict (vault#328)", async () => {
6139
+ const tools = generateMcpTools(store);
6140
+ const create = tools.find((t) => t.name === "create-note")!;
6141
+ // Two notes at the same path, different extensions — legal per vault#328.
6142
+ await create.execute({ content: "csv body", path: "Reports/q1", extension: "csv" });
6143
+ await create.execute({ content: "md body", path: "Reports/q1", extension: "md" });
6144
+
6145
+ // ignore against the CSV one specifically.
6146
+ const result = await create.execute({
6147
+ content: "attempted",
6148
+ path: "Reports/q1",
6149
+ extension: "csv",
6150
+ if_exists: "ignore",
6151
+ }) as any;
6152
+ expect(result.existed).toBe(true);
6153
+ expect(result.content).toBe("csv body");
6154
+ expect(result.extension).toBe("csv");
6155
+ });
6156
+
6157
+ it("batch: if_exists is per-item, not inherited from a top-level default", async () => {
6158
+ const tools = generateMcpTools(store);
6159
+ const create = tools.find((t) => t.name === "create-note")!;
6160
+ await create.execute({ content: "existing", path: "Inbox/batch-conflict" });
6161
+
6162
+ // Top-level if_exists is NOT merged into batch items (matches
6163
+ // if_missing's contract on update-note) — the batch item below has no
6164
+ // if_exists of its own, so it hits the default "error" path and the
6165
+ // WHOLE batch rolls back, even though a top-level if_exists was passed.
6166
+ await expect(create.execute({
6167
+ if_exists: "ignore",
6168
+ notes: [{ content: "conflict", path: "Inbox/batch-conflict" }],
6169
+ })).rejects.toThrow(/path_conflict/);
6170
+ });
6171
+
6172
+ it("batch: mixes fresh creates and if_exists hits in the same call, in order", async () => {
6173
+ const tools = generateMcpTools(store);
6174
+ const create = tools.find((t) => t.name === "create-note")!;
6175
+ await create.execute({ content: "pre-existing", path: "Inbox/batch-b" });
6176
+
6177
+ const result = await create.execute({
6178
+ notes: [
6179
+ { content: "a", path: "Inbox/batch-a", if_exists: "ignore" },
6180
+ { content: "b-new", path: "Inbox/batch-b", if_exists: "ignore" },
6181
+ { content: "c", path: "Inbox/batch-c", if_exists: "ignore" },
6182
+ ],
6183
+ }) as any[];
6184
+
6185
+ expect(result).toHaveLength(3);
6186
+ expect(result[0].existed).toBe(false);
6187
+ expect(result[1].existed).toBe(true);
6188
+ expect(result[1].content).toBe("pre-existing"); // untouched
6189
+ expect(result[2].existed).toBe(false);
6190
+ });
6191
+
6192
+ it("a genuine schema violation on if_exists:update still rejects and mutates nothing", async () => {
6193
+ await store.upsertTagSchema("task", {
6194
+ fields: { status: { type: "string", enum: ["open", "done"], strict: true, required: true } },
6195
+ });
6196
+ const tools = generateMcpTools(store);
6197
+ const create = tools.find((t) => t.name === "create-note")!;
6198
+ await create.execute({ content: "v1", path: "Inbox/strict-target", tags: ["task"], metadata: { status: "open" } });
6199
+
6200
+ await expect(create.execute({
6201
+ content: "v2",
6202
+ path: "Inbox/strict-target",
6203
+ metadata: { status: "NOT-A-VALID-ENUM" },
6204
+ if_exists: "update",
6205
+ })).rejects.toThrow();
6206
+
6207
+ const onDisk = await store.getNoteByPath("Inbox/strict-target");
6208
+ expect(onDisk!.content).toBe("v1"); // untouched — rejected before any write
6209
+ });
6210
+
6211
+ it("if_exists:update re-validates against the CURRENT schema without re-supplying satisfied required fields", async () => {
6212
+ await store.upsertTagSchema("task", {
6213
+ fields: { status: { type: "string", enum: ["open", "done"], strict: true, required: true } },
6214
+ });
6215
+ const tools = generateMcpTools(store);
6216
+ const create = tools.find((t) => t.name === "create-note")!;
6217
+ await create.execute({ content: "v1", path: "Inbox/status-set", tags: ["task"], metadata: { status: "open" } });
6218
+
6219
+ // Only touching content — `status` is already satisfied by the
6220
+ // EXISTING note, so re-validating the merged/projected shape (not the
6221
+ // raw incoming item) must NOT demand it again.
6222
+ const result = await create.execute({
6223
+ content: "v2",
6224
+ path: "Inbox/status-set",
6225
+ if_exists: "update",
6226
+ }) as any;
6227
+ expect(result.content).toBe("v2");
6228
+ expect(result.metadata?.status).toBe("open");
6229
+ });
6230
+
6231
+ // vault#555 CRITICAL 2 (W8 fix-2 bug class): a tags-ONLY if_exists:"update"
6232
+ // (no content/metadata) must still bump updated_at — store.tagNote genuinely
6233
+ // mutates the note, and a frozen updated_at breaks cursor polling + sync.
6234
+ it('if_exists:"update" with ONLY tags added still advances updated_at', async () => {
6235
+ const tools = generateMcpTools(store);
6236
+ const create = tools.find((t) => t.name === "create-note")!;
6237
+ const first = await create.execute({ content: "body", path: "Inbox/tagonly", tags: ["alpha"] }) as any;
6238
+ const before = (await store.getNoteByPath("Inbox/tagonly"))!.updatedAt!;
6239
+
6240
+ // Ensure a strictly-later wall-clock so the bump is observable even if the
6241
+ // two writes land in the same millisecond.
6242
+ await new Promise((r) => setTimeout(r, 5));
6243
+
6244
+ const second = await create.execute({
6245
+ path: "Inbox/tagonly",
6246
+ tags: ["beta"], // ONLY a tag change — no content, no metadata
6247
+ if_exists: "update",
6248
+ }) as any;
6249
+ expect(second.existed).toBe(true);
6250
+ expect(second.tags?.sort()).toEqual(["alpha", "beta"]); // tag actually added
6251
+ const after = (await store.getNoteByPath("Inbox/tagonly"))!.updatedAt!;
6252
+ expect(after > before).toBe(true); // updated_at advanced
6253
+ expect(second.id).toBe(first.id);
6254
+ });
6255
+
6256
+ it('if_exists:"update" with ONLY links added still advances updated_at', async () => {
6257
+ await store.createNote("target", { id: "tgt-linkonly", path: "Targets/linkonly" });
6258
+ const tools = generateMcpTools(store);
6259
+ const create = tools.find((t) => t.name === "create-note")!;
6260
+ await create.execute({ content: "body", path: "Inbox/linkonly", tags: ["alpha"] });
6261
+ const before = (await store.getNoteByPath("Inbox/linkonly"))!.updatedAt!;
6262
+ await new Promise((r) => setTimeout(r, 5));
6263
+
6264
+ await create.execute({
6265
+ path: "Inbox/linkonly",
6266
+ links: [{ target: "tgt-linkonly", relationship: "relates-to" }],
6267
+ if_exists: "update",
6268
+ });
6269
+ const after = (await store.getNoteByPath("Inbox/linkonly"))!.updatedAt!;
6270
+ expect(after > before).toBe(true);
6271
+ });
6272
+ });
6273
+
6274
+ // ---------------------------------------------------------------------------
6275
+ // create-note `summary` — compact batch response shape (vault#555)
6276
+ // ---------------------------------------------------------------------------
6277
+
6278
+ describe("create-note summary (vault#555)", async () => {
6279
+ let store: SqliteStore;
6280
+ beforeEach(() => {
6281
+ store = new SqliteStore(new Database(":memory:"));
6282
+ });
6283
+
6284
+ it("summary:true returns {created, ids, failed} instead of N full note objects", async () => {
6285
+ const tools = generateMcpTools(store);
6286
+ const create = tools.find((t) => t.name === "create-note")!;
6287
+ const result = await create.execute({
6288
+ summary: true,
6289
+ notes: [
6290
+ { content: "a", path: "Inbox/sum-a" },
6291
+ { content: "b", path: "Inbox/sum-b" },
6292
+ { content: "c", path: "Inbox/sum-c" },
6293
+ ],
6294
+ }) as any;
6295
+ expect(result.created).toBe(3);
6296
+ expect(result.ids).toHaveLength(3);
6297
+ expect(result.failed).toEqual([]);
6298
+ // Not the full-object shape.
6299
+ expect(result.notes).toBeUndefined();
6300
+ expect(Array.isArray(result)).toBe(false);
6301
+
6302
+ // The ids really do resolve to the notes just created.
6303
+ const onDisk = await store.getNoteByPath("Inbox/sum-a");
6304
+ expect(result.ids).toContain(onDisk!.id);
6305
+ });
6306
+
6307
+ it("summary:true `created` excludes if_exists hits — only counts brand-new inserts", async () => {
6308
+ const tools = generateMcpTools(store);
6309
+ const create = tools.find((t) => t.name === "create-note")!;
6310
+ await create.execute({ content: "pre-existing", path: "Inbox/sum-existing" });
6311
+
6312
+ const result = await create.execute({
6313
+ summary: true,
6314
+ notes: [
6315
+ { content: "fresh", path: "Inbox/sum-fresh", if_exists: "ignore" },
6316
+ { content: "ignored", path: "Inbox/sum-existing", if_exists: "ignore" },
6317
+ ],
6318
+ }) as any;
6319
+ expect(result.created).toBe(1); // only the fresh one
6320
+ expect(result.ids).toHaveLength(2); // both ids are still reported
6321
+ });
6322
+
6323
+ it("summary is ignored on a single-note (non-batch) call", async () => {
6324
+ const tools = generateMcpTools(store);
6325
+ const create = tools.find((t) => t.name === "create-note")!;
6326
+ const result = await create.execute({ content: "solo", summary: true }) as any;
6327
+ expect(result.content).toBe("solo"); // full note object, not a summary
6328
+ expect(result.created).toBeUndefined();
6329
+ });
6330
+
6331
+ it("without summary, batch still returns full note objects (back-compat)", async () => {
6332
+ const tools = generateMcpTools(store);
6333
+ const create = tools.find((t) => t.name === "create-note")!;
6334
+ const result = await create.execute({
6335
+ notes: [{ content: "a", path: "Inbox/sum-nosummary" }],
6336
+ }) as any[];
6337
+ expect(Array.isArray(result)).toBe(true);
6338
+ expect(result[0].content).toBe("a");
6339
+ });
6340
+ });
6341
+
5191
6342
  // ---------------------------------------------------------------------------
5192
6343
  // Schema inheritance via parent_names + `_default` universal parent — vault#270
5193
6344
  // ---------------------------------------------------------------------------