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