@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.
- package/core/src/core.test.ts +384 -0
- package/core/src/schema-defaults.ts +17 -2
- package/core/src/store.ts +345 -56
- package/core/src/wikilinks.ts +29 -0
- package/package.json +1 -1
package/core/src/core.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* unrelated write (e.g. a
|
|
140
|
-
* the value DID change (set,
|
|
141
|
-
*
|
|
142
|
-
* first, then re-established
|
|
143
|
-
*
|
|
144
|
-
* without needing to track the
|
|
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
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
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
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
|
}
|
package/core/src/wikilinks.ts
CHANGED
|
@@ -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
|