@openparachute/vault 0.7.0-rc.9 → 0.7.2-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) 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 +737 -2
  5. package/core/src/cursor-keyset-ms.test.ts +537 -0
  6. package/core/src/cursor.ts +76 -6
  7. package/core/src/doctor.ts +23 -1
  8. package/core/src/hooks.ts +9 -0
  9. package/core/src/indexed-fields.test.ts +4 -1
  10. package/core/src/indexed-fields.ts +6 -1
  11. package/core/src/links.ts +22 -0
  12. package/core/src/mcp.ts +322 -53
  13. package/core/src/notes.ts +518 -67
  14. package/core/src/paths.ts +4 -0
  15. package/core/src/portable-md.test.ts +161 -0
  16. package/core/src/portable-md.ts +140 -9
  17. package/core/src/query-warnings.ts +60 -12
  18. package/core/src/schema-defaults.ts +7 -1
  19. package/core/src/schema.ts +128 -2
  20. package/core/src/seed-packs.ts +55 -15
  21. package/core/src/store.ts +141 -7
  22. package/core/src/tag-schemas.ts +20 -7
  23. package/core/src/txn.test.ts +100 -4
  24. package/core/src/txn.ts +119 -24
  25. package/core/src/types.ts +89 -0
  26. package/core/src/ulid.test.ts +56 -0
  27. package/core/src/ulid.ts +116 -0
  28. package/core/src/vault-projection.ts +19 -1
  29. package/core/src/wikilinks.test.ts +205 -1
  30. package/core/src/wikilinks.ts +200 -35
  31. package/package.json +1 -1
  32. package/src/aggregate-routes.test.ts +230 -0
  33. package/src/cli.ts +6 -0
  34. package/src/contract-errors.test.ts +2 -1
  35. package/src/contract-search.test.ts +17 -0
  36. package/src/mcp-link-warnings-scope.test.ts +144 -0
  37. package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
  38. package/src/mcp-tools.ts +76 -17
  39. package/src/mirror-import.ts +5 -0
  40. package/src/routes.ts +298 -24
  41. package/src/routing.test.ts +167 -0
  42. package/src/routing.ts +47 -11
  43. package/src/tag-integrity-mcp.test.ts +45 -6
  44. package/src/tag-scope.ts +10 -1
  45. package/src/vault.test.ts +557 -21
  46. package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
  47. 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, validatePath, PathValidationError } 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 () => {
@@ -600,9 +663,13 @@ describe("renameTag cascade (vault#240 + #247)", async () => {
600
663
  // vault#555 fix 2 — a rename cascade's inline content rewrite is a real
601
664
  // persisted-state change; `updated_at` must move or a cursor/sync-poll
602
665
  // loop never sees the rewritten note.
603
- it("1b. content rewrite bumps updated_at (vault#555)", async () => {
666
+ it("1b. content rewrite bumps updated_at (vault#555) AND updated_at_ms (vault#586)", async () => {
604
667
  const note = await store.createNote("Today's #task is important.", { tags: ["task"] });
605
668
  const before = note.updatedAt;
669
+ const msOf = (id: string) =>
670
+ (db.prepare("SELECT updated_at_ms FROM notes WHERE id = ?").get(id) as { updated_at_ms: number })
671
+ .updated_at_ms;
672
+ const beforeMs = msOf(note.id);
606
673
  await new Promise((r) => setTimeout(r, 5));
607
674
 
608
675
  await store.renameTag("task", "todo");
@@ -611,6 +678,12 @@ describe("renameTag cascade (vault#240 + #247)", async () => {
611
678
  expect(fresh!.content).toContain("#todo");
612
679
  expect(fresh!.updatedAt).not.toBe(before);
613
680
  expect(new Date(fresh!.updatedAt) > new Date(before)).toBe(true);
681
+ // vault#586: the integer keyset key must move in lockstep with updated_at —
682
+ // else a cursor sync-poll never surfaces the rewritten note. Pins the
683
+ // renameTag content-cascade ms hunk (a revert leaves ms stale → RED).
684
+ const afterMs = msOf(note.id);
685
+ expect(afterMs).toBeGreaterThan(beforeMs);
686
+ expect(afterMs).toBe(Date.parse(fresh!.updatedAt!));
614
687
  });
615
688
 
616
689
  it("2. cascades sub-tags recursively (task → todo, task/work → todo/work, task/work/client → todo/work/client)", async () => {
@@ -1126,6 +1199,116 @@ describe("vault stats", async () => {
1126
1199
  });
1127
1200
  });
1128
1201
 
1202
+ // ---- Vault Map (front-door structural orientation) ----
1203
+
1204
+ describe("vault map", async () => {
1205
+ it("handles an empty vault gracefully", async () => {
1206
+ const map = getVaultMap(db);
1207
+ expect(map.total_notes).toBe(0);
1208
+ expect(map.tags).toEqual([]);
1209
+ expect(map.path_buckets).toEqual([]);
1210
+ expect(map.unfiled_notes).toBe(0);
1211
+ });
1212
+
1213
+ it("counts total notes, tag membership, and top-level path buckets", async () => {
1214
+ await store.createNote("a", { tags: ["person"], path: "People/Alice" });
1215
+ await store.createNote("b", { tags: ["person"], path: "People/Bob" });
1216
+ await store.createNote("c", { tags: ["project"], path: "Projects/Acme" });
1217
+ await store.createNote("d", { tags: ["project", "person"] }); // no path
1218
+
1219
+ const map = getVaultMap(db);
1220
+ expect(map.total_notes).toBe(4);
1221
+
1222
+ const tagByName = Object.fromEntries(map.tags.map((t) => [t.name, t.count]));
1223
+ expect(tagByName.person).toBe(3);
1224
+ expect(tagByName.project).toBe(2);
1225
+
1226
+ const bucketByName = Object.fromEntries(map.path_buckets.map((b) => [b.name, b.count]));
1227
+ expect(bucketByName.People).toBe(2);
1228
+ expect(bucketByName.Projects).toBe(1);
1229
+
1230
+ expect(map.unfiled_notes).toBe(1);
1231
+ // Invariant: unfiled + every bucket's count == total_notes (a note has at
1232
+ // most one path, so buckets + unfiled partition the vault exactly).
1233
+ const bucketSum = map.path_buckets.reduce((sum, b) => sum + b.count, 0);
1234
+ expect(bucketSum + map.unfiled_notes).toBe(map.total_notes);
1235
+ });
1236
+
1237
+ it("buckets a top-level (no-slash) path under its own full name", async () => {
1238
+ await store.createNote("solo", { path: "Welcome" });
1239
+ const map = getVaultMap(db);
1240
+ expect(map.path_buckets).toEqual([{ name: "Welcome", count: 1 }]);
1241
+ expect(map.unfiled_notes).toBe(0);
1242
+ });
1243
+
1244
+ it("tags are sorted by count desc, then name asc", async () => {
1245
+ for (let i = 0; i < 3; i++) await store.createNote(`m-${i}`, { tags: ["popular"] });
1246
+ await store.createNote("z", { tags: ["zzz-rare"] });
1247
+ await store.createNote("a", { tags: ["aaa-rare"] });
1248
+
1249
+ const map = getVaultMap(db);
1250
+ expect(map.tags).toEqual([
1251
+ { name: "popular", count: 3 },
1252
+ { name: "aaa-rare", count: 1 },
1253
+ { name: "zzz-rare", count: 1 },
1254
+ ]);
1255
+ });
1256
+
1257
+ describe("tagFilter (scope-aware path)", () => {
1258
+ it("omitting tagFilter computes the vault-wide map (unchanged default)", async () => {
1259
+ await store.createNote("a", { tags: ["work"], path: "Work/One" });
1260
+ await store.createNote("b", { tags: ["personal"], path: "Personal/Two" });
1261
+
1262
+ const map = getVaultMap(db);
1263
+ expect(map.total_notes).toBe(2);
1264
+ expect(map.tags.map((t) => t.name).sort()).toEqual(["personal", "work"]);
1265
+ expect(map.path_buckets.map((b) => b.name).sort()).toEqual(["Personal", "Work"]);
1266
+ });
1267
+
1268
+ it("an empty tagFilter array means nothing is in scope — all-zero map, NOT the full vault", async () => {
1269
+ await store.createNote("a", { tags: ["work"], path: "Work/One" });
1270
+
1271
+ const map = getVaultMap(db, { tagFilter: [] });
1272
+ expect(map).toEqual({ total_notes: 0, tags: [], path_buckets: [], unfiled_notes: 0 });
1273
+ });
1274
+
1275
+ it("a non-empty tagFilter restricts every count to notes reachable through that tag set", async () => {
1276
+ await store.createNote("a", { tags: ["work"], path: "Work/One" });
1277
+ await store.createNote("b", { tags: ["work", "urgent"], path: "Work/Two" });
1278
+ await store.createNote("c", { tags: ["personal"], path: "Personal/Three" });
1279
+ await store.createNote("d", { tags: ["personal"] }); // no path
1280
+
1281
+ const map = getVaultMap(db, { tagFilter: ["work", "urgent"] });
1282
+ expect(map.total_notes).toBe(2); // a, b — "personal" notes excluded
1283
+ expect(map.tags).toEqual([
1284
+ { name: "work", count: 2 },
1285
+ { name: "urgent", count: 1 },
1286
+ ]);
1287
+ expect(map.path_buckets).toEqual([{ name: "Work", count: 2 }]);
1288
+ expect(map.unfiled_notes).toBe(0);
1289
+ });
1290
+
1291
+ it("a note carrying both an in-scope and out-of-scope tag is counted once, not double", async () => {
1292
+ await store.createNote("a", { tags: ["work", "personal"], path: "Work/One" });
1293
+
1294
+ const map = getVaultMap(db, { tagFilter: ["work"] });
1295
+ expect(map.total_notes).toBe(1);
1296
+ expect(map.path_buckets).toEqual([{ name: "Work", count: 1 }]);
1297
+ // "personal" is out of scope — must not leak into the tags list.
1298
+ expect(map.tags.map((t) => t.name)).not.toContain("personal");
1299
+ });
1300
+
1301
+ it("scoped unfiled_notes counts only in-scope path-less notes", async () => {
1302
+ await store.createNote("a", { tags: ["work"] }); // no path, in scope
1303
+ await store.createNote("b", { tags: ["personal"] }); // no path, out of scope
1304
+
1305
+ const map = getVaultMap(db, { tagFilter: ["work"] });
1306
+ expect(map.unfiled_notes).toBe(1);
1307
+ expect(map.total_notes).toBe(1);
1308
+ });
1309
+ });
1310
+ });
1311
+
1129
1312
  // ---- Query ----
1130
1313
 
1131
1314
  describe("queryNotes", async () => {
@@ -1370,7 +1553,16 @@ describe("queryNotes", async () => {
1370
1553
  // Helper: pin a note's updated_at to a known value so cursor math
1371
1554
  // doesn't race wall-clock writes from the test harness.
1372
1555
  function pinUpdatedAt(id: string, iso: string) {
1373
- db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?").run(iso, id);
1556
+ // Mirror production: EVERY write maintains updated_at_ms in lockstep with
1557
+ // updated_at (vault#586 — the integer column is the keyset-ordering
1558
+ // authority). A helper that pinned only the string would leave a stale ms
1559
+ // and mis-order the cursor. The pinned values here are canonical `...Z`,
1560
+ // so `Date.parse` is exact.
1561
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?").run(
1562
+ iso,
1563
+ Date.parse(iso),
1564
+ id,
1565
+ );
1374
1566
  }
1375
1567
 
1376
1568
  it("first call returns notes + a next_cursor string", async () => {
@@ -1615,6 +1807,124 @@ describe("queryNotes", async () => {
1615
1807
  });
1616
1808
  });
1617
1809
 
1810
+ describe("ULID ids for new notes (existing IDs unchanged)", () => {
1811
+ function pinUpdatedAt(id: string, iso: string) {
1812
+ // Keep updated_at_ms in lockstep — see the sibling helper's note (vault#586).
1813
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?").run(
1814
+ iso,
1815
+ Date.parse(iso),
1816
+ id,
1817
+ );
1818
+ }
1819
+
1820
+ it("new notes get ULID-format ids (26-char Crockford base32)", async () => {
1821
+ const note = await store.createNote("fresh note");
1822
+ expect(note.id).toHaveLength(26);
1823
+ expect(note.id).toMatch(ULID_REGEX);
1824
+ });
1825
+
1826
+ it("generateUlid() output matches the ULID format directly", () => {
1827
+ const id = generateUlid();
1828
+ expect(id).toHaveLength(26);
1829
+ expect(id).toMatch(ULID_REGEX);
1830
+ });
1831
+
1832
+ it("ULIDs minted in the same millisecond are monotonically increasing", () => {
1833
+ const ids = Array.from({ length: 200 }, () => generateUlid());
1834
+ // No duplicates, and strictly increasing under plain string compare —
1835
+ // this is the monotonic-within-a-ms guarantee, not just uniqueness.
1836
+ const unique = new Set(ids);
1837
+ expect(unique.size).toBe(ids.length);
1838
+ for (let i = 1; i < ids.length; i++) {
1839
+ expect(ids[i]! > ids[i - 1]!).toBe(true);
1840
+ }
1841
+ });
1842
+
1843
+ it("an explicitly-supplied old-format id still round-trips (create/read/link/resolve)", async () => {
1844
+ const oldId = "2020-01-01-00-00-00-000001";
1845
+
1846
+ // create — old-format id passed explicitly via opts.id.
1847
+ const oldNote = await store.createNote("legacy note", { id: oldId, path: "legacy-note" });
1848
+ expect(oldNote.id).toBe(oldId);
1849
+
1850
+ // read — by id and by path.
1851
+ const byId = await store.getNote(oldId);
1852
+ expect(byId?.id).toBe(oldId);
1853
+ expect(byId?.content).toBe("legacy note");
1854
+ const byPath = await store.getNoteByPath("legacy-note");
1855
+ expect(byPath?.id).toBe(oldId);
1856
+
1857
+ // link — a brand-new (ULID) note links to the old-format note, and
1858
+ // vice versa; both directions traverse the graph correctly.
1859
+ const newNote = await store.createNote("modern note");
1860
+ expect(newNote.id).toMatch(ULID_REGEX);
1861
+ await store.createLink(newNote.id, oldId, "references");
1862
+ await store.createLink(oldId, newNote.id, "referenced-by");
1863
+
1864
+ const outbound = await store.getLinks(newNote.id, { direction: "outbound" });
1865
+ expect(outbound.some((l) => l.targetId === oldId)).toBe(true);
1866
+ const inbound = await store.getLinks(newNote.id, { direction: "inbound" });
1867
+ expect(inbound.some((l) => l.sourceId === oldId)).toBe(true);
1868
+
1869
+ // resolve — resolveLinkTarget's id-then-path-then-title chain works
1870
+ // identically for an old-format id as it does for a ULID.
1871
+ expect(resolveLinkTarget(db, oldId)).toBe(oldId);
1872
+ expect(resolveLinkTarget(db, "legacy-note")).toBe(oldId);
1873
+ expect(resolveLinkTarget(db, newNote.id)).toBe(newNote.id);
1874
+ });
1875
+
1876
+ it("mixed-format cursor pagination is stable under a shared updated_at (no miss/dupe)", async () => {
1877
+ const ts = "2026-05-01T00:00:00.000Z";
1878
+ const ids: string[] = [];
1879
+
1880
+ // Two "old" notes with explicit timestamp-format ids...
1881
+ for (const oldId of ["2020-01-01-00-00-00-000001", "2020-06-15-12-30-00-500000"]) {
1882
+ const n = await store.createNote(`old ${oldId}`, { id: oldId });
1883
+ pinUpdatedAt(n.id, ts);
1884
+ ids.push(n.id);
1885
+ }
1886
+ // ...and three "new" notes with auto-generated ULIDs, all sharing the
1887
+ // exact same updated_at — id is the ONLY thing that can order them.
1888
+ for (let i = 0; i < 3; i++) {
1889
+ const n = await store.createNote(`new ${i}`);
1890
+ expect(n.id).toMatch(ULID_REGEX);
1891
+ pinUpdatedAt(n.id, ts);
1892
+ ids.push(n.id);
1893
+ }
1894
+
1895
+ // The expected stable total order is plain string comparison over the
1896
+ // id column — same rule the SQL keyset ORDER BY uses (BINARY/ASCII
1897
+ // collation), which holds regardless of id format.
1898
+ const expectedOrder = [...ids].sort();
1899
+
1900
+ // Paginate with a small limit to force several pages across the
1901
+ // format boundary and confirm no note is skipped or repeated.
1902
+ //
1903
+ // The first call MUST pass `cursor: ""` (the documented bootstrap
1904
+ // value — see queryNotes's cursor-mode doc comment in
1905
+ // core/src/notes.ts), NOT omit `cursor` entirely. Omitting it opts
1906
+ // OUT of cursor/keyset mode altogether, which orders the first page
1907
+ // by `created_at` (insertion order) instead of `updated_at` — a
1908
+ // DIFFERENT order than every subsequent (real-cursor) page uses. That
1909
+ // mismatch produces a watermark from a page in the wrong order, which
1910
+ // can then skip rows once keyset mode takes over on page 2 — exactly
1911
+ // the bug this test exists to catch, so the harness itself must use
1912
+ // the correct bootstrap contract or it can flake based on whether
1913
+ // `created_at` happens to tie across the fixture's notes.
1914
+ const seen: string[] = [];
1915
+ let cursor = "";
1916
+ for (let page = 0; page < 10; page++) {
1917
+ const result = await store.queryNotesPaged({ limit: 2, cursor });
1918
+ if (result.notes.length === 0) break;
1919
+ seen.push(...result.notes.map((n) => n.id));
1920
+ cursor = result.next_cursor;
1921
+ }
1922
+
1923
+ expect(seen).toEqual(expectedOrder);
1924
+ expect(new Set(seen).size).toBe(seen.length);
1925
+ });
1926
+ });
1927
+
1618
1928
  it("limits results", async () => {
1619
1929
  for (let i = 0; i < 5; i++) await store.createNote(`Note ${i}`);
1620
1930
  const results = await store.queryNotes({ limit: 3 });
@@ -2355,6 +2665,164 @@ describe("MCP tools", async () => {
2355
2665
  expect(links.some((l) => l.targetId === target.id && l.relationship === "knows")).toBe(true);
2356
2666
  });
2357
2667
 
2668
+ // vault#570 — content-parsed [[wikilinks]] to a missing target used to
2669
+ // fire NO write-time warning at all, even though the equivalent
2670
+ // structured `links` entry (tested above) already did. This closes that
2671
+ // asymmetry: the SAME `unresolved_link` warning shape, sourced from
2672
+ // content instead of `links`.
2673
+ it("create-note with a content [[wikilink]] to a missing target: warns (unresolved_link), same as structured links", async () => {
2674
+ const tools = generateMcpTools(store);
2675
+ const createNote = tools.find((t) => t.name === "create-note")!;
2676
+ const source = await createNote.execute({
2677
+ content: "See [[Missing Note]] for details.",
2678
+ }) as any;
2679
+
2680
+ expect(source.warnings).toBeDefined();
2681
+ expect(source.warnings).toHaveLength(1);
2682
+ expect(source.warnings[0].code).toBe("unresolved_link");
2683
+ expect(source.warnings[0].target).toBe("Missing Note");
2684
+ expect(source.warnings[0].relationship).toBe("wikilink");
2685
+ expect(await store.getLinks(source.id, { direction: "outbound" })).toHaveLength(0);
2686
+
2687
+ // Backfills automatically, same as the structured-link contract.
2688
+ const target = await store.createNote("here now", { path: "Missing Note" });
2689
+ const links = await store.getLinks(source.id, { direction: "outbound" });
2690
+ expect(links).toHaveLength(1);
2691
+ expect(links[0]!.targetId).toBe(target.id);
2692
+ });
2693
+
2694
+ // vault#570 — a target matching ≥2 notes (e.g. two notes sharing a
2695
+ // basename/title) is a factually different situation from "no note
2696
+ // matches" — reporting it as `unresolved_link` ("did not resolve to any
2697
+ // note") would be WRONG (it matched two, not zero). Must surface a
2698
+ // distinct `ambiguous_link` warning instead, and must NOT create an edge
2699
+ // (there's no principled way to pick one of the two candidates).
2700
+ it("create-note with a content [[wikilink]] to an AMBIGUOUS target (2 same-title notes): ambiguous_link, not unresolved_link, no edge", async () => {
2701
+ const a = await store.createNote("A", { path: "Folder1/Shared Title" });
2702
+ const b = await store.createNote("B", { path: "Folder2/Shared Title" });
2703
+
2704
+ const tools = generateMcpTools(store);
2705
+ const createNote = tools.find((t) => t.name === "create-note")!;
2706
+ const source = await createNote.execute({
2707
+ content: "See [[Shared Title]] for details.",
2708
+ }) as any;
2709
+
2710
+ expect(source.warnings).toBeDefined();
2711
+ expect(source.warnings).toHaveLength(1);
2712
+ expect(source.warnings[0].code).toBe("ambiguous_link");
2713
+ expect(source.warnings[0].target).toBe("Shared Title");
2714
+ expect(source.warnings[0].relationship).toBe("wikilink");
2715
+ expect(source.warnings[0].candidate_count).toBe(2);
2716
+
2717
+ // No edge created to EITHER candidate.
2718
+ const links = await store.getLinks(source.id, { direction: "outbound" });
2719
+ expect(links).toHaveLength(0);
2720
+ expect(links.some((l) => l.targetId === a.id || l.targetId === b.id)).toBe(false);
2721
+
2722
+ // Not queued for lazy resolution either — a future note being created
2723
+ // can't retroactively resolve an ambiguity between two EXISTING notes.
2724
+ // The table itself may not even exist yet (nothing else in this test
2725
+ // ever queued anything) — `PRAGMA table_info` on a nonexistent table
2726
+ // returns zero rows rather than throwing, same tolerance the wikilinks
2727
+ // module itself uses.
2728
+ const tableExists = (db.prepare("PRAGMA table_info(unresolved_wikilinks)").all() as unknown[]).length > 0;
2729
+ if (tableExists) {
2730
+ const pending = db.prepare(
2731
+ "SELECT * FROM unresolved_wikilinks WHERE source_id = ?",
2732
+ ).all(source.id) as unknown[];
2733
+ expect(pending).toHaveLength(0);
2734
+ }
2735
+ });
2736
+
2737
+ // vault#570 — the same ambiguous-target treatment applies to structured
2738
+ // `links`, not just content [[wikilinks]] (resolveOrQueueLink is the
2739
+ // shared implementation both paths call).
2740
+ it("create-note with a structured link to an AMBIGUOUS target: ambiguous_link, no edge", async () => {
2741
+ await store.createNote("A", { path: "Folder1/Dup" });
2742
+ await store.createNote("B", { path: "Folder2/Dup" });
2743
+
2744
+ const tools = generateMcpTools(store);
2745
+ const createNote = tools.find((t) => t.name === "create-note")!;
2746
+ const source = await createNote.execute({
2747
+ content: "no wikilinks here",
2748
+ links: [{ target: "Dup", relationship: "knows" }],
2749
+ }) as any;
2750
+
2751
+ expect(source.warnings).toBeDefined();
2752
+ expect(source.warnings).toHaveLength(1);
2753
+ expect(source.warnings[0].code).toBe("ambiguous_link");
2754
+ expect(source.warnings[0].target).toBe("Dup");
2755
+ expect(source.warnings[0].relationship).toBe("knows");
2756
+ expect(source.warnings[0].candidate_count).toBe(2);
2757
+ expect(await store.getLinks(source.id, { direction: "outbound" })).toHaveLength(0);
2758
+ });
2759
+
2760
+ // vault#570 — a content [[wikilink]] to a note created LATER in the same
2761
+ // create-note batch must resolve silently (forward-ref), exactly like
2762
+ // structured `links` already do — no spurious `unresolved_link` warning
2763
+ // just because the classification pass runs before this fix would have.
2764
+ it("create-note batch: a content [[wikilink]] to a note created LATER in the same batch resolves without warning", async () => {
2765
+ const tools = generateMcpTools(store);
2766
+ const createNote = tools.find((t) => t.name === "create-note")!;
2767
+ const result = await createNote.execute({
2768
+ notes: [
2769
+ { path: "WikiA", content: "links to [[WikiB]]" },
2770
+ { path: "WikiB", content: "the target" },
2771
+ ],
2772
+ }) as any[];
2773
+ const a = result.find((n: any) => n.path === "WikiA");
2774
+ expect(a.warnings).toBeUndefined();
2775
+ const links = await store.getLinks(a.id, { direction: "outbound" });
2776
+ expect(links).toHaveLength(1);
2777
+ expect(links[0]!.relationship).toBe("wikilink");
2778
+ });
2779
+
2780
+ // vault#570 — update-note's content replace path gets the same
2781
+ // unresolved_link/ambiguous_link treatment as create-note.
2782
+ it("update-note content replace with a [[wikilink]] to a missing target: warns (unresolved_link)", async () => {
2783
+ const note = await store.createNote("plain content", { path: "Updatable" });
2784
+ const tools = generateMcpTools(store);
2785
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2786
+
2787
+ const result = await updateNote.execute({
2788
+ id: note.id,
2789
+ content: "now references [[Not Yet Real]]",
2790
+ force: true,
2791
+ }) as any;
2792
+
2793
+ expect(result.warnings).toBeDefined();
2794
+ expect(result.warnings[0].code).toBe("unresolved_link");
2795
+ expect(result.warnings[0].target).toBe("Not Yet Real");
2796
+
2797
+ // A tags/links-only update on the SAME note must NOT re-surface the
2798
+ // warning — nothing about content changed on this call.
2799
+ const tagOnly = await updateNote.execute({
2800
+ id: note.id,
2801
+ tags: { add: ["x"] },
2802
+ force: true,
2803
+ }) as any;
2804
+ expect(tagOnly.warnings).toBeUndefined();
2805
+ });
2806
+
2807
+ it("update-note content replace with a [[wikilink]] to an AMBIGUOUS target: ambiguous_link, no edge", async () => {
2808
+ await store.createNote("A", { path: "Folder1/Both" });
2809
+ await store.createNote("B", { path: "Folder2/Both" });
2810
+ const note = await store.createNote("plain content", { path: "Updatable2" });
2811
+ const tools = generateMcpTools(store);
2812
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2813
+
2814
+ const result = await updateNote.execute({
2815
+ id: note.id,
2816
+ content: "now references [[Both]]",
2817
+ force: true,
2818
+ }) as any;
2819
+
2820
+ expect(result.warnings).toBeDefined();
2821
+ expect(result.warnings[0].code).toBe("ambiguous_link");
2822
+ expect(result.warnings[0].candidate_count).toBe(2);
2823
+ expect(await store.getLinks(note.id, { direction: "outbound" })).toHaveLength(0);
2824
+ });
2825
+
2358
2826
  it("update-note tool updates created_at", async () => {
2359
2827
  const note = await store.createNote("Test");
2360
2828
  const tools = generateMcpTools(store);
@@ -3347,6 +3815,36 @@ describe("MCP tools", async () => {
3347
3815
  expect(result.content).toBe("By Path");
3348
3816
  });
3349
3817
 
3818
+ // ---- Title-fallback id resolution (additive — vault "title-fallback link + id resolution") ----
3819
+
3820
+ it("query-notes single note by H1 title when id AND path both miss", async () => {
3821
+ await store.createNote("# My Great Note\n\nBody.", { path: "Inbox/2026-07-10-xyz" });
3822
+ const tools = generateMcpTools(store);
3823
+ const query = tools.find((t) => t.name === "query-notes")!;
3824
+ const result = await query.execute({ id: "My Great Note" }) as any;
3825
+ expect(result.content).toBe("# My Great Note\n\nBody.");
3826
+ expect(result.path).toBe("Inbox/2026-07-10-xyz");
3827
+ });
3828
+
3829
+ it("query-notes exact path still wins over a same-named title on another note", async () => {
3830
+ const byPath = await store.createNote("Path note", { path: "My Great Note" });
3831
+ await store.createNote("# My Great Note\n\nOther body.", { path: "Inbox/other" });
3832
+ const tools = generateMcpTools(store);
3833
+ const query = tools.find((t) => t.name === "query-notes")!;
3834
+ const result = await query.execute({ id: "My Great Note" }) as any;
3835
+ expect(result.id).toBe(byPath.id);
3836
+ expect(result.content).toBe("Path note");
3837
+ });
3838
+
3839
+ it("query-notes stays unresolved (not_found) when 2+ notes share the same H1 title", async () => {
3840
+ await store.createNote("# Dup Note\n\nA.", { path: "Inbox/dup-a" });
3841
+ await store.createNote("# Dup Note\n\nB.", { path: "Inbox/dup-b" });
3842
+ const tools = generateMcpTools(store);
3843
+ const query = tools.find((t) => t.name === "query-notes")!;
3844
+ const result = await query.execute({ id: "Dup Note" }) as any;
3845
+ expect(result.error_type).toBe("not_found");
3846
+ });
3847
+
3350
3848
  it("query-notes by tag", async () => {
3351
3849
  await store.createNote("Test", { tags: ["daily"] });
3352
3850
  const tools = generateMcpTools(store);
@@ -3875,10 +4373,205 @@ describe("MCP tools", async () => {
3875
4373
  d: { type: "boolean" },
3876
4374
  e: { type: "array" },
3877
4375
  f: { type: "object" },
4376
+ g: { type: "reference" },
3878
4377
  },
3879
4378
  }) as any;
3880
4379
  expect(result.fields.a.type).toBe("string");
3881
4380
  expect(result.fields.f.type).toBe("object");
4381
+ expect(result.fields.g.type).toBe("reference");
4382
+ });
4383
+
4384
+ // ---- Typed reference field: indexed value + auto-link (vault#typed-reference-field) ----
4385
+
4386
+ describe("typed reference field", () => {
4387
+ it("create-note with a reference-typed field indexes the value AND creates the link", async () => {
4388
+ const target = await store.createNote("Alice", { path: "People/Alice" });
4389
+ await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
4390
+ const tools = generateMcpTools(store);
4391
+ const createNote = tools.find((t) => t.name === "create-note")!;
4392
+ const result = await createNote.execute({
4393
+ content: "Ship it",
4394
+ tags: ["task"],
4395
+ metadata: { assignee: "People/Alice" },
4396
+ }) as any;
4397
+
4398
+ // (a) the value half — stored + readable like a string field.
4399
+ expect(result.metadata.assignee).toBe("People/Alice");
4400
+
4401
+ // (b) the link half — a graph edge to the resolved target, relationship = field name.
4402
+ const links = await store.getLinks(result.id, { direction: "outbound" });
4403
+ const assigneeLink = links.find((l) => l.relationship === "assignee");
4404
+ expect(assigneeLink).toBeDefined();
4405
+ expect(assigneeLink!.targetId).toBe(target.id);
4406
+ });
4407
+
4408
+ it("resolves a reference field by note ID as well as path/title", async () => {
4409
+ const target = await store.createNote("Bob", { id: "bob-id" });
4410
+ await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
4411
+ const tools = generateMcpTools(store);
4412
+ const createNote = tools.find((t) => t.name === "create-note")!;
4413
+ const result = await createNote.execute({
4414
+ content: "Ship it",
4415
+ tags: ["task"],
4416
+ metadata: { assignee: "bob-id" },
4417
+ }) as any;
4418
+
4419
+ const links = await store.getLinks(result.id, { direction: "outbound" });
4420
+ expect(links.find((l) => l.relationship === "assignee")?.targetId).toBe(target.id);
4421
+ });
4422
+
4423
+ it("querying by a reference field's value works (plain metadata filter and, when indexed, the eq operator)", async () => {
4424
+ await store.createNote("Alice", { path: "People/Alice", id: "alice" });
4425
+ const tools = generateMcpTools(store);
4426
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4427
+ await updateTag.execute({
4428
+ tag: "task",
4429
+ fields: { assignee: { type: "reference", indexed: true } },
4430
+ });
4431
+ const createNote = tools.find((t) => t.name === "create-note")!;
4432
+ const created = await createNote.execute({
4433
+ content: "Ship it",
4434
+ tags: ["task"],
4435
+ metadata: { assignee: "alice" },
4436
+ }) as any;
4437
+
4438
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
4439
+ const plain = await queryNotes.execute({ tags: ["task"], metadata: { assignee: "alice" } }) as any[];
4440
+ expect(plain.map((n: any) => n.id)).toContain(created.id);
4441
+
4442
+ const viaOperator = await queryNotes.execute({
4443
+ tags: ["task"],
4444
+ metadata: { assignee: { eq: "alice" } },
4445
+ }) as any[];
4446
+ expect(viaOperator.map((n: any) => n.id)).toContain(created.id);
4447
+ });
4448
+
4449
+ it("update-note changing a reference field's value moves the link (old edge dropped, new edge created)", async () => {
4450
+ const alice = await store.createNote("Alice", { id: "alice" });
4451
+ const bob = await store.createNote("Bob", { id: "bob" });
4452
+ await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
4453
+ const tools = generateMcpTools(store);
4454
+ const createNote = tools.find((t) => t.name === "create-note")!;
4455
+ const note = await createNote.execute({
4456
+ content: "Ship it",
4457
+ tags: ["task"],
4458
+ metadata: { assignee: "alice" },
4459
+ }) as any;
4460
+
4461
+ let links = await store.getLinks(note.id, { direction: "outbound" });
4462
+ expect(links.filter((l) => l.relationship === "assignee").map((l) => l.targetId)).toEqual([alice.id]);
4463
+
4464
+ const updateNote = tools.find((t) => t.name === "update-note")!;
4465
+ await updateNote.execute({ id: note.id, metadata: { assignee: "bob" }, force: true });
4466
+
4467
+ links = await store.getLinks(note.id, { direction: "outbound" });
4468
+ const assigneeLinks = links.filter((l) => l.relationship === "assignee");
4469
+ expect(assigneeLinks).toHaveLength(1);
4470
+ expect(assigneeLinks[0]!.targetId).toBe(bob.id);
4471
+
4472
+ const fresh = await store.getNote(note.id);
4473
+ expect(fresh!.metadata!.assignee).toBe("bob");
4474
+ });
4475
+
4476
+ it("update-note clearing a reference field drops the link", async () => {
4477
+ await store.createNote("Alice", { id: "alice" });
4478
+ await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
4479
+ const tools = generateMcpTools(store);
4480
+ const createNote = tools.find((t) => t.name === "create-note")!;
4481
+ const note = await createNote.execute({
4482
+ content: "Ship it",
4483
+ tags: ["task"],
4484
+ metadata: { assignee: "alice" },
4485
+ }) as any;
4486
+
4487
+ let links = await store.getLinks(note.id, { direction: "outbound" });
4488
+ expect(links.some((l) => l.relationship === "assignee")).toBe(true);
4489
+
4490
+ const updateNote = tools.find((t) => t.name === "update-note")!;
4491
+ // RFC 7386 tombstone — null deletes the key.
4492
+ await updateNote.execute({ id: note.id, metadata: { assignee: null }, force: true });
4493
+
4494
+ links = await store.getLinks(note.id, { direction: "outbound" });
4495
+ expect(links.some((l) => l.relationship === "assignee")).toBe(false);
4496
+
4497
+ const fresh = await store.getNote(note.id);
4498
+ expect(fresh!.metadata).not.toHaveProperty("assignee");
4499
+ });
4500
+
4501
+ it("re-writing a reference field with the SAME value is a no-op (link untouched)", async () => {
4502
+ const alice = await store.createNote("Alice", { id: "alice" });
4503
+ await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
4504
+ const tools = generateMcpTools(store);
4505
+ const createNote = tools.find((t) => t.name === "create-note")!;
4506
+ const note = await createNote.execute({
4507
+ content: "Ship it",
4508
+ tags: ["task"],
4509
+ metadata: { assignee: "alice" },
4510
+ }) as any;
4511
+
4512
+ const before = await store.getLinks(note.id, { direction: "outbound" });
4513
+ const beforeLink = before.find((l) => l.relationship === "assignee");
4514
+ expect(beforeLink).toBeDefined();
4515
+
4516
+ const updateNote = tools.find((t) => t.name === "update-note")!;
4517
+ await updateNote.execute({ id: note.id, metadata: { assignee: "alice" }, force: true });
4518
+
4519
+ const after = await store.getLinks(note.id, { direction: "outbound" });
4520
+ const afterLinks = after.filter((l) => l.relationship === "assignee");
4521
+ expect(afterLinks).toHaveLength(1);
4522
+ expect(afterLinks[0]!.targetId).toBe(alice.id);
4523
+ // Same createdAt — proves the edge was never dropped and recreated.
4524
+ expect(afterLinks[0]!.createdAt).toBe(beforeLink!.createdAt);
4525
+ });
4526
+
4527
+ it("a reference field's unresolved target queues and backfills when the target note is created later", async () => {
4528
+ await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
4529
+ const tools = generateMcpTools(store);
4530
+ const createNote = tools.find((t) => t.name === "create-note")!;
4531
+ const note = await createNote.execute({
4532
+ content: "Ship it",
4533
+ tags: ["task"],
4534
+ metadata: { assignee: "Not Yet Created" },
4535
+ }) as any;
4536
+
4537
+ let links = await store.getLinks(note.id, { direction: "outbound" });
4538
+ expect(links.some((l) => l.relationship === "assignee")).toBe(false);
4539
+
4540
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
4541
+ const broken = await queryNotes.execute({ has_broken_links: true }) as any[];
4542
+ expect(broken.map((n: any) => n.id)).toContain(note.id);
4543
+
4544
+ const target = await store.createNote("here now", { path: "Not Yet Created" });
4545
+
4546
+ links = await store.getLinks(note.id, { direction: "outbound" });
4547
+ const assigneeLink = links.find((l) => l.relationship === "assignee");
4548
+ expect(assigneeLink).toBeDefined();
4549
+ expect(assigneeLink!.targetId).toBe(target.id);
4550
+ });
4551
+
4552
+ it("a content-only or tags-only update does not touch a note's reference-field link", async () => {
4553
+ const alice = await store.createNote("Alice", { id: "alice" });
4554
+ await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
4555
+ const tools = generateMcpTools(store);
4556
+ const createNote = tools.find((t) => t.name === "create-note")!;
4557
+ const note = await createNote.execute({
4558
+ content: "Ship it",
4559
+ tags: ["task"],
4560
+ metadata: { assignee: "alice" },
4561
+ }) as any;
4562
+
4563
+ const before = await store.getLinks(note.id, { direction: "outbound" });
4564
+ const beforeLink = before.find((l) => l.relationship === "assignee");
4565
+
4566
+ // Content-only update — no `metadata` key in the call at all.
4567
+ await store.updateNote(note.id, { content: "Ship it faster" });
4568
+
4569
+ const after = await store.getLinks(note.id, { direction: "outbound" });
4570
+ const afterLink = after.find((l) => l.relationship === "assignee");
4571
+ expect(afterLink).toBeDefined();
4572
+ expect(afterLink!.targetId).toBe(alice.id);
4573
+ expect(afterLink!.createdAt).toBe(beforeLink!.createdAt);
4574
+ });
3882
4575
  });
3883
4576
 
3884
4577
  it("find-path works with ID/path resolution", async () => {
@@ -6983,3 +7676,45 @@ describe("vault projection (vault#271)", async () => {
6983
7676
  expect(approxTokens).toBeLessThan(5000);
6984
7677
  });
6985
7678
  });
7679
+
7680
+ // ---- Path write-validation (vault#589 / FIX 2) ----
7681
+
7682
+ describe("validatePath — reject NUL / '..' at the write surface", () => {
7683
+ const NUL = String.fromCharCode(0);
7684
+
7685
+ it("rejects a NUL byte in the path", () => {
7686
+ expect(() => validatePath(`bad${NUL}path`)).toThrow(PathValidationError);
7687
+ try {
7688
+ validatePath(`x${NUL}y`);
7689
+ throw new Error("expected validatePath to throw");
7690
+ } catch (e: any) {
7691
+ expect(e.code).toBe("INVALID_PATH");
7692
+ expect(e.error_type).toBe("invalid_path");
7693
+ expect(e.reason).toMatch(/NUL/);
7694
+ }
7695
+ });
7696
+
7697
+ it("rejects a '..' path segment (traversal-shaped)", () => {
7698
+ expect(() => validatePath("..")).toThrow(PathValidationError);
7699
+ expect(() => validatePath("../secrets")).toThrow(PathValidationError);
7700
+ expect(() => validatePath("a/../b")).toThrow(PathValidationError);
7701
+ expect(() => validatePath("a/..")).toThrow(PathValidationError);
7702
+ // Backslash form normalizes to '/', so it's caught too.
7703
+ expect(() => validatePath("a\\..\\b")).toThrow(PathValidationError);
7704
+ });
7705
+
7706
+ it("accepts legitimate paths, including ones that merely contain dots", () => {
7707
+ expect(() => validatePath("Projects/Parachute/README")).not.toThrow();
7708
+ expect(() => validatePath("notes/2026-07-09")).not.toThrow();
7709
+ expect(() => validatePath("weird...name")).not.toThrow(); // three literal dots, not a '..' segment
7710
+ expect(() => validatePath("a/.hidden/b")).not.toThrow(); // single-dot-prefixed segment is fine
7711
+ expect(() => validatePath("with spaces and : colon?")).not.toThrow(); // FORBIDDEN_CHARS stay un-enforced at write
7712
+ });
7713
+
7714
+ it("treats a null / undefined / empty path as valid (notes need no path)", () => {
7715
+ expect(() => validatePath(null)).not.toThrow();
7716
+ expect(() => validatePath(undefined)).not.toThrow();
7717
+ expect(() => validatePath("")).not.toThrow();
7718
+ expect(() => validatePath(" ")).not.toThrow(); // normalizes to null
7719
+ });
7720
+ });