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

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.
@@ -4572,6 +4572,390 @@ describe("MCP tools", async () => {
4572
4572
  expect(afterLink!.targetId).toBe(alice.id);
4573
4573
  expect(afterLink!.createdAt).toBe(beforeLink!.createdAt);
4574
4574
  });
4575
+
4576
+ it("regression guard: cardinality:'one' (scalar) reference field still creates exactly one edge", async () => {
4577
+ const alice = await store.createNote("Alice", { id: "alice" });
4578
+ await store.upsertTagSchema("task", { fields: { assignee: { type: "reference", cardinality: "one" } } });
4579
+ const tools = generateMcpTools(store);
4580
+ const createNote = tools.find((t) => t.name === "create-note")!;
4581
+ const note = await createNote.execute({
4582
+ content: "Ship it",
4583
+ tags: ["task"],
4584
+ metadata: { assignee: "alice" },
4585
+ }) as any;
4586
+
4587
+ const links = (await store.getLinks(note.id, { direction: "outbound" }))
4588
+ .filter((l) => l.relationship === "assignee");
4589
+ expect(links).toHaveLength(1);
4590
+ expect(links[0]!.targetId).toBe(alice.id);
4591
+ });
4592
+
4593
+ // ---- Gap #2: cardinality:"many" (array) reference fields (vault#typed-reference-field) ----
4594
+
4595
+ it("cardinality:'many' reference field creates one link per array element, deduped, with no spurious type_mismatch warning", async () => {
4596
+ const carol = await store.createNote("Carol", { path: "People/Carol" });
4597
+ const dave = await store.createNote("Dave", { path: "People/Dave" });
4598
+ await store.upsertTagSchema("task", {
4599
+ fields: { collaborators: { type: "reference", cardinality: "many" } },
4600
+ });
4601
+ const tools = generateMcpTools(store);
4602
+ const createNote = tools.find((t) => t.name === "create-note")!;
4603
+ const result = await createNote.execute({
4604
+ content: "Ship it",
4605
+ tags: ["task"],
4606
+ // "People/Carol" appears twice — duplicate elements must dedupe to one edge.
4607
+ metadata: { collaborators: ["People/Carol", "People/Dave", "People/Carol"] },
4608
+ }) as any;
4609
+
4610
+ // No spurious type_mismatch — an array is exactly what cardinality:"many" asks
4611
+ // for; pre-fix, valueMatchesType's "reference" case only accepted strings.
4612
+ expect(result.validation_status.warnings).toEqual([]);
4613
+
4614
+ const links = (await store.getLinks(result.id, { direction: "outbound" }))
4615
+ .filter((l) => l.relationship === "collaborators");
4616
+ expect(links).toHaveLength(2);
4617
+ expect(new Set(links.map((l) => l.targetId))).toEqual(new Set([carol.id, dave.id]));
4618
+
4619
+ // Traversable via find-path, same as a scalar reference link.
4620
+ const findPath = tools.find((t) => t.name === "find-path")!;
4621
+ const path = await findPath.execute({ source: result.id, target: carol.id }) as any;
4622
+ expect(path?.path).toEqual([result.id, carol.id]);
4623
+ });
4624
+
4625
+ it("update-note changing a cardinality:'many' reference array adds the new link, drops the removed one, and leaves the unchanged one alone", async () => {
4626
+ const carol = await store.createNote("Carol", { path: "People/Carol" });
4627
+ const dave = await store.createNote("Dave", { path: "People/Dave" });
4628
+ const erin = await store.createNote("Erin", { path: "People/Erin" });
4629
+ await store.upsertTagSchema("task", {
4630
+ fields: { collaborators: { type: "reference", cardinality: "many" } },
4631
+ });
4632
+ const tools = generateMcpTools(store);
4633
+ const createNote = tools.find((t) => t.name === "create-note")!;
4634
+ const note = await createNote.execute({
4635
+ content: "Ship it",
4636
+ tags: ["task"],
4637
+ metadata: { collaborators: ["People/Carol", "People/Dave"] },
4638
+ }) as any;
4639
+
4640
+ let links = (await store.getLinks(note.id, { direction: "outbound" }))
4641
+ .filter((l) => l.relationship === "collaborators");
4642
+ expect(links).toHaveLength(2);
4643
+ const carolLinkBefore = links.find((l) => l.targetId === carol.id)!;
4644
+
4645
+ const updateNote = tools.find((t) => t.name === "update-note")!;
4646
+ // Drop Dave, add Erin, keep Carol.
4647
+ await updateNote.execute({
4648
+ id: note.id,
4649
+ metadata: { collaborators: ["People/Carol", "People/Erin"] },
4650
+ force: true,
4651
+ });
4652
+
4653
+ links = (await store.getLinks(note.id, { direction: "outbound" }))
4654
+ .filter((l) => l.relationship === "collaborators");
4655
+ expect(links).toHaveLength(2);
4656
+ expect(new Set(links.map((l) => l.targetId))).toEqual(new Set([carol.id, erin.id]));
4657
+ // Dropped — Dave's link is gone.
4658
+ expect(links.some((l) => l.targetId === dave.id)).toBe(false);
4659
+ // Unchanged — Carol's edge is the SAME row (createdAt preserved), proving a
4660
+ // real diff (not a blanket clear-and-recreate under the relationship).
4661
+ const carolLinkAfter = links.find((l) => l.targetId === carol.id)!;
4662
+ expect(carolLinkAfter.createdAt).toBe(carolLinkBefore.createdAt);
4663
+ });
4664
+
4665
+ it("an unresolved array element is queued and backfills when its target note is created later, without disturbing already-resolved siblings", async () => {
4666
+ const carol = await store.createNote("Carol", { path: "People/Carol" });
4667
+ await store.upsertTagSchema("task", {
4668
+ fields: { collaborators: { type: "reference", cardinality: "many" } },
4669
+ });
4670
+ const tools = generateMcpTools(store);
4671
+ const createNote = tools.find((t) => t.name === "create-note")!;
4672
+ const note = await createNote.execute({
4673
+ content: "Ship it",
4674
+ tags: ["task"],
4675
+ metadata: { collaborators: ["People/Carol", "People/Not Yet Created"] },
4676
+ }) as any;
4677
+
4678
+ let links = (await store.getLinks(note.id, { direction: "outbound" }))
4679
+ .filter((l) => l.relationship === "collaborators");
4680
+ expect(links).toHaveLength(1); // only Carol resolves so far
4681
+ expect(links[0]!.targetId).toBe(carol.id);
4682
+
4683
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
4684
+ const broken = await queryNotes.execute({ has_broken_links: true }) as any[];
4685
+ expect(broken.map((n: any) => n.id)).toContain(note.id);
4686
+
4687
+ const target = await store.createNote("here now", { path: "People/Not Yet Created" });
4688
+
4689
+ links = (await store.getLinks(note.id, { direction: "outbound" }))
4690
+ .filter((l) => l.relationship === "collaborators");
4691
+ expect(links).toHaveLength(2);
4692
+ expect(new Set(links.map((l) => l.targetId))).toEqual(new Set([carol.id, target.id]));
4693
+ });
4694
+
4695
+ it("an array element resolving to the note itself creates a self-loop link, matching scalar self-reference behavior", async () => {
4696
+ await store.upsertTagSchema("task", {
4697
+ fields: { collaborators: { type: "reference", cardinality: "many" } },
4698
+ });
4699
+ const tools = generateMcpTools(store);
4700
+ const createNote = tools.find((t) => t.name === "create-note")!;
4701
+ const note = await createNote.execute({
4702
+ content: "Ship it",
4703
+ tags: ["task"],
4704
+ path: "Tasks/Self",
4705
+ metadata: { collaborators: ["Tasks/Self"] },
4706
+ }) as any;
4707
+
4708
+ const links = (await store.getLinks(note.id, { direction: "outbound" }))
4709
+ .filter((l) => l.relationship === "collaborators");
4710
+ expect(links).toHaveLength(1);
4711
+ expect(links[0]!.targetId).toBe(note.id);
4712
+ });
4713
+
4714
+ it("an ambiguous array element (matches ≥2 notes) is neither linked nor queued, mirroring the scalar ambiguous contract", async () => {
4715
+ await store.createNote("Shared Title", { path: "A/Shared Title" });
4716
+ await store.createNote("Shared Title", { path: "B/Shared Title" });
4717
+ const target = await store.createNote("Target", { path: "People/Target" });
4718
+ await store.upsertTagSchema("task", {
4719
+ fields: { collaborators: { type: "reference", cardinality: "many" } },
4720
+ });
4721
+ const tools = generateMcpTools(store);
4722
+ const createNote = tools.find((t) => t.name === "create-note")!;
4723
+ const note = await createNote.execute({
4724
+ content: "Ship it",
4725
+ tags: ["task"],
4726
+ metadata: { collaborators: ["Shared Title", "People/Target"] },
4727
+ }) as any;
4728
+
4729
+ const links = (await store.getLinks(note.id, { direction: "outbound" }))
4730
+ .filter((l) => l.relationship === "collaborators");
4731
+ // Only the unambiguous element links; "Shared Title" is neither linked nor queued.
4732
+ expect(links).toHaveLength(1);
4733
+ expect(links[0]!.targetId).toBe(target.id);
4734
+
4735
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
4736
+ const broken = await queryNotes.execute({ has_broken_links: true }) as any[];
4737
+ expect(broken.map((n: any) => n.id)).not.toContain(note.id);
4738
+ });
4739
+
4740
+ // ---- Gap #3: schema-after-data backfill (vault#typed-reference-field) ----
4741
+
4742
+ it("declaring type:'reference' via update-tag after notes already carry the value backfills links for existing notes (scalar and array)", async () => {
4743
+ const alice = await store.createNote("Alice", { path: "People/Alice" });
4744
+ const bob = await store.createNote("Bob", { path: "People/Bob" });
4745
+ const carol = await store.createNote("Carol", { path: "People/Carol" });
4746
+ const tools = generateMcpTools(store);
4747
+ const createNote = tools.find((t) => t.name === "create-note")!;
4748
+
4749
+ // Written BEFORE the tag declares these fields as `reference` — plain
4750
+ // untyped metadata, no schema applies at write time, so no link is created.
4751
+ const scalarNote = await createNote.execute({
4752
+ content: "Scalar task",
4753
+ tags: ["task"],
4754
+ metadata: { manager: "People/Alice" },
4755
+ }) as any;
4756
+ const arrayNote = await createNote.execute({
4757
+ content: "Array task",
4758
+ tags: ["task"],
4759
+ metadata: { collaborators: ["People/Bob", "People/Carol"] },
4760
+ }) as any;
4761
+
4762
+ expect((await store.getLinks(scalarNote.id, { direction: "outbound" })).length).toBe(0);
4763
+ expect((await store.getLinks(arrayNote.id, { direction: "outbound" })).length).toBe(0);
4764
+
4765
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4766
+ await updateTag.execute({
4767
+ tag: "task",
4768
+ fields: {
4769
+ manager: { type: "reference" },
4770
+ collaborators: { type: "reference", cardinality: "many" },
4771
+ },
4772
+ });
4773
+
4774
+ const scalarLinks = (await store.getLinks(scalarNote.id, { direction: "outbound" }))
4775
+ .filter((l) => l.relationship === "manager");
4776
+ expect(scalarLinks).toHaveLength(1);
4777
+ expect(scalarLinks[0]!.targetId).toBe(alice.id);
4778
+
4779
+ const arrayLinks = (await store.getLinks(arrayNote.id, { direction: "outbound" }))
4780
+ .filter((l) => l.relationship === "collaborators");
4781
+ expect(arrayLinks).toHaveLength(2);
4782
+ expect(new Set(arrayLinks.map((l) => l.targetId))).toEqual(new Set([bob.id, carol.id]));
4783
+
4784
+ // Idempotency: a LATER update-tag call that declares a NEW reference
4785
+ // field re-triggers the tag's whole backfill walk (see
4786
+ // `backfillReferenceFieldLinks` — it re-syncs every reference field on
4787
+ // each note, not just the newly-added one). Already-synced links must
4788
+ // not duplicate.
4789
+ await updateTag.execute({
4790
+ tag: "task",
4791
+ fields: { reviewer: { type: "reference" } },
4792
+ });
4793
+
4794
+ const scalarLinksAfter = (await store.getLinks(scalarNote.id, { direction: "outbound" }))
4795
+ .filter((l) => l.relationship === "manager");
4796
+ expect(scalarLinksAfter).toHaveLength(1);
4797
+ const arrayLinksAfter = (await store.getLinks(arrayNote.id, { direction: "outbound" }))
4798
+ .filter((l) => l.relationship === "collaborators");
4799
+ expect(arrayLinksAfter).toHaveLength(2);
4800
+ });
4801
+
4802
+ it("declaring type:'reference' on a PARENT tag backfills links for notes carrying a descendant (child) tag", async () => {
4803
+ const alice = await store.createNote("Alice", { path: "People/Alice" });
4804
+ const tools = generateMcpTools(store);
4805
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4806
+ // `subtask` is a child of `task` via parent_names — schema inheritance
4807
+ // (vault#270) means `subtask` notes are in `task`'s effective walk.
4808
+ await updateTag.execute({ tag: "subtask", parent_names: ["task"] });
4809
+
4810
+ const createNote = tools.find((t) => t.name === "create-note")!;
4811
+ const note = await createNote.execute({
4812
+ content: "Child task",
4813
+ tags: ["subtask"],
4814
+ metadata: { manager: "People/Alice" },
4815
+ }) as any;
4816
+
4817
+ expect((await store.getLinks(note.id, { direction: "outbound" })).length).toBe(0);
4818
+
4819
+ await updateTag.execute({ tag: "task", fields: { manager: { type: "reference" } } });
4820
+
4821
+ const links = (await store.getLinks(note.id, { direction: "outbound" }))
4822
+ .filter((l) => l.relationship === "manager");
4823
+ expect(links).toHaveLength(1);
4824
+ expect(links[0]!.targetId).toBe(alice.id);
4825
+ });
4826
+
4827
+ // ---- Round-4 review fixes (2026-07-10) ----
4828
+
4829
+ it("BLOCKER 1: the schema-declaration backfill walks ALL notes, not the first 100", async () => {
4830
+ // One shared target every note references.
4831
+ const target = await store.createNote("Target", { path: "People/Target" });
4832
+ const tools = generateMcpTools(store);
4833
+ const createNote = tools.find((t) => t.name === "create-note")!;
4834
+
4835
+ // 120 notes each carrying a `manager` value BEFORE the field is a
4836
+ // reference — exceeds queryNotes' default LIMIT 100.
4837
+ const noteIds: string[] = [];
4838
+ for (let i = 0; i < 120; i++) {
4839
+ const n = await createNote.execute({
4840
+ content: `Task ${i}`,
4841
+ tags: ["task"],
4842
+ metadata: { manager: "People/Target" },
4843
+ }) as any;
4844
+ noteIds.push(n.id);
4845
+ }
4846
+
4847
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4848
+ await updateTag.execute({ tag: "task", fields: { manager: { type: "reference" } } });
4849
+
4850
+ // EVERY one of the 120 notes must have a manager edge — a 100-cap would
4851
+ // leave 20 unlinked (the silent-half-graph this feature prevents).
4852
+ let linked = 0;
4853
+ for (const id of noteIds) {
4854
+ const links = (await store.getLinks(id, { direction: "outbound" }))
4855
+ .filter((l) => l.relationship === "manager" && l.targetId === target.id);
4856
+ if (links.length === 1) linked++;
4857
+ }
4858
+ expect(linked).toBe(120);
4859
+ });
4860
+
4861
+ it("BLOCKER 2: re-declaring an ALREADY-reference field via update-tag heals notes whose links were never built", async () => {
4862
+ const alice = await store.createNote("Alice", { path: "People/Alice" });
4863
+ const tools = generateMcpTools(store);
4864
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4865
+ const createNote = tools.find((t) => t.name === "create-note")!;
4866
+
4867
+ // Field is declared reference from the start, and a note is written.
4868
+ await updateTag.execute({ tag: "task", fields: { manager: { type: "reference" } } });
4869
+ const note = await createNote.execute({
4870
+ content: "Ship it",
4871
+ tags: ["task"],
4872
+ metadata: { manager: "People/Alice" },
4873
+ }) as any;
4874
+ expect((await store.getLinks(note.id, { direction: "outbound" }))
4875
+ .filter((l) => l.relationship === "manager")).toHaveLength(1);
4876
+
4877
+ // Simulate a pre-fix vault: the edge was never built. Delete it directly,
4878
+ // leaving the metadata value in place but the graph edge missing.
4879
+ await store.deleteLink(note.id, alice.id, "manager");
4880
+ expect((await store.getLinks(note.id, { direction: "outbound" }))
4881
+ .filter((l) => l.relationship === "manager")).toHaveLength(0);
4882
+
4883
+ // Re-declare the SAME already-reference field — the documented heal path.
4884
+ // A transition-keyed trigger would no-op here; the fix fires the backfill
4885
+ // for any reference field in the declared schema.
4886
+ await updateTag.execute({ tag: "task", fields: { manager: { type: "reference" } } });
4887
+
4888
+ const healed = (await store.getLinks(note.id, { direction: "outbound" }))
4889
+ .filter((l) => l.relationship === "manager");
4890
+ expect(healed).toHaveLength(1);
4891
+ expect(healed[0]!.targetId).toBe(alice.id);
4892
+ });
4893
+
4894
+ 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 () => {
4895
+ const dave = await store.createNote("Dave", { path: "People/Dave" });
4896
+ const erin = await store.createNote("Erin", { path: "People/Erin" });
4897
+ await store.upsertTagSchema("task", {
4898
+ fields: { collaborators: { type: "reference", cardinality: "many" } },
4899
+ });
4900
+ const tools = generateMcpTools(store);
4901
+ const createNote = tools.find((t) => t.name === "create-note")!;
4902
+ const updateNote = tools.find((t) => t.name === "update-note")!;
4903
+
4904
+ // --- Corner (a): a still-present element whose target is renamed after
4905
+ // linking; then a DIFFERENT element is dropped. The renamed target's
4906
+ // stale edge must be reconciled away (the old re-resolve-removed-value
4907
+ // approach left it forever).
4908
+ const note = await createNote.execute({
4909
+ content: "Ship it",
4910
+ tags: ["task"],
4911
+ metadata: { collaborators: ["People/Dave", "People/Erin"] },
4912
+ }) as any;
4913
+ expect((await store.getLinks(note.id, { direction: "outbound" }))
4914
+ .filter((l) => l.relationship === "collaborators")).toHaveLength(2);
4915
+
4916
+ // Rename Dave's target note — the raw value "People/Dave" no longer resolves.
4917
+ await updateNote.execute({ id: dave.id, path: "People/David", force: true });
4918
+
4919
+ // Drop Erin (a different element); keep the now-stale "People/Dave".
4920
+ await updateNote.execute({
4921
+ id: note.id,
4922
+ metadata: { collaborators: ["People/Dave"] },
4923
+ force: true,
4924
+ });
4925
+
4926
+ const afterA = (await store.getLinks(note.id, { direction: "outbound" }))
4927
+ .filter((l) => l.relationship === "collaborators");
4928
+ // Erin dropped; Dave's stale edge (target renamed, value no longer
4929
+ // resolves) reconciled away → zero edges, not a lingering stale one.
4930
+ expect(afterA.some((l) => l.targetId === erin.id)).toBe(false);
4931
+ expect(afterA.some((l) => l.targetId === dave.id)).toBe(false);
4932
+
4933
+ // --- Corner (c): two elements ALIAS the same target (path + H1 title).
4934
+ // Dropping one alias must keep the shared edge (still referenced by the
4935
+ // survivor), not delete it.
4936
+ const carol = await store.createNote("# Carol\n\nbody", { path: "People/Carol" });
4937
+ const aliasNote = await createNote.execute({
4938
+ content: "Alias task",
4939
+ tags: ["task"],
4940
+ // "People/Carol" (path) and "Carol" (H1 title) both resolve to carol.
4941
+ metadata: { collaborators: ["People/Carol", "Carol"] },
4942
+ }) as any;
4943
+ let aliasLinks = (await store.getLinks(aliasNote.id, { direction: "outbound" }))
4944
+ .filter((l) => l.relationship === "collaborators");
4945
+ expect(aliasLinks).toHaveLength(1);
4946
+ expect(aliasLinks[0]!.targetId).toBe(carol.id);
4947
+
4948
+ // Drop the title alias, keep the path alias → the shared edge survives.
4949
+ await updateNote.execute({
4950
+ id: aliasNote.id,
4951
+ metadata: { collaborators: ["People/Carol"] },
4952
+ force: true,
4953
+ });
4954
+ aliasLinks = (await store.getLinks(aliasNote.id, { direction: "outbound" }))
4955
+ .filter((l) => l.relationship === "collaborators");
4956
+ expect(aliasLinks).toHaveLength(1);
4957
+ expect(aliasLinks[0]!.targetId).toBe(carol.id);
4958
+ });
4575
4959
  });
4576
4960
 
4577
4961
  it("find-path works with ID/path resolution", async () => {
@@ -348,7 +348,7 @@ function jsonTypeOf(value: unknown): string {
348
348
  return typeof value; // "string" | "number" | "boolean" | "object" | ...
349
349
  }
350
350
 
351
- function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
351
+ function valueMatchesType(value: unknown, type: SchemaField["type"], cardinality?: SchemaField["cardinality"]): boolean {
352
352
  if (type === undefined) return true;
353
353
  switch (type) {
354
354
  case "string":
@@ -373,7 +373,22 @@ function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
373
373
  // the write path (core/src/store.ts) separately resolves this value to
374
374
  // a note and maintains a graph link; see tag-schemas.ts's
375
375
  // `VALID_FIELD_TYPES` doc comment for the full contract.
376
+ //
377
+ // `cardinality: "many"` (vault#typed-reference-field gap #2) is a
378
+ // one-to-MANY reference field — the value is an ARRAY of reference
379
+ // strings, one per linked note, not a single string. Validate the
380
+ // shape a "many" reference actually takes: an array whose elements are
381
+ // ALL non-empty-typed strings (per-item type check — the ARRAY shape
382
+ // itself is separately covered by the `cardinality_mismatch` check
383
+ // below in `validateNote`, so this only needs to judge element types).
384
+ // Without this branch, EVERY valid `cardinality:"many"` reference write
385
+ // fired a self-contradictory `type_mismatch` ("should be reference, got
386
+ // array") — an array is exactly what "many" asks for; only a
387
+ // non-string ELEMENT should ever fail this check.
376
388
  case "reference":
389
+ if (cardinality === "many") {
390
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
391
+ }
377
392
  return typeof value === "string";
378
393
  }
379
394
  }
@@ -435,7 +450,7 @@ export function validateNote(
435
450
 
436
451
  if (absent) continue;
437
452
 
438
- if (spec.type && !valueMatchesType(value, spec.type)) {
453
+ if (spec.type && !valueMatchesType(value, spec.type, spec.cardinality)) {
439
454
  // Decision A (vault#553): an INDEXED field's type is a query
440
455
  // contract — a type-mismatched write poisons range-query ordering
441
456
  // (SQLite's TEXT-sorts-above-INTEGER affinity) regardless of whether
package/core/src/store.ts CHANGED
@@ -15,8 +15,10 @@ import {
15
15
  resolveUnresolvedWikilinks,
16
16
  resolveOrQueueLink,
17
17
  clearQueuedLink,
18
+ clearQueuedLinkTarget,
18
19
  requeueInboundWikilinksForDelete,
19
20
  } from "./wikilinks.js";
21
+ import { chunkForInClause } from "./sql-in.js";
20
22
  import { pathTitle } from "./paths.js";
21
23
  import { timestampToMs } from "./cursor.js";
22
24
  import { transaction } from "./txn.js";
@@ -24,6 +26,7 @@ import { HookRegistry } from "./hooks.js";
24
26
  import {
25
27
  loadTagHierarchy,
26
28
  getTagExpansion,
29
+ getTagDescendants,
27
30
  stripTagHash,
28
31
  TAG_CONFIG_PREFIX,
29
32
  DEFAULT_TAG_NAME,
@@ -45,6 +48,36 @@ import {
45
48
  import type { SearchMode } from "./search-query.js";
46
49
  import { runDoctorScan, type DoctorReport, type DoctorScanOpts } from "./doctor.js";
47
50
 
51
+ /**
52
+ * Normalize a `type: "reference"` field value (scalar OR array — see
53
+ * `BunSqliteStore.syncReferenceFieldArrayLinks`) into a Set of non-empty,
54
+ * trimmed string elements. A bare scalar string becomes a one-element set
55
+ * (so a cardinality transition between scalar and array diffs correctly
56
+ * against the other side); a non-array/non-string value (undefined, null,
57
+ * number, object, …) becomes the empty set; array elements that aren't
58
+ * non-empty strings are dropped (they can never resolve to a link target).
59
+ * Set semantics absorb both element ORDER and DUPLICATE entries, matching
60
+ * how the underlying `links` table's UNIQUE(source_id, target_id,
61
+ * relationship) would collapse duplicate-target elements anyway.
62
+ */
63
+ function referenceValueToSet(value: unknown): Set<string> {
64
+ const out = new Set<string>();
65
+ if (Array.isArray(value)) {
66
+ for (const item of value) {
67
+ if (typeof item === "string" && item.trim() !== "") out.add(item);
68
+ }
69
+ } else if (typeof value === "string" && value.trim() !== "") {
70
+ out.add(value);
71
+ }
72
+ return out;
73
+ }
74
+
75
+ function setsEqual(a: Set<string>, b: Set<string>): boolean {
76
+ if (a.size !== b.size) return false;
77
+ for (const v of a) if (!b.has(v)) return false;
78
+ return true;
79
+ }
80
+
48
81
  /**
49
82
  * bun:sqlite-backed Store implementation. Internally everything is
50
83
  * synchronous; the public Store API is async so the same interface
@@ -115,7 +148,8 @@ export class BunSqliteStore implements Store {
115
148
  /**
116
149
  * Auto-link sync for `type: "reference"` schema fields
117
150
  * (vault#typed-reference-field — see `docs/design/typed-reference-field.md`
118
- * for the full design + known gaps).
151
+ * for the full design; gaps #2/#3 closed below, see the doc for what
152
+ * remains open).
119
153
  *
120
154
  * A `reference`-typed field is BOTH an indexed value (handled by the
121
155
  * ordinary metadata write — no special casing needed there, see
@@ -130,27 +164,35 @@ export class BunSqliteStore implements Store {
130
164
  *
131
165
  * Called from `createNote`/`updateNote`/`createNotes` — the single
132
166
  * chokepoint both MCP and REST funnel through — AFTER the note row itself
133
- * is written, so `note.tags`/`note.metadata` reflect the final state.
167
+ * is written, so `note.tags`/`note.metadata` reflect the final state. This
168
+ * is the WRITE-PATH (reconciling) sync; the gap #3 schema-declaration
169
+ * backfill is a separate, purely-additive path
170
+ * ({@link backfillReferenceFieldLinks}), so a `update-tag` re-declare never
171
+ * churns already-correct edges.
134
172
  *
135
173
  * `priorMetadata` is the note's metadata BEFORE this write (`undefined` on
136
- * create). For each reference field, if its value is unchanged from
137
- * `priorMetadata`, nothing is touched the existing link (if any) already
138
- * reflects it, and re-running the resolve/queue machinery on every
139
- * unrelated write (e.g. a content-only edit) would be wasted work. When
140
- * the value DID change (set, changed, or removed), every existing link +
141
- * queued forward-ref under that field's relationship name is cleared
142
- * first, then re-established from the new value — this makes the field's
143
- * current value the single source of truth for "this field's link"
144
- * without needing to track the specific prior target.
174
+ * create). For each SCALAR reference
175
+ * field, if its value is unchanged from `priorMetadata`, nothing is
176
+ * touched the existing link (if any) already reflects it, and
177
+ * re-running the resolve/queue machinery on every unrelated write (e.g. a
178
+ * content-only edit) would be wasted work. When the value DID change (set,
179
+ * changed, or removed), every existing link + queued forward-ref under
180
+ * that field's relationship name is cleared first, then re-established
181
+ * from the new value this makes the field's current value the single
182
+ * source of truth for "this field's link" without needing to track the
183
+ * specific prior target.
145
184
  *
146
- * Known gaps (see the design doc): only scalar (`cardinality: "one"`,
147
- * the default) reference values are linked an array value is left as a
148
- * validated-like-string-per-item... actually an array value simply isn't
149
- * a string, so no link is created for it (the write still succeeds; a
150
- * `type_mismatch`/`cardinality_mismatch` warning surfaces via the normal
151
- * `validation_status` path). A tag gaining a `reference` field declaration
152
- * does NOT retroactively link notes that already carry a matching value —
153
- * only writes that actually touch the field (going forward) sync.
185
+ * A `cardinality: "many"` (array) value takes a different path
186
+ * {@link syncReferenceFieldArrayLinks}since a relationship name can now
187
+ * back MULTIPLE edges (one per element) rather than at most one, so
188
+ * "clear everything under this relationship, recreate" would be wrong: it
189
+ * would drop+recreate edges for elements that didn't even change. That
190
+ * method diffs old-vs-new array membership (a Set, so element order and
191
+ * duplicate entries don't matter) and only touches what actually changed.
192
+ * Dispatched whenever EITHER side of the comparison is an array — this
193
+ * also correctly handles a field transitioning between scalar and array
194
+ * shape (e.g. a schema's `cardinality` changes), by treating a scalar side
195
+ * as a one-element set.
154
196
  */
155
197
  private syncReferenceFieldLinks(
156
198
  note: Note,
@@ -167,6 +209,12 @@ export class BunSqliteStore implements Store {
167
209
 
168
210
  const nextValue = metadata[fieldName];
169
211
  const priorValue = prior[fieldName];
212
+
213
+ if (Array.isArray(nextValue) || Array.isArray(priorValue)) {
214
+ this.syncReferenceFieldArrayLinks(note.id, fieldName, nextValue, priorValue);
215
+ continue;
216
+ }
217
+
170
218
  if (nextValue === priorValue) continue; // unchanged — link (if any) already reflects it
171
219
 
172
220
  // Re-establish this field's link from scratch: drop whatever it
@@ -200,6 +248,205 @@ export class BunSqliteStore implements Store {
200
248
  }
201
249
  }
202
250
 
251
+ /**
252
+ * The `cardinality: "many"` counterpart of the scalar sync above (vault
253
+ * typed-reference-field gap #2, docs/design/typed-reference-field.md).
254
+ * ONE link per array element, all sharing `relationship = fieldName` — the
255
+ * `links` table's `UNIQUE(source_id, target_id, relationship)` naturally
256
+ * dedupes distinct elements resolving to the same target, and distinct
257
+ * elements resolving to distinct targets coexist as separate rows under
258
+ * the same relationship name.
259
+ *
260
+ * `nextValue`/`priorValue` are read as SETS of non-empty, trimmed string
261
+ * elements (via {@link referenceValueToSet}) — a non-array scalar
262
+ * contributes a one-element set (so a cardinality transition diffs
263
+ * correctly), non-string/empty elements are dropped (they can never
264
+ * resolve to a link target; `valueMatchesType` already flags them via the
265
+ * normal `type_mismatch` path if the schema disagrees), and DUPLICATE
266
+ * elements collapse to one (a `["carol","carol"]` array creates exactly
267
+ * one edge to carol, matching how `createLink`'s own UNIQUE constraint
268
+ * would collapse it anyway).
269
+ *
270
+ * Reconciles against the RESOLVED next-set, NOT a raw-value diff (round-4
271
+ * review NIT 3). Resolving every element of the new array yields the exact
272
+ * set of `target_id`s the field's edges under this relationship SHOULD
273
+ * point at; existing edges are then reconciled TO that set:
274
+ * - Delete every existing edge under `(noteId, relationship)` whose
275
+ * `target_id` is NOT in the resolved next-set. Reconciling on resolved
276
+ * TARGETS (not by re-resolving each removed raw string) is what makes
277
+ * the three corners the raw-value diff got wrong come out right:
278
+ * (a) a removed element whose target was renamed/deleted since — its
279
+ * edge's `target_id` is simply absent from the next-set, so it's
280
+ * dropped (the raw-value approach re-resolved the stale string, missed,
281
+ * and left the edge forever); (b) a removed element now ambiguous —
282
+ * same, dropped by target; (c) two elements ALIASING the same target
283
+ * (a path and its H1 title both resolving to one note) — removing one
284
+ * alias keeps the shared edge, because the surviving alias still
285
+ * resolves that `target_id` INTO the next-set (the raw-value approach
286
+ * deleted the shared edge when it processed the removed alias, then
287
+ * never recreated it because the surviving alias looked "unchanged").
288
+ * - Create every resolved next-set edge via `createLink`'s `INSERT OR
289
+ * IGNORE` — an already-present edge (an element unchanged across the
290
+ * update) is untouched, so it KEEPS its original `created_at`.
291
+ * - A next-set element that doesn't resolve is queued exactly like a
292
+ * scalar reference (self-links and ambiguous matches follow the same
293
+ * contract: a self element creates a self-loop; an ambiguous one is
294
+ * neither linked nor queued, vault#570). Queued forward-refs for
295
+ * elements DROPPED from the array (in prior, absent from next) are
296
+ * cleared per-element via {@link clearQueuedLinkTarget} — NOT the
297
+ * blanket {@link clearQueuedLink}, which would also drop OTHER elements'
298
+ * still-pending queue rows under the same relationship.
299
+ *
300
+ * Two sets that are IDENTICAL (regardless of original order/duplicates in
301
+ * either array) short-circuit to a no-op — mirrors the scalar path's
302
+ * unchanged-value fast path.
303
+ */
304
+ private syncReferenceFieldArrayLinks(
305
+ noteId: string,
306
+ fieldName: string,
307
+ nextValue: unknown,
308
+ priorValue: unknown,
309
+ ): void {
310
+ const nextSet = referenceValueToSet(nextValue);
311
+ const priorSet = referenceValueToSet(priorValue);
312
+
313
+ if (setsEqual(nextSet, priorSet)) return; // same membership — nothing to do
314
+
315
+ // Resolve the ENTIRE next-set (queuing misses) → the target ids the
316
+ // field's edges under this relationship should point at right now.
317
+ const desiredTargetIds = new Set<string>();
318
+ for (const value of nextSet) {
319
+ const outcome = resolveOrQueueLink(this.db, noteId, value, fieldName);
320
+ if (outcome.status === "resolved") desiredTargetIds.add(outcome.note_id);
321
+ }
322
+
323
+ // Reconcile existing edges to the desired target set (see the doc comment
324
+ // for why deleting by resolved TARGET, not by re-resolved raw value, is
325
+ // what fixes the rename / ambiguous / aliasing corners).
326
+ const existing = linkOps.getLinks(this.db, noteId, { direction: "outbound" });
327
+ for (const link of existing) {
328
+ if (link.relationship !== fieldName) continue;
329
+ if (!desiredTargetIds.has(link.targetId)) {
330
+ linkOps.deleteLink(this.db, noteId, link.targetId, fieldName);
331
+ }
332
+ }
333
+
334
+ // Create every desired edge — INSERT OR IGNORE keeps an unchanged
335
+ // element's row (and its `created_at`) intact.
336
+ for (const targetId of desiredTargetIds) {
337
+ linkOps.createLink(this.db, noteId, targetId, fieldName);
338
+ }
339
+
340
+ // Drop queued forward-refs for elements removed from the array. A
341
+ // still-present-but-unresolved element stays queued (re-queued
342
+ // idempotently above).
343
+ for (const value of priorSet) {
344
+ if (!nextSet.has(value)) clearQueuedLinkTarget(this.db, noteId, fieldName, value);
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Gap #3 backfill (vault typed-reference-field,
350
+ * docs/design/typed-reference-field.md): when `update-tag`'s persisted
351
+ * schema for `tag` includes one or more `type: "reference"` fields,
352
+ * existing notes that already carry a value for such a field may predate
353
+ * the declaration (or predate the gap #2 array-link fix) and sit unlinked
354
+ * — only a future write that actually touches the field would otherwise
355
+ * sync it. This walks every note carrying `tag` (or a descendant, via
356
+ * schema inheritance — same scope `countConformanceViolations` uses for
357
+ * its own schema-change impact walk) and materializes the missing links
358
+ * for each note's CURRENT value of the declared reference field(s).
359
+ *
360
+ * `referenceFieldNames` is the set of fields THIS `update-tag` persisted
361
+ * as `type: "reference"` (round-4 review NIT 5) — the walk syncs ONLY
362
+ * those, never every reference field the note happens to carry from OTHER
363
+ * co-tags/ancestors, so declaring a reference field on tag A can't churn
364
+ * an unrelated reference field contributed by tag B on a note carrying
365
+ * both. Fired for ANY reference field in the declared schema, not just a
366
+ * type-transition (round-4 review BLOCKER 2), so re-declaring an
367
+ * already-reference field HEALS notes whose links were never built —
368
+ * exactly what UPGRADING tells operators to do. Safe to re-run: the
369
+ * backfill is purely ADDITIVE + idempotent (see
370
+ * {@link backfillOneReferenceField}).
371
+ *
372
+ * Walks the FULL matching note set — the id sweep is unbounded (round-4
373
+ * review BLOCKER 1: `queryNotes` silently caps at LIMIT 100, which on a
374
+ * >100-note tag left the majority of notes unlinked — the exact
375
+ * silent-half-graph this feature exists to prevent). Ids are collected by
376
+ * a direct `note_tags` scan chunked under the IN-param cap, then hydrated
377
+ * in batches.
378
+ *
379
+ * Runs INSIDE the caller's tag-write transaction (round-4 review NIT 4 —
380
+ * NOT its own separate transaction), so a walk failure rolls the schema
381
+ * write back and the retry re-fires rather than leaving a persisted
382
+ * `reference` schema whose links never got built. `update-tag` is
383
+ * `vault:admin`-tier, so a bounded per-tag(+descendants) walk is an
384
+ * accepted cost.
385
+ */
386
+ private backfillReferenceFieldLinks(tag: string, referenceFieldNames: Set<string>): void {
387
+ if (referenceFieldNames.size === 0) return;
388
+ const hierarchy = this.getTagHierarchy();
389
+ const tagSet = Array.from(getTagDescendants(hierarchy, tag));
390
+ if (tagSet.length === 0) return;
391
+
392
+ // Unbounded id sweep: every note carrying `tag` or a descendant. Direct
393
+ // note_tags scan (deduped across tags), chunked under the IN-param cap —
394
+ // NO default LIMIT, unlike `queryNotes`.
395
+ const idSet = new Set<string>();
396
+ for (const chunk of chunkForInClause(tagSet)) {
397
+ const placeholders = chunk.map(() => "?").join(", ");
398
+ const rows = this.db.prepare(
399
+ `SELECT DISTINCT note_id FROM note_tags WHERE tag_name IN (${placeholders})`,
400
+ ).all(...chunk) as { note_id: string }[];
401
+ for (const r of rows) idSet.add(r.note_id);
402
+ }
403
+ if (idSet.size === 0) return;
404
+
405
+ const ids = Array.from(idSet);
406
+ const schemaConfig = this.getSchemaConfig();
407
+ const tagsById = noteOps.getNoteTagsForNotes(this.db, ids);
408
+ const notes = noteOps.getNotes(this.db, ids);
409
+
410
+ for (const note of notes) {
411
+ const tags = tagsById.get(note.id) ?? note.tags ?? [];
412
+ const resolution = resolveNoteSchemas(schemaConfig, { tags });
413
+ const metadata = note.metadata ?? {};
414
+ for (const fieldName of referenceFieldNames) {
415
+ const merged = resolution.mergedFields.get(fieldName);
416
+ // The field must actually be `reference` in THIS note's effective
417
+ // schema — a descendant tag can override it to a non-reference type
418
+ // (first-in-walk wins), in which case this note doesn't get a link.
419
+ if (!merged || merged.spec.type !== "reference") continue;
420
+ this.backfillOneReferenceField(note.id, fieldName, metadata[fieldName]);
421
+ }
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Materialize the missing graph link(s) for ONE reference field's current
427
+ * value on one note (gap #3 backfill helper). Purely ADDITIVE and
428
+ * idempotent: for each element of the value (a scalar string is a
429
+ * one-element set; an array is deduped via {@link referenceValueToSet}),
430
+ * resolve-or-queue and, on a resolve, `createLink`. It NEVER deletes an
431
+ * edge — a backfill materializes links that were never built; reconciling
432
+ * divergent state (removing a stale edge when a value CHANGES) stays the
433
+ * write path's job (`syncReferenceFieldLinks` /
434
+ * {@link syncReferenceFieldArrayLinks}). Because `createLink` and the
435
+ * forward-ref queue are both `INSERT OR IGNORE`, a re-run — or an edge
436
+ * that already exists — is a no-op that preserves the existing row's
437
+ * `created_at` (so re-declaring a schema doesn't churn already-correct
438
+ * edges, and a scalar value that has since gone ambiguous doesn't silently
439
+ * lose its already-built edge — round-4 review NIT 5).
440
+ */
441
+ private backfillOneReferenceField(noteId: string, fieldName: string, value: unknown): void {
442
+ for (const element of referenceValueToSet(value)) {
443
+ const outcome = resolveOrQueueLink(this.db, noteId, element, fieldName);
444
+ if (outcome.status === "resolved") {
445
+ linkOps.createLink(this.db, noteId, outcome.note_id, fieldName);
446
+ }
447
+ }
448
+ }
449
+
203
450
  /**
204
451
  * Drop the tag-hierarchy cache if the mutated path is in the `_tags/*`
205
452
  * namespace. Called from create/update/delete — old path is passed
@@ -863,6 +1110,25 @@ export class BunSqliteStore implements Store {
863
1110
  const priorIndexed = indexedSet(priorRecord?.fields);
864
1111
  const nextIndexed = indexedSet(nextFields);
865
1112
 
1113
+ // Gap #3 backfill target set (vault typed-reference-field,
1114
+ // docs/design/typed-reference-field.md) — the field names THIS call
1115
+ // persists as `type: "reference"`. Fired for ANY reference field in the
1116
+ // declared schema, NOT just a type-transition (round-4 review BLOCKER
1117
+ // 2): keying on "transition to reference" made the documented
1118
+ // "re-declare the field to heal existing notes" path a silent no-op (an
1119
+ // already-reference field stays reference, so the transition never
1120
+ // fires), stranding pre-fix vaults whose reference-many links were never
1121
+ // built. The backfill itself is additive + idempotent, so re-firing on
1122
+ // every declare is safe; `update-tag` is a rare admin op, so the bounded
1123
+ // walk is an accepted cost. Empty when `fields` is untouched or cleared.
1124
+ const referenceFieldNames = new Set<string>(
1125
+ patch.fields !== undefined
1126
+ ? Object.entries(nextFields ?? {})
1127
+ .filter(([, spec]) => spec.type === "reference")
1128
+ .map(([name]) => name)
1129
+ : [],
1130
+ );
1131
+
866
1132
  // PRE-VALIDATE every newly-indexed field BEFORE any persistence. A bad
867
1133
  // field name (or unmappable type) must fail closed — the schema record
868
1134
  // must NOT be written when the backing index can't be created. Pre-checking
@@ -910,50 +1176,73 @@ export class BunSqliteStore implements Store {
910
1176
  }
911
1177
  }
912
1178
 
913
- // Persist the record + reconcile the indexed-field lifecycle atomically.
914
- // If declareField throws inside the transaction (e.g. a cross-tag type
915
- // mismatch only detectable once the existing declarer set is consulted),
916
- // the whole write rolls back — the schema never ends up claiming an index
917
- // that doesn't exist. vault#478 transactional fix.
918
- const result = this.transaction(() => {
919
- const record = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
920
-
921
- if (patch.fields !== undefined) {
922
- for (const fieldName of nextIndexed) {
923
- const spec = nextFields![fieldName]!;
924
- // Type already validated above; non-null assertion is safe here.
925
- const mapped = indexedFieldOps.mapFieldType(spec.type)!;
926
- indexedFieldOps.declareField(this.db, fieldName, mapped, tag);
927
- }
928
- for (const fieldName of priorIndexed) {
929
- if (!nextIndexed.has(fieldName)) {
930
- indexedFieldOps.releaseField(this.db, fieldName, tag);
1179
+ // Persist the record + reconcile the indexed-field lifecycle AND the
1180
+ // gap #3 reference-link backfill atomically, in ONE transaction (round-4
1181
+ // review NIT 4). If declareField throws (e.g. a cross-tag type mismatch
1182
+ // only detectable once the existing declarer set is consulted) OR the
1183
+ // backfill walk throws, the whole write rolls back — the schema never
1184
+ // ends up claiming an index that doesn't exist (vault#478), and never
1185
+ // persists a `reference` declaration whose links silently failed to
1186
+ // build (a partial-state 500 whose retry, post-BLOCKER-2, would re-fire
1187
+ // and heal — but atomic avoids the partial state in the first place).
1188
+ let result: tagSchemaOps.TagRecord;
1189
+ try {
1190
+ result = this.transaction(() => {
1191
+ const record = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
1192
+
1193
+ if (patch.fields !== undefined) {
1194
+ for (const fieldName of nextIndexed) {
1195
+ const spec = nextFields![fieldName]!;
1196
+ // Type already validated above; non-null assertion is safe here.
1197
+ const mapped = indexedFieldOps.mapFieldType(spec.type)!;
1198
+ indexedFieldOps.declareField(this.db, fieldName, mapped, tag);
1199
+ }
1200
+ for (const fieldName of priorIndexed) {
1201
+ if (!nextIndexed.has(fieldName)) {
1202
+ indexedFieldOps.releaseField(this.db, fieldName, tag);
1203
+ }
931
1204
  }
932
1205
  }
933
- }
934
- return record;
935
- });
936
1206
 
937
- if (patch.parent_names !== undefined) {
938
- // parent_names drives both query expansion (tag hierarchy) AND, post
939
- // vault#270, schema inheritance bust both caches.
940
- this._tagHierarchy = null;
941
- this._schemaConfig = null;
942
- }
943
- if (patch.fields !== undefined) {
944
- this._schemaConfig = null;
945
- }
946
- // First-time creation of a tag row (e.g. an empty `_default` placeholder)
947
- // changes the `_default` universal-parent gate even when no fields or
948
- // parent_names are touched. Cheap to bust: caches rebuild on next read.
949
- if (tag === "_default") {
950
- this._tagHierarchy = null;
951
- this._schemaConfig = null;
1207
+ // Gap #3 backfill — INSIDE the transaction. Bust the in-memory
1208
+ // caches FIRST (round-4 review NIT 6): the walk's
1209
+ // `resolveNoteSchemas` must see the just-persisted reference
1210
+ // declaration, and `getTagDescendants` must see the post-write
1211
+ // hierarchy — including the `_default` universal-parent gate a
1212
+ // first-time `_default` row flips (which used to be busted only
1213
+ // AFTER the backfill call). Nulling a cache inside the txn is safe:
1214
+ // it only forces a rebuild from the current in-txn DB state; the
1215
+ // `finally` below re-nulls so a rollback can't strand the rebuild.
1216
+ if (referenceFieldNames.size > 0) {
1217
+ this._tagHierarchy = null;
1218
+ this._schemaConfig = null;
1219
+ this.backfillReferenceFieldLinks(tag, referenceFieldNames);
1220
+ }
1221
+ return record;
1222
+ });
1223
+ } finally {
1224
+ // Invalidate whatever this write (or the backfill's mid-txn cache
1225
+ // rebuild) may have touched — on BOTH the commit and the rollback
1226
+ // path. `parent_names` drives query expansion + (vault#270) schema
1227
+ // inheritance; `fields` drives schema validation; a first-time
1228
+ // `_default` row flips the universal-parent gate; and the backfill
1229
+ // above re-caches mid-transaction state a rollback must not keep.
1230
+ if (
1231
+ patch.parent_names !== undefined ||
1232
+ patch.fields !== undefined ||
1233
+ tag === "_default" ||
1234
+ referenceFieldNames.size > 0
1235
+ ) {
1236
+ this._tagHierarchy = null;
1237
+ this._schemaConfig = null;
1238
+ }
952
1239
  }
1240
+
953
1241
  // Tag-mutation event for the git-mirror and any other downstream
954
1242
  // consumer. Fire "upserted" on every successful tag-record write —
955
1243
  // schema/relationship/parent-name mutations all alter the sidecar
956
- // contents the mirror persists.
1244
+ // contents the mirror persists. (Success path only — a thrown/rolled-back
1245
+ // write never reaches here.)
957
1246
  this.hooks.dispatchTag("upserted", tag, this);
958
1247
  return result;
959
1248
  }
@@ -807,6 +807,35 @@ export function clearQueuedLink(db: Database, sourceId: string, relationship: st
807
807
  }
808
808
  }
809
809
 
810
+ /**
811
+ * Drop exactly ONE pending forward-ref — scoped by `targetPath` in addition
812
+ * to `sourceId`/`relationship` (the full `unresolved_wikilinks` primary
813
+ * key). Used by the `cardinality:"many"` reference-field array sync
814
+ * (core/src/store.ts's `syncReferenceFieldLinks`) when a SPECIFIC array
815
+ * element is removed from a field's value: unlike the scalar path (which
816
+ * owns the ENTIRE relationship namespace and can safely clear every queued
817
+ * row via {@link clearQueuedLink}), an array field can have MULTIPLE
818
+ * pending forward-refs under the same relationship at once (one per
819
+ * unresolved element) — blanket-clearing all of them on a single element's
820
+ * removal would silently drop the other still-pending elements' queue rows.
821
+ * Safe no-op when the table doesn't exist yet or nothing is queued for this
822
+ * exact (source, target, relationship) triple.
823
+ */
824
+ export function clearQueuedLinkTarget(
825
+ db: Database,
826
+ sourceId: string,
827
+ relationship: string,
828
+ targetPath: string,
829
+ ): void {
830
+ try {
831
+ db.prepare(
832
+ "DELETE FROM unresolved_wikilinks WHERE source_id = ? AND relationship = ? AND target_path = ? COLLATE NOCASE",
833
+ ).run(sourceId, relationship, targetPath);
834
+ } catch {
835
+ // Table may not exist yet — nothing to clear.
836
+ }
837
+ }
838
+
810
839
  /**
811
840
  * Resolve a structured link NOW, or queue it for lazy resolution when the
812
841
  * target doesn't exist yet — mirroring the wikilink forward-ref contract
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.2-rc.4",
3
+ "version": "0.7.2-rc.5",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",