@openparachute/vault 0.7.0-rc.9 → 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.
@@ -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);
@@ -3875,10 +4349,205 @@ describe("MCP tools", async () => {
3875
4349
  d: { type: "boolean" },
3876
4350
  e: { type: "array" },
3877
4351
  f: { type: "object" },
4352
+ g: { type: "reference" },
3878
4353
  },
3879
4354
  }) as any;
3880
4355
  expect(result.fields.a.type).toBe("string");
3881
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
+ });
3882
4551
  });
3883
4552
 
3884
4553
  it("find-path works with ID/path resolution", async () => {