@openparachute/vault 0.7.2-rc.4 → 0.7.2-rc.6

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.
@@ -3,7 +3,7 @@ import { Database } from "bun:sqlite";
3
3
  import { SqliteStore } from "./store.js";
4
4
  import { generateMcpTools } from "./mcp.js";
5
5
  import { initSchema } from "./schema.js";
6
- import { decodeCursor } from "./cursor.js";
6
+ import { decodeCursor, timestampToMs } from "./cursor.js";
7
7
  import { traverseLinks } from "./links.js";
8
8
  import * as indexedFieldOps from "./indexed-fields.js";
9
9
  import { resolveLinkTarget } from "./wikilinks.js";
@@ -1493,11 +1493,15 @@ describe("queryNotes", async () => {
1493
1493
  await store.updateNote(b.id, { append: " edit" });
1494
1494
 
1495
1495
  // Pin each note's updated_at deterministically so the assertion isn't
1496
- // racing real wall-clock writes from the test harness.
1497
- db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
1498
- .run("2026-01-15T00:00:00.000Z", a.id);
1499
- db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
1500
- .run("2026-04-25T00:00:00.000Z", b.id);
1496
+ // racing real wall-clock writes from the test harness. Mirror
1497
+ // production (vault#586): EVERY write maintains `updated_at_ms` in
1498
+ // lockstep with `updated_at` — the vault#585 fix compares the ms
1499
+ // mirror, not the TEXT column, so a raw pin that only touched the
1500
+ // TEXT column would silently stop affecting this filter.
1501
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
1502
+ .run("2026-01-15T00:00:00.000Z", Date.parse("2026-01-15T00:00:00.000Z"), a.id);
1503
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
1504
+ .run("2026-04-25T00:00:00.000Z", Date.parse("2026-04-25T00:00:00.000Z"), b.id);
1501
1505
 
1502
1506
  const results = await store.queryNotes({
1503
1507
  dateFilter: { field: "updated_at", from: "2026-04-01" },
@@ -1519,16 +1523,69 @@ describe("queryNotes", async () => {
1519
1523
  it("dateFilter on updated_at honors the upper-bound exclusive `to`", async () => {
1520
1524
  const a = await store.createNote("inside-window");
1521
1525
  const b = await store.createNote("after-window");
1522
- db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
1523
- .run("2026-04-25T00:00:00.000Z", a.id);
1524
- db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
1525
- .run("2026-05-15T00:00:00.000Z", b.id);
1526
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
1527
+ .run("2026-04-25T00:00:00.000Z", Date.parse("2026-04-25T00:00:00.000Z"), a.id);
1528
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
1529
+ .run("2026-05-15T00:00:00.000Z", Date.parse("2026-05-15T00:00:00.000Z"), b.id);
1526
1530
 
1527
1531
  const results = await store.queryNotes({
1528
1532
  dateFilter: { field: "updated_at", from: "2026-04-01", to: "2026-05-01" },
1529
1533
  });
1530
1534
  expect(results.map((n) => n.content)).toEqual(["inside-window"]);
1531
1535
  });
1536
+
1537
+ // ---- vault#585: date_filter on updated_at compares the ms mirror ----
1538
+ //
1539
+ // Simulates a post-vault#586-migration vault: `updated_at_ms` is
1540
+ // correctly backfilled from the non-canonical stored `updated_at` TEXT
1541
+ // via `timestampToMs` (exactly what `migrateToV26` does on import), but
1542
+ // the TEXT column itself stays non-canonical — import stores frontmatter
1543
+ // timestamps VERBATIM. Before vault#585, `date_filter` on `updated_at`
1544
+ // still compared the RAW TEXT column lexicographically — wrong on these
1545
+ // non-canonical forms even though the correct ms value already sat in
1546
+ // `updated_at_ms`.
1547
+ function pinNonCanonicalUpdatedAt(id: string, rawTimestamp: string) {
1548
+ const ms = timestampToMs(rawTimestamp);
1549
+ if (ms === null) throw new Error(`test fixture bug: unparseable timestamp ${rawTimestamp}`);
1550
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
1551
+ .run(rawTimestamp, ms, id);
1552
+ }
1553
+
1554
+ it("dateFilter `from` on updated_at is correct against a space-form non-canonical timestamp (vault#585)", async () => {
1555
+ // "2024-11-02 14:30:00" (space separator, no zone) is UTC-correct
1556
+ // 14:30 on Nov 2 per timestampToMs — 4.5 hours AFTER the 10:00 bound,
1557
+ // so it MUST be included. Lexicographic TEXT comparison disagrees:
1558
+ // the space (0x20) sorts BEFORE 'T' (0x54), so the space-form string
1559
+ // compares as LESS than the canonical `T`-separated bound on the same
1560
+ // calendar day — the old TEXT-compare code wrongly EXCLUDED it.
1561
+ const included = await store.createNote("space-form, after bound");
1562
+ pinNonCanonicalUpdatedAt(included.id, "2024-11-02 14:30:00");
1563
+ const excluded = await store.createNote("canonical, before bound");
1564
+ pinNonCanonicalUpdatedAt(excluded.id, "2024-11-01T00:00:00.000Z");
1565
+
1566
+ const results = await store.queryNotes({
1567
+ dateFilter: { field: "updated_at", from: "2024-11-02T10:00:00.000Z" },
1568
+ });
1569
+ expect(results.map((n) => n.content)).toEqual(["space-form, after bound"]);
1570
+ });
1571
+
1572
+ it("dateFilter `to` on updated_at is correct against an offset non-canonical timestamp (vault#585)", async () => {
1573
+ // "2024-11-02T21:00:00+13:00" is UTC-correct 08:00 on Nov 2 per
1574
+ // timestampToMs (21:00 minus the 13-hour offset) — BEFORE the 10:00
1575
+ // bound, so it MUST be included (`to` is the exclusive-upper `<`).
1576
+ // Lexicographic TEXT comparison disagrees: the wall-clock hour "21"
1577
+ // sorts AFTER the bound's "10" as a plain string, so the old
1578
+ // TEXT-compare code wrongly EXCLUDED it.
1579
+ const included = await store.createNote("offset, actually before bound");
1580
+ pinNonCanonicalUpdatedAt(included.id, "2024-11-02T21:00:00+13:00");
1581
+ const excluded = await store.createNote("canonical, after bound");
1582
+ pinNonCanonicalUpdatedAt(excluded.id, "2024-11-02T15:00:00.000Z");
1583
+
1584
+ const results = await store.queryNotes({
1585
+ dateFilter: { field: "updated_at", to: "2024-11-02T10:00:00.000Z" },
1586
+ });
1587
+ expect(results.map((n) => n.content)).toEqual(["offset, actually before bound"]);
1588
+ });
1532
1589
  });
1533
1590
 
1534
1591
  it("sorts ascending and descending", async () => {
@@ -2114,6 +2171,52 @@ describe("queryNotes", async () => {
2114
2171
  expect(store.queryNotes({ orderBy: "foo" })).rejects.toThrow(/not indexed/);
2115
2172
  });
2116
2173
 
2174
+ // ---- vault#585: order_by "updated_at" is a pseudo-field like link_count ----
2175
+ //
2176
+ // Before vault#585, `order_by: "updated_at"` always threw
2177
+ // FIELD_NOT_INDEXED — `updated_at` is a native `notes` column, not a
2178
+ // declared-indexed metadata field, and the generic order_by branch
2179
+ // gated every field (including this one) through `requireIndexedField`
2180
+ // (see the 2026-06 MCP-tool-description-audit CHANGELOG entry — this
2181
+ // was documented, intentional behavior, not a silent mis-sort). This
2182
+ // test proves the NEW capability lands correct from day one: ordering
2183
+ // on the integer `updated_at_ms` mirror (vault#586), immune to the
2184
+ // TEXT-lexicographic trap a plain `ORDER BY updated_at` would fall
2185
+ // into — a space-form timestamp's ' ' (0x20) always sorts BEFORE any
2186
+ // canonical `T`-separated timestamp's 'T' (0x54) on the same calendar
2187
+ // day, regardless of the actual wall-clock instant either encodes.
2188
+ it("order_by: \"updated_at\" sorts correctly across canonical + non-canonical timestamps (vault#585)", async () => {
2189
+ const p = await store.createNote("P earliest (canonical)");
2190
+ const q = await store.createNote("Q middle (space-form, non-canonical)");
2191
+ const r = await store.createNote("R latest (canonical)");
2192
+ const pin = (id: string, raw: string) => {
2193
+ const ms = timestampToMs(raw);
2194
+ if (ms === null) throw new Error(`test fixture bug: unparseable timestamp ${raw}`);
2195
+ db.prepare("UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?").run(raw, ms, id);
2196
+ };
2197
+ // True chronological order: P (08:00) < Q (12:00) < R (16:00). Q's
2198
+ // space-form TEXT ("2024-11-02 12:00:00") sorts BEFORE P's canonical
2199
+ // TEXT ("2024-11-02T08:00:00.000Z") under a plain string compare
2200
+ // (' ' < 'T'), which is exactly backwards.
2201
+ pin(p.id, "2024-11-02T08:00:00.000Z");
2202
+ pin(q.id, "2024-11-02 12:00:00");
2203
+ pin(r.id, "2024-11-02T16:00:00.000Z");
2204
+
2205
+ const asc = await store.queryNotes({ orderBy: "updated_at", sort: "asc" });
2206
+ expect(asc.map((n) => n.content)).toEqual([
2207
+ "P earliest (canonical)",
2208
+ "Q middle (space-form, non-canonical)",
2209
+ "R latest (canonical)",
2210
+ ]);
2211
+
2212
+ const desc = await store.queryNotes({ orderBy: "updated_at", sort: "desc" });
2213
+ expect(desc.map((n) => n.content)).toEqual([
2214
+ "R latest (canonical)",
2215
+ "Q middle (space-form, non-canonical)",
2216
+ "P earliest (canonical)",
2217
+ ]);
2218
+ });
2219
+
2117
2220
  it("unknown operator throws UNKNOWN_OPERATOR with supported-op list", async () => {
2118
2221
  await seedIndexedPriorities();
2119
2222
  expect(
@@ -4572,6 +4675,390 @@ describe("MCP tools", async () => {
4572
4675
  expect(afterLink!.targetId).toBe(alice.id);
4573
4676
  expect(afterLink!.createdAt).toBe(beforeLink!.createdAt);
4574
4677
  });
4678
+
4679
+ it("regression guard: cardinality:'one' (scalar) reference field still creates exactly one edge", async () => {
4680
+ const alice = await store.createNote("Alice", { id: "alice" });
4681
+ await store.upsertTagSchema("task", { fields: { assignee: { type: "reference", cardinality: "one" } } });
4682
+ const tools = generateMcpTools(store);
4683
+ const createNote = tools.find((t) => t.name === "create-note")!;
4684
+ const note = await createNote.execute({
4685
+ content: "Ship it",
4686
+ tags: ["task"],
4687
+ metadata: { assignee: "alice" },
4688
+ }) as any;
4689
+
4690
+ const links = (await store.getLinks(note.id, { direction: "outbound" }))
4691
+ .filter((l) => l.relationship === "assignee");
4692
+ expect(links).toHaveLength(1);
4693
+ expect(links[0]!.targetId).toBe(alice.id);
4694
+ });
4695
+
4696
+ // ---- Gap #2: cardinality:"many" (array) reference fields (vault#typed-reference-field) ----
4697
+
4698
+ it("cardinality:'many' reference field creates one link per array element, deduped, with no spurious type_mismatch warning", async () => {
4699
+ const carol = await store.createNote("Carol", { path: "People/Carol" });
4700
+ const dave = await store.createNote("Dave", { path: "People/Dave" });
4701
+ await store.upsertTagSchema("task", {
4702
+ fields: { collaborators: { type: "reference", cardinality: "many" } },
4703
+ });
4704
+ const tools = generateMcpTools(store);
4705
+ const createNote = tools.find((t) => t.name === "create-note")!;
4706
+ const result = await createNote.execute({
4707
+ content: "Ship it",
4708
+ tags: ["task"],
4709
+ // "People/Carol" appears twice — duplicate elements must dedupe to one edge.
4710
+ metadata: { collaborators: ["People/Carol", "People/Dave", "People/Carol"] },
4711
+ }) as any;
4712
+
4713
+ // No spurious type_mismatch — an array is exactly what cardinality:"many" asks
4714
+ // for; pre-fix, valueMatchesType's "reference" case only accepted strings.
4715
+ expect(result.validation_status.warnings).toEqual([]);
4716
+
4717
+ const links = (await store.getLinks(result.id, { direction: "outbound" }))
4718
+ .filter((l) => l.relationship === "collaborators");
4719
+ expect(links).toHaveLength(2);
4720
+ expect(new Set(links.map((l) => l.targetId))).toEqual(new Set([carol.id, dave.id]));
4721
+
4722
+ // Traversable via find-path, same as a scalar reference link.
4723
+ const findPath = tools.find((t) => t.name === "find-path")!;
4724
+ const path = await findPath.execute({ source: result.id, target: carol.id }) as any;
4725
+ expect(path?.path).toEqual([result.id, carol.id]);
4726
+ });
4727
+
4728
+ it("update-note changing a cardinality:'many' reference array adds the new link, drops the removed one, and leaves the unchanged one alone", async () => {
4729
+ const carol = await store.createNote("Carol", { path: "People/Carol" });
4730
+ const dave = await store.createNote("Dave", { path: "People/Dave" });
4731
+ const erin = await store.createNote("Erin", { path: "People/Erin" });
4732
+ await store.upsertTagSchema("task", {
4733
+ fields: { collaborators: { type: "reference", cardinality: "many" } },
4734
+ });
4735
+ const tools = generateMcpTools(store);
4736
+ const createNote = tools.find((t) => t.name === "create-note")!;
4737
+ const note = await createNote.execute({
4738
+ content: "Ship it",
4739
+ tags: ["task"],
4740
+ metadata: { collaborators: ["People/Carol", "People/Dave"] },
4741
+ }) as any;
4742
+
4743
+ let links = (await store.getLinks(note.id, { direction: "outbound" }))
4744
+ .filter((l) => l.relationship === "collaborators");
4745
+ expect(links).toHaveLength(2);
4746
+ const carolLinkBefore = links.find((l) => l.targetId === carol.id)!;
4747
+
4748
+ const updateNote = tools.find((t) => t.name === "update-note")!;
4749
+ // Drop Dave, add Erin, keep Carol.
4750
+ await updateNote.execute({
4751
+ id: note.id,
4752
+ metadata: { collaborators: ["People/Carol", "People/Erin"] },
4753
+ force: true,
4754
+ });
4755
+
4756
+ links = (await store.getLinks(note.id, { direction: "outbound" }))
4757
+ .filter((l) => l.relationship === "collaborators");
4758
+ expect(links).toHaveLength(2);
4759
+ expect(new Set(links.map((l) => l.targetId))).toEqual(new Set([carol.id, erin.id]));
4760
+ // Dropped — Dave's link is gone.
4761
+ expect(links.some((l) => l.targetId === dave.id)).toBe(false);
4762
+ // Unchanged — Carol's edge is the SAME row (createdAt preserved), proving a
4763
+ // real diff (not a blanket clear-and-recreate under the relationship).
4764
+ const carolLinkAfter = links.find((l) => l.targetId === carol.id)!;
4765
+ expect(carolLinkAfter.createdAt).toBe(carolLinkBefore.createdAt);
4766
+ });
4767
+
4768
+ it("an unresolved array element is queued and backfills when its target note is created later, without disturbing already-resolved siblings", async () => {
4769
+ const carol = await store.createNote("Carol", { path: "People/Carol" });
4770
+ await store.upsertTagSchema("task", {
4771
+ fields: { collaborators: { type: "reference", cardinality: "many" } },
4772
+ });
4773
+ const tools = generateMcpTools(store);
4774
+ const createNote = tools.find((t) => t.name === "create-note")!;
4775
+ const note = await createNote.execute({
4776
+ content: "Ship it",
4777
+ tags: ["task"],
4778
+ metadata: { collaborators: ["People/Carol", "People/Not Yet Created"] },
4779
+ }) as any;
4780
+
4781
+ let links = (await store.getLinks(note.id, { direction: "outbound" }))
4782
+ .filter((l) => l.relationship === "collaborators");
4783
+ expect(links).toHaveLength(1); // only Carol resolves so far
4784
+ expect(links[0]!.targetId).toBe(carol.id);
4785
+
4786
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
4787
+ const broken = await queryNotes.execute({ has_broken_links: true }) as any[];
4788
+ expect(broken.map((n: any) => n.id)).toContain(note.id);
4789
+
4790
+ const target = await store.createNote("here now", { path: "People/Not Yet Created" });
4791
+
4792
+ links = (await store.getLinks(note.id, { direction: "outbound" }))
4793
+ .filter((l) => l.relationship === "collaborators");
4794
+ expect(links).toHaveLength(2);
4795
+ expect(new Set(links.map((l) => l.targetId))).toEqual(new Set([carol.id, target.id]));
4796
+ });
4797
+
4798
+ it("an array element resolving to the note itself creates a self-loop link, matching scalar self-reference behavior", async () => {
4799
+ await store.upsertTagSchema("task", {
4800
+ fields: { collaborators: { type: "reference", cardinality: "many" } },
4801
+ });
4802
+ const tools = generateMcpTools(store);
4803
+ const createNote = tools.find((t) => t.name === "create-note")!;
4804
+ const note = await createNote.execute({
4805
+ content: "Ship it",
4806
+ tags: ["task"],
4807
+ path: "Tasks/Self",
4808
+ metadata: { collaborators: ["Tasks/Self"] },
4809
+ }) as any;
4810
+
4811
+ const links = (await store.getLinks(note.id, { direction: "outbound" }))
4812
+ .filter((l) => l.relationship === "collaborators");
4813
+ expect(links).toHaveLength(1);
4814
+ expect(links[0]!.targetId).toBe(note.id);
4815
+ });
4816
+
4817
+ it("an ambiguous array element (matches ≥2 notes) is neither linked nor queued, mirroring the scalar ambiguous contract", async () => {
4818
+ await store.createNote("Shared Title", { path: "A/Shared Title" });
4819
+ await store.createNote("Shared Title", { path: "B/Shared Title" });
4820
+ const target = await store.createNote("Target", { path: "People/Target" });
4821
+ await store.upsertTagSchema("task", {
4822
+ fields: { collaborators: { type: "reference", cardinality: "many" } },
4823
+ });
4824
+ const tools = generateMcpTools(store);
4825
+ const createNote = tools.find((t) => t.name === "create-note")!;
4826
+ const note = await createNote.execute({
4827
+ content: "Ship it",
4828
+ tags: ["task"],
4829
+ metadata: { collaborators: ["Shared Title", "People/Target"] },
4830
+ }) as any;
4831
+
4832
+ const links = (await store.getLinks(note.id, { direction: "outbound" }))
4833
+ .filter((l) => l.relationship === "collaborators");
4834
+ // Only the unambiguous element links; "Shared Title" is neither linked nor queued.
4835
+ expect(links).toHaveLength(1);
4836
+ expect(links[0]!.targetId).toBe(target.id);
4837
+
4838
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
4839
+ const broken = await queryNotes.execute({ has_broken_links: true }) as any[];
4840
+ expect(broken.map((n: any) => n.id)).not.toContain(note.id);
4841
+ });
4842
+
4843
+ // ---- Gap #3: schema-after-data backfill (vault#typed-reference-field) ----
4844
+
4845
+ it("declaring type:'reference' via update-tag after notes already carry the value backfills links for existing notes (scalar and array)", async () => {
4846
+ const alice = await store.createNote("Alice", { path: "People/Alice" });
4847
+ const bob = await store.createNote("Bob", { path: "People/Bob" });
4848
+ const carol = await store.createNote("Carol", { path: "People/Carol" });
4849
+ const tools = generateMcpTools(store);
4850
+ const createNote = tools.find((t) => t.name === "create-note")!;
4851
+
4852
+ // Written BEFORE the tag declares these fields as `reference` — plain
4853
+ // untyped metadata, no schema applies at write time, so no link is created.
4854
+ const scalarNote = await createNote.execute({
4855
+ content: "Scalar task",
4856
+ tags: ["task"],
4857
+ metadata: { manager: "People/Alice" },
4858
+ }) as any;
4859
+ const arrayNote = await createNote.execute({
4860
+ content: "Array task",
4861
+ tags: ["task"],
4862
+ metadata: { collaborators: ["People/Bob", "People/Carol"] },
4863
+ }) as any;
4864
+
4865
+ expect((await store.getLinks(scalarNote.id, { direction: "outbound" })).length).toBe(0);
4866
+ expect((await store.getLinks(arrayNote.id, { direction: "outbound" })).length).toBe(0);
4867
+
4868
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4869
+ await updateTag.execute({
4870
+ tag: "task",
4871
+ fields: {
4872
+ manager: { type: "reference" },
4873
+ collaborators: { type: "reference", cardinality: "many" },
4874
+ },
4875
+ });
4876
+
4877
+ const scalarLinks = (await store.getLinks(scalarNote.id, { direction: "outbound" }))
4878
+ .filter((l) => l.relationship === "manager");
4879
+ expect(scalarLinks).toHaveLength(1);
4880
+ expect(scalarLinks[0]!.targetId).toBe(alice.id);
4881
+
4882
+ const arrayLinks = (await store.getLinks(arrayNote.id, { direction: "outbound" }))
4883
+ .filter((l) => l.relationship === "collaborators");
4884
+ expect(arrayLinks).toHaveLength(2);
4885
+ expect(new Set(arrayLinks.map((l) => l.targetId))).toEqual(new Set([bob.id, carol.id]));
4886
+
4887
+ // Idempotency: a LATER update-tag call that declares a NEW reference
4888
+ // field re-triggers the tag's whole backfill walk (see
4889
+ // `backfillReferenceFieldLinks` — it re-syncs every reference field on
4890
+ // each note, not just the newly-added one). Already-synced links must
4891
+ // not duplicate.
4892
+ await updateTag.execute({
4893
+ tag: "task",
4894
+ fields: { reviewer: { type: "reference" } },
4895
+ });
4896
+
4897
+ const scalarLinksAfter = (await store.getLinks(scalarNote.id, { direction: "outbound" }))
4898
+ .filter((l) => l.relationship === "manager");
4899
+ expect(scalarLinksAfter).toHaveLength(1);
4900
+ const arrayLinksAfter = (await store.getLinks(arrayNote.id, { direction: "outbound" }))
4901
+ .filter((l) => l.relationship === "collaborators");
4902
+ expect(arrayLinksAfter).toHaveLength(2);
4903
+ });
4904
+
4905
+ it("declaring type:'reference' on a PARENT tag backfills links for notes carrying a descendant (child) tag", async () => {
4906
+ const alice = await store.createNote("Alice", { path: "People/Alice" });
4907
+ const tools = generateMcpTools(store);
4908
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4909
+ // `subtask` is a child of `task` via parent_names — schema inheritance
4910
+ // (vault#270) means `subtask` notes are in `task`'s effective walk.
4911
+ await updateTag.execute({ tag: "subtask", parent_names: ["task"] });
4912
+
4913
+ const createNote = tools.find((t) => t.name === "create-note")!;
4914
+ const note = await createNote.execute({
4915
+ content: "Child task",
4916
+ tags: ["subtask"],
4917
+ metadata: { manager: "People/Alice" },
4918
+ }) as any;
4919
+
4920
+ expect((await store.getLinks(note.id, { direction: "outbound" })).length).toBe(0);
4921
+
4922
+ await updateTag.execute({ tag: "task", fields: { manager: { type: "reference" } } });
4923
+
4924
+ const links = (await store.getLinks(note.id, { direction: "outbound" }))
4925
+ .filter((l) => l.relationship === "manager");
4926
+ expect(links).toHaveLength(1);
4927
+ expect(links[0]!.targetId).toBe(alice.id);
4928
+ });
4929
+
4930
+ // ---- Round-4 review fixes (2026-07-10) ----
4931
+
4932
+ it("BLOCKER 1: the schema-declaration backfill walks ALL notes, not the first 100", async () => {
4933
+ // One shared target every note references.
4934
+ const target = await store.createNote("Target", { path: "People/Target" });
4935
+ const tools = generateMcpTools(store);
4936
+ const createNote = tools.find((t) => t.name === "create-note")!;
4937
+
4938
+ // 120 notes each carrying a `manager` value BEFORE the field is a
4939
+ // reference — exceeds queryNotes' default LIMIT 100.
4940
+ const noteIds: string[] = [];
4941
+ for (let i = 0; i < 120; i++) {
4942
+ const n = await createNote.execute({
4943
+ content: `Task ${i}`,
4944
+ tags: ["task"],
4945
+ metadata: { manager: "People/Target" },
4946
+ }) as any;
4947
+ noteIds.push(n.id);
4948
+ }
4949
+
4950
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4951
+ await updateTag.execute({ tag: "task", fields: { manager: { type: "reference" } } });
4952
+
4953
+ // EVERY one of the 120 notes must have a manager edge — a 100-cap would
4954
+ // leave 20 unlinked (the silent-half-graph this feature prevents).
4955
+ let linked = 0;
4956
+ for (const id of noteIds) {
4957
+ const links = (await store.getLinks(id, { direction: "outbound" }))
4958
+ .filter((l) => l.relationship === "manager" && l.targetId === target.id);
4959
+ if (links.length === 1) linked++;
4960
+ }
4961
+ expect(linked).toBe(120);
4962
+ });
4963
+
4964
+ it("BLOCKER 2: re-declaring an ALREADY-reference field via update-tag heals notes whose links were never built", async () => {
4965
+ const alice = await store.createNote("Alice", { path: "People/Alice" });
4966
+ const tools = generateMcpTools(store);
4967
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4968
+ const createNote = tools.find((t) => t.name === "create-note")!;
4969
+
4970
+ // Field is declared reference from the start, and a note is written.
4971
+ await updateTag.execute({ tag: "task", fields: { manager: { type: "reference" } } });
4972
+ const note = await createNote.execute({
4973
+ content: "Ship it",
4974
+ tags: ["task"],
4975
+ metadata: { manager: "People/Alice" },
4976
+ }) as any;
4977
+ expect((await store.getLinks(note.id, { direction: "outbound" }))
4978
+ .filter((l) => l.relationship === "manager")).toHaveLength(1);
4979
+
4980
+ // Simulate a pre-fix vault: the edge was never built. Delete it directly,
4981
+ // leaving the metadata value in place but the graph edge missing.
4982
+ await store.deleteLink(note.id, alice.id, "manager");
4983
+ expect((await store.getLinks(note.id, { direction: "outbound" }))
4984
+ .filter((l) => l.relationship === "manager")).toHaveLength(0);
4985
+
4986
+ // Re-declare the SAME already-reference field — the documented heal path.
4987
+ // A transition-keyed trigger would no-op here; the fix fires the backfill
4988
+ // for any reference field in the declared schema.
4989
+ await updateTag.execute({ tag: "task", fields: { manager: { type: "reference" } } });
4990
+
4991
+ const healed = (await store.getLinks(note.id, { direction: "outbound" }))
4992
+ .filter((l) => l.relationship === "manager");
4993
+ expect(healed).toHaveLength(1);
4994
+ expect(healed[0]!.targetId).toBe(alice.id);
4995
+ });
4996
+
4997
+ it("NIT 3: dropping an array element reconciles against RESOLVED targets — a since-renamed target's stale edge is dropped, and an aliased-same-target edge survives", async () => {
4998
+ const dave = await store.createNote("Dave", { path: "People/Dave" });
4999
+ const erin = await store.createNote("Erin", { path: "People/Erin" });
5000
+ await store.upsertTagSchema("task", {
5001
+ fields: { collaborators: { type: "reference", cardinality: "many" } },
5002
+ });
5003
+ const tools = generateMcpTools(store);
5004
+ const createNote = tools.find((t) => t.name === "create-note")!;
5005
+ const updateNote = tools.find((t) => t.name === "update-note")!;
5006
+
5007
+ // --- Corner (a): a still-present element whose target is renamed after
5008
+ // linking; then a DIFFERENT element is dropped. The renamed target's
5009
+ // stale edge must be reconciled away (the old re-resolve-removed-value
5010
+ // approach left it forever).
5011
+ const note = await createNote.execute({
5012
+ content: "Ship it",
5013
+ tags: ["task"],
5014
+ metadata: { collaborators: ["People/Dave", "People/Erin"] },
5015
+ }) as any;
5016
+ expect((await store.getLinks(note.id, { direction: "outbound" }))
5017
+ .filter((l) => l.relationship === "collaborators")).toHaveLength(2);
5018
+
5019
+ // Rename Dave's target note — the raw value "People/Dave" no longer resolves.
5020
+ await updateNote.execute({ id: dave.id, path: "People/David", force: true });
5021
+
5022
+ // Drop Erin (a different element); keep the now-stale "People/Dave".
5023
+ await updateNote.execute({
5024
+ id: note.id,
5025
+ metadata: { collaborators: ["People/Dave"] },
5026
+ force: true,
5027
+ });
5028
+
5029
+ const afterA = (await store.getLinks(note.id, { direction: "outbound" }))
5030
+ .filter((l) => l.relationship === "collaborators");
5031
+ // Erin dropped; Dave's stale edge (target renamed, value no longer
5032
+ // resolves) reconciled away → zero edges, not a lingering stale one.
5033
+ expect(afterA.some((l) => l.targetId === erin.id)).toBe(false);
5034
+ expect(afterA.some((l) => l.targetId === dave.id)).toBe(false);
5035
+
5036
+ // --- Corner (c): two elements ALIAS the same target (path + H1 title).
5037
+ // Dropping one alias must keep the shared edge (still referenced by the
5038
+ // survivor), not delete it.
5039
+ const carol = await store.createNote("# Carol\n\nbody", { path: "People/Carol" });
5040
+ const aliasNote = await createNote.execute({
5041
+ content: "Alias task",
5042
+ tags: ["task"],
5043
+ // "People/Carol" (path) and "Carol" (H1 title) both resolve to carol.
5044
+ metadata: { collaborators: ["People/Carol", "Carol"] },
5045
+ }) as any;
5046
+ let aliasLinks = (await store.getLinks(aliasNote.id, { direction: "outbound" }))
5047
+ .filter((l) => l.relationship === "collaborators");
5048
+ expect(aliasLinks).toHaveLength(1);
5049
+ expect(aliasLinks[0]!.targetId).toBe(carol.id);
5050
+
5051
+ // Drop the title alias, keep the path alias → the shared edge survives.
5052
+ await updateNote.execute({
5053
+ id: aliasNote.id,
5054
+ metadata: { collaborators: ["People/Carol"] },
5055
+ force: true,
5056
+ });
5057
+ aliasLinks = (await store.getLinks(aliasNote.id, { direction: "outbound" }))
5058
+ .filter((l) => l.relationship === "collaborators");
5059
+ expect(aliasLinks).toHaveLength(1);
5060
+ expect(aliasLinks[0]!.targetId).toBe(carol.id);
5061
+ });
4575
5062
  });
4576
5063
 
4577
5064
  it("find-path works with ID/path resolution", async () => {
package/core/src/mcp.ts CHANGED
@@ -419,7 +419,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
419
419
  last_updated_by: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write was attributed to this principal. Exact match; indexed." },
420
420
  created_via: { type: "string", description: "Write-attribution filter (vault#298): only notes FIRST written through this interface/channel — e.g. `mcp`, `surface:<name>`, `agent:<id>`, `operator`, `api`. Exact match; indexed." },
421
421
  last_updated_via: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write came through this interface/channel. Exact match; indexed." },
422
- order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. The special value `link_count` sorts by link DEGREE (both-directions raw row count) — no declaration needed — matching the `include_link_count` field for every note. Direction is taken from `sort` (default 'asc'); `created_at` is appended as a stable tiebreaker." },
422
+ order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. Two special values need no declaration: `link_count` sorts by link DEGREE (both-directions raw row count), matching the `include_link_count` field for every note; `updated_at` (vault#585) sorts on the integer `updated_at_ms` mirror column — correct on non-canonical/imported timestamps — with `id` as the tiebreaker. Direction is taken from `sort` (default 'asc'); for other fields `created_at` is appended as a stable tiebreaker." },
423
423
  date_from: { type: "string", description: "Start date (ISO, inclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', from }`." },
424
424
  date_to: { type: "string", description: "End date (ISO, exclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', to }`." },
425
425
  date_filter: {