@openparachute/vault 0.7.2-rc.3 → 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/aggregate.test.ts +23 -0
- package/core/src/contract-taxonomy.test.ts +22 -0
- package/core/src/core.test.ts +384 -0
- package/core/src/query-operators.ts +56 -0
- package/core/src/schema-defaults.ts +17 -2
- package/core/src/store.ts +353 -56
- package/core/src/tag-hierarchy.ts +13 -0
- package/core/src/wikilinks.test.ts +175 -0
- package/core/src/wikilinks.ts +180 -10
- package/package.json +1 -1
- package/src/routes.ts +143 -5
- package/src/vault.test.ts +139 -1
|
@@ -214,6 +214,29 @@ describe("aggregateNotes — errors", () => {
|
|
|
214
214
|
}
|
|
215
215
|
});
|
|
216
216
|
|
|
217
|
+
// LB — a `type: "number"` (float) field can NEVER be `indexed: true`
|
|
218
|
+
// (only integer/boolean are indexable numeric shapes), so it legitimately
|
|
219
|
+
// never appears in `indexed_fields`. Before the fix, the FIELD_NOT_INDEXED
|
|
220
|
+
// thrown here carried the SAME generic "declare it via update-tag with
|
|
221
|
+
// indexed: true" hint every other unindexed field gets — advice that's
|
|
222
|
+
// impossible to satisfy for `number` specifically, dead-ending an agent
|
|
223
|
+
// that tries it and hits the SEPARATE "unsupported type ... for indexing"
|
|
224
|
+
// error. The hint must instead name the real fix: store as an integer.
|
|
225
|
+
it("FIELD_NOT_INDEXED on a type: \"number\" sum field carries an actionable hint, not the impossible generic \"declare indexed: true\" advice", async () => {
|
|
226
|
+
await store.upsertTagRecord("expense", { fields: { amount: { type: "number" } } });
|
|
227
|
+
await store.createNote("a", { tags: ["expense"], metadata: { amount: 9.99 } });
|
|
228
|
+
try {
|
|
229
|
+
aggregateNotes(db, { aggregate: { group_by: "tag", op: "sum", field: "amount" } });
|
|
230
|
+
throw new Error("expected throw");
|
|
231
|
+
} catch (e: any) {
|
|
232
|
+
expect(e.code).toBe("FIELD_NOT_INDEXED");
|
|
233
|
+
expect(e.message).not.toContain("declare it via update-tag with indexed: true");
|
|
234
|
+
const guidance = `${e.message} ${e.hint ?? ""}`.toLowerCase();
|
|
235
|
+
expect(guidance).toContain("integer");
|
|
236
|
+
expect(guidance).toMatch(/float|number|decimal/);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
217
240
|
it("throws INVALID_QUERY when the sum field is indexed but not numeric (TEXT-backed)", async () => {
|
|
218
241
|
await store.upsertTagRecord("task", {
|
|
219
242
|
fields: {
|
|
@@ -295,6 +295,28 @@ describe("contract: taxonomy — #552 (flipped from test.todo)", () => {
|
|
|
295
295
|
expect(cleanReport.summary).toContain("clean");
|
|
296
296
|
});
|
|
297
297
|
|
|
298
|
+
// LB — a DIRECT self-parent (`parent_names: [tag]` on tag itself) is a
|
|
299
|
+
// degenerate one-node cycle: `getTagDescendants` starts its traversal
|
|
300
|
+
// result at `{tag}`, and the moment it walks the tag's own self-edge it
|
|
301
|
+
// finds `tag` already `result.has(child)` and skips re-expanding it — so
|
|
302
|
+
// `descendants` never grows past size 1. `findHierarchyCycles`'s original
|
|
303
|
+
// gate (`descendants.has(tag) && descendants.size > 1`) requires size>1,
|
|
304
|
+
// so this exact case reported clean despite being a real cycle. Only
|
|
305
|
+
// reachable via guard-bypassing data (direct DB write / import) since
|
|
306
|
+
// `upsertTagRecord` rejects a self-parent at write time (see the "bare
|
|
307
|
+
// self-parent: also caught" assertion above, which exercises the WRITE
|
|
308
|
+
// guard — this test exercises the READ-side doctor scan on data that
|
|
309
|
+
// skipped that guard).
|
|
310
|
+
it("doctor flags a bare self-parent cycle (X declares parent_names: [X]), which the size>1 gate alone misses", async () => {
|
|
311
|
+
await store.upsertTagRecord("self-cyc", {});
|
|
312
|
+
db.prepare("UPDATE tags SET parent_names = ? WHERE name = ?").run(JSON.stringify(["self-cyc"]), "self-cyc");
|
|
313
|
+
|
|
314
|
+
const report = await store.doctor();
|
|
315
|
+
const finding = report.findings.find((f) => f.type === "parent_names_cycle" && f.subject === "self-cyc");
|
|
316
|
+
expect(finding).toBeDefined();
|
|
317
|
+
expect(finding?.severity).toBe("error");
|
|
318
|
+
});
|
|
319
|
+
|
|
298
320
|
it("dead_tag_metadata_reference does not false-positive on a schema-declared enum field whose values coincide with an unrelated live tag (vault#570)", async () => {
|
|
299
321
|
// "archived" is a real, unrelated live tag — coincidentally sharing a
|
|
300
322
|
// name with one of "status"'s declared enum values. Pre-fix, this ONE
|
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 () => {
|
|
@@ -101,13 +101,69 @@ function toBinding(field: string, op: string, value: unknown): SQLQueryBindings
|
|
|
101
101
|
);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Best-effort lookup of a metadata field's declared `type` across every tag
|
|
106
|
+
* schema — used ONLY to sharpen the {@link requireIndexedField} error hint
|
|
107
|
+
* below when the field was never indexed. Scans `tags.fields` directly
|
|
108
|
+
* rather than going through `indexed_fields` (which by construction has no
|
|
109
|
+
* row for a type that can never BE indexed, e.g. `"number"`) or a
|
|
110
|
+
* per-note schema resolution (scoped to one note's tags, not "any tag
|
|
111
|
+
* anywhere"). Only reached on the FIELD_NOT_INDEXED error path — a full
|
|
112
|
+
* table scan here is fine since it runs once per rejected call, never on a
|
|
113
|
+
* hot path. Returns the type from the FIRST tag found declaring `field`
|
|
114
|
+
* (a cross-tag type conflict on the same field name is its own separate
|
|
115
|
+
* validation elsewhere, not this function's job); `undefined` when no tag
|
|
116
|
+
* declares this field name, or its `fields` JSON doesn't parse.
|
|
117
|
+
*/
|
|
118
|
+
function findDeclaredFieldType(db: Database, field: string): string | undefined {
|
|
119
|
+
const rows = db.prepare(
|
|
120
|
+
`SELECT fields FROM tags WHERE fields IS NOT NULL`,
|
|
121
|
+
).all() as { fields: string | null }[];
|
|
122
|
+
for (const row of rows) {
|
|
123
|
+
if (!row.fields) continue;
|
|
124
|
+
try {
|
|
125
|
+
const parsed: unknown = JSON.parse(row.fields);
|
|
126
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
|
127
|
+
const spec = (parsed as Record<string, unknown>)[field];
|
|
128
|
+
if (spec === null || typeof spec !== "object" || Array.isArray(spec)) continue;
|
|
129
|
+
const type = (spec as Record<string, unknown>).type;
|
|
130
|
+
if (typeof type === "string") return type;
|
|
131
|
+
} catch {
|
|
132
|
+
// Malformed `fields` JSON on some other tag — not this lookup's job
|
|
133
|
+
// to validate; skip and keep scanning.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
104
139
|
/**
|
|
105
140
|
* Look up `field` in `indexed_fields` or throw a loud error suggesting the
|
|
106
141
|
* caller declare it via `update-tag` with `indexed: true`.
|
|
142
|
+
*
|
|
143
|
+
* That generic advice is IMPOSSIBLE to satisfy for a field whose schema
|
|
144
|
+
* declares `type: "number"` (a float) — only `integer`/`boolean` are
|
|
145
|
+
* indexable numeric shapes (see `indexed-fields.ts`'s `TYPE_MAP`), so
|
|
146
|
+
* `update-tag` with `indexed: true` on a `number` field itself throws
|
|
147
|
+
* "unsupported type ... for indexing". An agent that only sees the generic
|
|
148
|
+
* hint has no escape from that loop (LB — round-4 bug hunt). When the
|
|
149
|
+
* declared type is `"number"`, swap in a hint that names the actual fix:
|
|
150
|
+
* store the value as an integer (e.g. cents instead of dollars) and index
|
|
151
|
+
* THAT field instead.
|
|
107
152
|
*/
|
|
108
153
|
export function requireIndexedField(db: Database, field: string): IndexedField {
|
|
109
154
|
const row = getIndexedField(db, field);
|
|
110
155
|
if (!row) {
|
|
156
|
+
if (findDeclaredFieldType(db, field) === "number") {
|
|
157
|
+
throw new QueryError(
|
|
158
|
+
`metadata field "${field}" is not indexed, and can never be: it's declared type: "number" (a float), and only integer/boolean fields are indexable numeric types. "declare indexed: true" will not work on this field.`,
|
|
159
|
+
"FIELD_NOT_INDEXED",
|
|
160
|
+
{
|
|
161
|
+
error_type: "field_not_indexed",
|
|
162
|
+
field,
|
|
163
|
+
hint: `"${field}" is a float ("number") field — float fields can't be server-side indexed, filtered with operators, used in order_by, or summed via aggregate. Store the value as an integer instead (e.g. cents rather than dollars) and declare indexed: true on THAT field to enable those.`,
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
}
|
|
111
167
|
throw new QueryError(
|
|
112
168
|
`metadata field "${field}" is not indexed. To use operator queries or order_by on this field, declare it via update-tag with indexed: true.`,
|
|
113
169
|
"FIELD_NOT_INDEXED",
|
|
@@ -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
|