@openparachute/vault 0.7.0-rc.9 → 0.7.1
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 +260 -0
- package/core/src/contract-taxonomy.test.ts +26 -2
- package/core/src/contract-typed-index.test.ts +4 -2
- package/core/src/core.test.ts +669 -0
- package/core/src/doctor.ts +23 -1
- package/core/src/indexed-fields.test.ts +4 -1
- package/core/src/indexed-fields.ts +6 -1
- package/core/src/links.ts +22 -0
- package/core/src/mcp.ts +310 -51
- package/core/src/notes.ts +344 -18
- package/core/src/query-warnings.ts +60 -12
- package/core/src/schema-defaults.ts +7 -1
- package/core/src/seed-packs.ts +19 -5
- package/core/src/store.ts +129 -5
- package/core/src/tag-schemas.ts +20 -7
- package/core/src/types.ts +89 -0
- package/core/src/ulid.test.ts +56 -0
- package/core/src/ulid.ts +116 -0
- package/core/src/vault-projection.ts +19 -1
- package/core/src/wikilinks.test.ts +205 -1
- package/core/src/wikilinks.ts +200 -35
- package/package.json +1 -1
- package/src/aggregate-routes.test.ts +230 -0
- package/src/contract-errors.test.ts +2 -1
- package/src/contract-search.test.ts +17 -0
- package/src/mcp-link-warnings-scope.test.ts +144 -0
- package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
- package/src/mcp-tools.ts +76 -17
- package/src/routes.ts +272 -23
- package/src/routing.test.ts +167 -0
- package/src/routing.ts +47 -11
- package/src/tag-integrity-mcp.test.ts +45 -6
- package/src/tag-scope.ts +10 -1
- package/src/vault.test.ts +469 -21
- package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
- package/web/ui/dist/index.html +1 -1
package/core/src/core.test.ts
CHANGED
|
@@ -6,6 +6,9 @@ import { initSchema } from "./schema.js";
|
|
|
6
6
|
import { decodeCursor } from "./cursor.js";
|
|
7
7
|
import { traverseLinks } from "./links.js";
|
|
8
8
|
import * as indexedFieldOps from "./indexed-fields.js";
|
|
9
|
+
import { resolveLinkTarget } from "./wikilinks.js";
|
|
10
|
+
import { generateUlid, ULID_REGEX } from "./ulid.js";
|
|
11
|
+
import { getVaultMap, extractH1Title, findNotesByTitle, getNoteByTitle } from "./notes.js";
|
|
9
12
|
|
|
10
13
|
let store: SqliteStore;
|
|
11
14
|
let db: Database;
|
|
@@ -461,6 +464,66 @@ describe("notes.extension (vault#328)", async () => {
|
|
|
461
464
|
});
|
|
462
465
|
});
|
|
463
466
|
|
|
467
|
+
// ---- Title fallback (extractH1Title / findNotesByTitle / getNoteByTitle) ----
|
|
468
|
+
|
|
469
|
+
describe("extractH1Title", () => {
|
|
470
|
+
it("extracts the first level-1 heading", () => {
|
|
471
|
+
expect(extractH1Title("# My Title\n\nBody text.")).toBe("My Title");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("finds the H1 even when it isn't the first line", () => {
|
|
475
|
+
expect(extractH1Title("---\nid: x\n---\n\n# My Title\n\nBody.")).toBe("My Title");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("trims surrounding whitespace", () => {
|
|
479
|
+
expect(extractH1Title("# Spaced Out \nBody.")).toBe("Spaced Out");
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("does not match a level-2+ heading", () => {
|
|
483
|
+
expect(extractH1Title("## Not H1\n\nBody.")).toBeNull();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("returns null when there is no heading", () => {
|
|
487
|
+
expect(extractH1Title("Just a paragraph, no heading.")).toBeNull();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("returns null when the heading marker has no space", () => {
|
|
491
|
+
expect(extractH1Title("#NoSpace\n\nBody.")).toBeNull();
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("returns null for a blank heading", () => {
|
|
495
|
+
expect(extractH1Title("# \nBody.")).toBeNull();
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
describe("findNotesByTitle / getNoteByTitle", async () => {
|
|
500
|
+
it("finds a single note by its H1 title", async () => {
|
|
501
|
+
const note = await store.createNote("# Weekly Review\n\nBody.", { path: "Inbox/a1" });
|
|
502
|
+
const matches = findNotesByTitle(db, "Weekly Review");
|
|
503
|
+
expect(matches).toHaveLength(1);
|
|
504
|
+
expect(matches[0]!.id).toBe(note.id);
|
|
505
|
+
|
|
506
|
+
const found = getNoteByTitle(db, "Weekly Review");
|
|
507
|
+
expect(found?.id).toBe(note.id);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("matches case-insensitively", async () => {
|
|
511
|
+
const note = await store.createNote("# Weekly Review\n\nBody.", { path: "Inbox/a2" });
|
|
512
|
+
expect(getNoteByTitle(db, "weekly review")?.id).toBe(note.id);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("returns no match (not throw) when 2+ notes share a title — ambiguous", async () => {
|
|
516
|
+
await store.createNote("# Shared\n\nA.", { path: "Inbox/b1" });
|
|
517
|
+
await store.createNote("# Shared\n\nB.", { path: "Inbox/b2" });
|
|
518
|
+
expect(findNotesByTitle(db, "Shared")).toHaveLength(2);
|
|
519
|
+
expect(getNoteByTitle(db, "Shared")).toBeNull();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("returns null for an unknown title", async () => {
|
|
523
|
+
expect(getNoteByTitle(db, "Nothing Like This")).toBeNull();
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
464
527
|
// ---- Tags ----
|
|
465
528
|
|
|
466
529
|
describe("tags", async () => {
|
|
@@ -1126,6 +1189,116 @@ describe("vault stats", async () => {
|
|
|
1126
1189
|
});
|
|
1127
1190
|
});
|
|
1128
1191
|
|
|
1192
|
+
// ---- Vault Map (front-door structural orientation) ----
|
|
1193
|
+
|
|
1194
|
+
describe("vault map", async () => {
|
|
1195
|
+
it("handles an empty vault gracefully", async () => {
|
|
1196
|
+
const map = getVaultMap(db);
|
|
1197
|
+
expect(map.total_notes).toBe(0);
|
|
1198
|
+
expect(map.tags).toEqual([]);
|
|
1199
|
+
expect(map.path_buckets).toEqual([]);
|
|
1200
|
+
expect(map.unfiled_notes).toBe(0);
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
it("counts total notes, tag membership, and top-level path buckets", async () => {
|
|
1204
|
+
await store.createNote("a", { tags: ["person"], path: "People/Alice" });
|
|
1205
|
+
await store.createNote("b", { tags: ["person"], path: "People/Bob" });
|
|
1206
|
+
await store.createNote("c", { tags: ["project"], path: "Projects/Acme" });
|
|
1207
|
+
await store.createNote("d", { tags: ["project", "person"] }); // no path
|
|
1208
|
+
|
|
1209
|
+
const map = getVaultMap(db);
|
|
1210
|
+
expect(map.total_notes).toBe(4);
|
|
1211
|
+
|
|
1212
|
+
const tagByName = Object.fromEntries(map.tags.map((t) => [t.name, t.count]));
|
|
1213
|
+
expect(tagByName.person).toBe(3);
|
|
1214
|
+
expect(tagByName.project).toBe(2);
|
|
1215
|
+
|
|
1216
|
+
const bucketByName = Object.fromEntries(map.path_buckets.map((b) => [b.name, b.count]));
|
|
1217
|
+
expect(bucketByName.People).toBe(2);
|
|
1218
|
+
expect(bucketByName.Projects).toBe(1);
|
|
1219
|
+
|
|
1220
|
+
expect(map.unfiled_notes).toBe(1);
|
|
1221
|
+
// Invariant: unfiled + every bucket's count == total_notes (a note has at
|
|
1222
|
+
// most one path, so buckets + unfiled partition the vault exactly).
|
|
1223
|
+
const bucketSum = map.path_buckets.reduce((sum, b) => sum + b.count, 0);
|
|
1224
|
+
expect(bucketSum + map.unfiled_notes).toBe(map.total_notes);
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
it("buckets a top-level (no-slash) path under its own full name", async () => {
|
|
1228
|
+
await store.createNote("solo", { path: "Welcome" });
|
|
1229
|
+
const map = getVaultMap(db);
|
|
1230
|
+
expect(map.path_buckets).toEqual([{ name: "Welcome", count: 1 }]);
|
|
1231
|
+
expect(map.unfiled_notes).toBe(0);
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
it("tags are sorted by count desc, then name asc", async () => {
|
|
1235
|
+
for (let i = 0; i < 3; i++) await store.createNote(`m-${i}`, { tags: ["popular"] });
|
|
1236
|
+
await store.createNote("z", { tags: ["zzz-rare"] });
|
|
1237
|
+
await store.createNote("a", { tags: ["aaa-rare"] });
|
|
1238
|
+
|
|
1239
|
+
const map = getVaultMap(db);
|
|
1240
|
+
expect(map.tags).toEqual([
|
|
1241
|
+
{ name: "popular", count: 3 },
|
|
1242
|
+
{ name: "aaa-rare", count: 1 },
|
|
1243
|
+
{ name: "zzz-rare", count: 1 },
|
|
1244
|
+
]);
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
describe("tagFilter (scope-aware path)", () => {
|
|
1248
|
+
it("omitting tagFilter computes the vault-wide map (unchanged default)", async () => {
|
|
1249
|
+
await store.createNote("a", { tags: ["work"], path: "Work/One" });
|
|
1250
|
+
await store.createNote("b", { tags: ["personal"], path: "Personal/Two" });
|
|
1251
|
+
|
|
1252
|
+
const map = getVaultMap(db);
|
|
1253
|
+
expect(map.total_notes).toBe(2);
|
|
1254
|
+
expect(map.tags.map((t) => t.name).sort()).toEqual(["personal", "work"]);
|
|
1255
|
+
expect(map.path_buckets.map((b) => b.name).sort()).toEqual(["Personal", "Work"]);
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
it("an empty tagFilter array means nothing is in scope — all-zero map, NOT the full vault", async () => {
|
|
1259
|
+
await store.createNote("a", { tags: ["work"], path: "Work/One" });
|
|
1260
|
+
|
|
1261
|
+
const map = getVaultMap(db, { tagFilter: [] });
|
|
1262
|
+
expect(map).toEqual({ total_notes: 0, tags: [], path_buckets: [], unfiled_notes: 0 });
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
it("a non-empty tagFilter restricts every count to notes reachable through that tag set", async () => {
|
|
1266
|
+
await store.createNote("a", { tags: ["work"], path: "Work/One" });
|
|
1267
|
+
await store.createNote("b", { tags: ["work", "urgent"], path: "Work/Two" });
|
|
1268
|
+
await store.createNote("c", { tags: ["personal"], path: "Personal/Three" });
|
|
1269
|
+
await store.createNote("d", { tags: ["personal"] }); // no path
|
|
1270
|
+
|
|
1271
|
+
const map = getVaultMap(db, { tagFilter: ["work", "urgent"] });
|
|
1272
|
+
expect(map.total_notes).toBe(2); // a, b — "personal" notes excluded
|
|
1273
|
+
expect(map.tags).toEqual([
|
|
1274
|
+
{ name: "work", count: 2 },
|
|
1275
|
+
{ name: "urgent", count: 1 },
|
|
1276
|
+
]);
|
|
1277
|
+
expect(map.path_buckets).toEqual([{ name: "Work", count: 2 }]);
|
|
1278
|
+
expect(map.unfiled_notes).toBe(0);
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
it("a note carrying both an in-scope and out-of-scope tag is counted once, not double", async () => {
|
|
1282
|
+
await store.createNote("a", { tags: ["work", "personal"], path: "Work/One" });
|
|
1283
|
+
|
|
1284
|
+
const map = getVaultMap(db, { tagFilter: ["work"] });
|
|
1285
|
+
expect(map.total_notes).toBe(1);
|
|
1286
|
+
expect(map.path_buckets).toEqual([{ name: "Work", count: 1 }]);
|
|
1287
|
+
// "personal" is out of scope — must not leak into the tags list.
|
|
1288
|
+
expect(map.tags.map((t) => t.name)).not.toContain("personal");
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
it("scoped unfiled_notes counts only in-scope path-less notes", async () => {
|
|
1292
|
+
await store.createNote("a", { tags: ["work"] }); // no path, in scope
|
|
1293
|
+
await store.createNote("b", { tags: ["personal"] }); // no path, out of scope
|
|
1294
|
+
|
|
1295
|
+
const map = getVaultMap(db, { tagFilter: ["work"] });
|
|
1296
|
+
expect(map.unfiled_notes).toBe(1);
|
|
1297
|
+
expect(map.total_notes).toBe(1);
|
|
1298
|
+
});
|
|
1299
|
+
});
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1129
1302
|
// ---- Query ----
|
|
1130
1303
|
|
|
1131
1304
|
describe("queryNotes", async () => {
|
|
@@ -1615,6 +1788,119 @@ describe("queryNotes", async () => {
|
|
|
1615
1788
|
});
|
|
1616
1789
|
});
|
|
1617
1790
|
|
|
1791
|
+
describe("ULID ids for new notes (existing IDs unchanged)", () => {
|
|
1792
|
+
function pinUpdatedAt(id: string, iso: string) {
|
|
1793
|
+
db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?").run(iso, id);
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
it("new notes get ULID-format ids (26-char Crockford base32)", async () => {
|
|
1797
|
+
const note = await store.createNote("fresh note");
|
|
1798
|
+
expect(note.id).toHaveLength(26);
|
|
1799
|
+
expect(note.id).toMatch(ULID_REGEX);
|
|
1800
|
+
});
|
|
1801
|
+
|
|
1802
|
+
it("generateUlid() output matches the ULID format directly", () => {
|
|
1803
|
+
const id = generateUlid();
|
|
1804
|
+
expect(id).toHaveLength(26);
|
|
1805
|
+
expect(id).toMatch(ULID_REGEX);
|
|
1806
|
+
});
|
|
1807
|
+
|
|
1808
|
+
it("ULIDs minted in the same millisecond are monotonically increasing", () => {
|
|
1809
|
+
const ids = Array.from({ length: 200 }, () => generateUlid());
|
|
1810
|
+
// No duplicates, and strictly increasing under plain string compare —
|
|
1811
|
+
// this is the monotonic-within-a-ms guarantee, not just uniqueness.
|
|
1812
|
+
const unique = new Set(ids);
|
|
1813
|
+
expect(unique.size).toBe(ids.length);
|
|
1814
|
+
for (let i = 1; i < ids.length; i++) {
|
|
1815
|
+
expect(ids[i]! > ids[i - 1]!).toBe(true);
|
|
1816
|
+
}
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
it("an explicitly-supplied old-format id still round-trips (create/read/link/resolve)", async () => {
|
|
1820
|
+
const oldId = "2020-01-01-00-00-00-000001";
|
|
1821
|
+
|
|
1822
|
+
// create — old-format id passed explicitly via opts.id.
|
|
1823
|
+
const oldNote = await store.createNote("legacy note", { id: oldId, path: "legacy-note" });
|
|
1824
|
+
expect(oldNote.id).toBe(oldId);
|
|
1825
|
+
|
|
1826
|
+
// read — by id and by path.
|
|
1827
|
+
const byId = await store.getNote(oldId);
|
|
1828
|
+
expect(byId?.id).toBe(oldId);
|
|
1829
|
+
expect(byId?.content).toBe("legacy note");
|
|
1830
|
+
const byPath = await store.getNoteByPath("legacy-note");
|
|
1831
|
+
expect(byPath?.id).toBe(oldId);
|
|
1832
|
+
|
|
1833
|
+
// link — a brand-new (ULID) note links to the old-format note, and
|
|
1834
|
+
// vice versa; both directions traverse the graph correctly.
|
|
1835
|
+
const newNote = await store.createNote("modern note");
|
|
1836
|
+
expect(newNote.id).toMatch(ULID_REGEX);
|
|
1837
|
+
await store.createLink(newNote.id, oldId, "references");
|
|
1838
|
+
await store.createLink(oldId, newNote.id, "referenced-by");
|
|
1839
|
+
|
|
1840
|
+
const outbound = await store.getLinks(newNote.id, { direction: "outbound" });
|
|
1841
|
+
expect(outbound.some((l) => l.targetId === oldId)).toBe(true);
|
|
1842
|
+
const inbound = await store.getLinks(newNote.id, { direction: "inbound" });
|
|
1843
|
+
expect(inbound.some((l) => l.sourceId === oldId)).toBe(true);
|
|
1844
|
+
|
|
1845
|
+
// resolve — resolveLinkTarget's id-then-path-then-title chain works
|
|
1846
|
+
// identically for an old-format id as it does for a ULID.
|
|
1847
|
+
expect(resolveLinkTarget(db, oldId)).toBe(oldId);
|
|
1848
|
+
expect(resolveLinkTarget(db, "legacy-note")).toBe(oldId);
|
|
1849
|
+
expect(resolveLinkTarget(db, newNote.id)).toBe(newNote.id);
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
it("mixed-format cursor pagination is stable under a shared updated_at (no miss/dupe)", async () => {
|
|
1853
|
+
const ts = "2026-05-01T00:00:00.000Z";
|
|
1854
|
+
const ids: string[] = [];
|
|
1855
|
+
|
|
1856
|
+
// Two "old" notes with explicit timestamp-format ids...
|
|
1857
|
+
for (const oldId of ["2020-01-01-00-00-00-000001", "2020-06-15-12-30-00-500000"]) {
|
|
1858
|
+
const n = await store.createNote(`old ${oldId}`, { id: oldId });
|
|
1859
|
+
pinUpdatedAt(n.id, ts);
|
|
1860
|
+
ids.push(n.id);
|
|
1861
|
+
}
|
|
1862
|
+
// ...and three "new" notes with auto-generated ULIDs, all sharing the
|
|
1863
|
+
// exact same updated_at — id is the ONLY thing that can order them.
|
|
1864
|
+
for (let i = 0; i < 3; i++) {
|
|
1865
|
+
const n = await store.createNote(`new ${i}`);
|
|
1866
|
+
expect(n.id).toMatch(ULID_REGEX);
|
|
1867
|
+
pinUpdatedAt(n.id, ts);
|
|
1868
|
+
ids.push(n.id);
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// The expected stable total order is plain string comparison over the
|
|
1872
|
+
// id column — same rule the SQL keyset ORDER BY uses (BINARY/ASCII
|
|
1873
|
+
// collation), which holds regardless of id format.
|
|
1874
|
+
const expectedOrder = [...ids].sort();
|
|
1875
|
+
|
|
1876
|
+
// Paginate with a small limit to force several pages across the
|
|
1877
|
+
// format boundary and confirm no note is skipped or repeated.
|
|
1878
|
+
//
|
|
1879
|
+
// The first call MUST pass `cursor: ""` (the documented bootstrap
|
|
1880
|
+
// value — see queryNotes's cursor-mode doc comment in
|
|
1881
|
+
// core/src/notes.ts), NOT omit `cursor` entirely. Omitting it opts
|
|
1882
|
+
// OUT of cursor/keyset mode altogether, which orders the first page
|
|
1883
|
+
// by `created_at` (insertion order) instead of `updated_at` — a
|
|
1884
|
+
// DIFFERENT order than every subsequent (real-cursor) page uses. That
|
|
1885
|
+
// mismatch produces a watermark from a page in the wrong order, which
|
|
1886
|
+
// can then skip rows once keyset mode takes over on page 2 — exactly
|
|
1887
|
+
// the bug this test exists to catch, so the harness itself must use
|
|
1888
|
+
// the correct bootstrap contract or it can flake based on whether
|
|
1889
|
+
// `created_at` happens to tie across the fixture's notes.
|
|
1890
|
+
const seen: string[] = [];
|
|
1891
|
+
let cursor = "";
|
|
1892
|
+
for (let page = 0; page < 10; page++) {
|
|
1893
|
+
const result = await store.queryNotesPaged({ limit: 2, cursor });
|
|
1894
|
+
if (result.notes.length === 0) break;
|
|
1895
|
+
seen.push(...result.notes.map((n) => n.id));
|
|
1896
|
+
cursor = result.next_cursor;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
expect(seen).toEqual(expectedOrder);
|
|
1900
|
+
expect(new Set(seen).size).toBe(seen.length);
|
|
1901
|
+
});
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1618
1904
|
it("limits results", async () => {
|
|
1619
1905
|
for (let i = 0; i < 5; i++) await store.createNote(`Note ${i}`);
|
|
1620
1906
|
const results = await store.queryNotes({ limit: 3 });
|
|
@@ -2355,6 +2641,164 @@ describe("MCP tools", async () => {
|
|
|
2355
2641
|
expect(links.some((l) => l.targetId === target.id && l.relationship === "knows")).toBe(true);
|
|
2356
2642
|
});
|
|
2357
2643
|
|
|
2644
|
+
// vault#570 — content-parsed [[wikilinks]] to a missing target used to
|
|
2645
|
+
// fire NO write-time warning at all, even though the equivalent
|
|
2646
|
+
// structured `links` entry (tested above) already did. This closes that
|
|
2647
|
+
// asymmetry: the SAME `unresolved_link` warning shape, sourced from
|
|
2648
|
+
// content instead of `links`.
|
|
2649
|
+
it("create-note with a content [[wikilink]] to a missing target: warns (unresolved_link), same as structured links", async () => {
|
|
2650
|
+
const tools = generateMcpTools(store);
|
|
2651
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2652
|
+
const source = await createNote.execute({
|
|
2653
|
+
content: "See [[Missing Note]] for details.",
|
|
2654
|
+
}) as any;
|
|
2655
|
+
|
|
2656
|
+
expect(source.warnings).toBeDefined();
|
|
2657
|
+
expect(source.warnings).toHaveLength(1);
|
|
2658
|
+
expect(source.warnings[0].code).toBe("unresolved_link");
|
|
2659
|
+
expect(source.warnings[0].target).toBe("Missing Note");
|
|
2660
|
+
expect(source.warnings[0].relationship).toBe("wikilink");
|
|
2661
|
+
expect(await store.getLinks(source.id, { direction: "outbound" })).toHaveLength(0);
|
|
2662
|
+
|
|
2663
|
+
// Backfills automatically, same as the structured-link contract.
|
|
2664
|
+
const target = await store.createNote("here now", { path: "Missing Note" });
|
|
2665
|
+
const links = await store.getLinks(source.id, { direction: "outbound" });
|
|
2666
|
+
expect(links).toHaveLength(1);
|
|
2667
|
+
expect(links[0]!.targetId).toBe(target.id);
|
|
2668
|
+
});
|
|
2669
|
+
|
|
2670
|
+
// vault#570 — a target matching ≥2 notes (e.g. two notes sharing a
|
|
2671
|
+
// basename/title) is a factually different situation from "no note
|
|
2672
|
+
// matches" — reporting it as `unresolved_link` ("did not resolve to any
|
|
2673
|
+
// note") would be WRONG (it matched two, not zero). Must surface a
|
|
2674
|
+
// distinct `ambiguous_link` warning instead, and must NOT create an edge
|
|
2675
|
+
// (there's no principled way to pick one of the two candidates).
|
|
2676
|
+
it("create-note with a content [[wikilink]] to an AMBIGUOUS target (2 same-title notes): ambiguous_link, not unresolved_link, no edge", async () => {
|
|
2677
|
+
const a = await store.createNote("A", { path: "Folder1/Shared Title" });
|
|
2678
|
+
const b = await store.createNote("B", { path: "Folder2/Shared Title" });
|
|
2679
|
+
|
|
2680
|
+
const tools = generateMcpTools(store);
|
|
2681
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2682
|
+
const source = await createNote.execute({
|
|
2683
|
+
content: "See [[Shared Title]] for details.",
|
|
2684
|
+
}) as any;
|
|
2685
|
+
|
|
2686
|
+
expect(source.warnings).toBeDefined();
|
|
2687
|
+
expect(source.warnings).toHaveLength(1);
|
|
2688
|
+
expect(source.warnings[0].code).toBe("ambiguous_link");
|
|
2689
|
+
expect(source.warnings[0].target).toBe("Shared Title");
|
|
2690
|
+
expect(source.warnings[0].relationship).toBe("wikilink");
|
|
2691
|
+
expect(source.warnings[0].candidate_count).toBe(2);
|
|
2692
|
+
|
|
2693
|
+
// No edge created to EITHER candidate.
|
|
2694
|
+
const links = await store.getLinks(source.id, { direction: "outbound" });
|
|
2695
|
+
expect(links).toHaveLength(0);
|
|
2696
|
+
expect(links.some((l) => l.targetId === a.id || l.targetId === b.id)).toBe(false);
|
|
2697
|
+
|
|
2698
|
+
// Not queued for lazy resolution either — a future note being created
|
|
2699
|
+
// can't retroactively resolve an ambiguity between two EXISTING notes.
|
|
2700
|
+
// The table itself may not even exist yet (nothing else in this test
|
|
2701
|
+
// ever queued anything) — `PRAGMA table_info` on a nonexistent table
|
|
2702
|
+
// returns zero rows rather than throwing, same tolerance the wikilinks
|
|
2703
|
+
// module itself uses.
|
|
2704
|
+
const tableExists = (db.prepare("PRAGMA table_info(unresolved_wikilinks)").all() as unknown[]).length > 0;
|
|
2705
|
+
if (tableExists) {
|
|
2706
|
+
const pending = db.prepare(
|
|
2707
|
+
"SELECT * FROM unresolved_wikilinks WHERE source_id = ?",
|
|
2708
|
+
).all(source.id) as unknown[];
|
|
2709
|
+
expect(pending).toHaveLength(0);
|
|
2710
|
+
}
|
|
2711
|
+
});
|
|
2712
|
+
|
|
2713
|
+
// vault#570 — the same ambiguous-target treatment applies to structured
|
|
2714
|
+
// `links`, not just content [[wikilinks]] (resolveOrQueueLink is the
|
|
2715
|
+
// shared implementation both paths call).
|
|
2716
|
+
it("create-note with a structured link to an AMBIGUOUS target: ambiguous_link, no edge", async () => {
|
|
2717
|
+
await store.createNote("A", { path: "Folder1/Dup" });
|
|
2718
|
+
await store.createNote("B", { path: "Folder2/Dup" });
|
|
2719
|
+
|
|
2720
|
+
const tools = generateMcpTools(store);
|
|
2721
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2722
|
+
const source = await createNote.execute({
|
|
2723
|
+
content: "no wikilinks here",
|
|
2724
|
+
links: [{ target: "Dup", relationship: "knows" }],
|
|
2725
|
+
}) as any;
|
|
2726
|
+
|
|
2727
|
+
expect(source.warnings).toBeDefined();
|
|
2728
|
+
expect(source.warnings).toHaveLength(1);
|
|
2729
|
+
expect(source.warnings[0].code).toBe("ambiguous_link");
|
|
2730
|
+
expect(source.warnings[0].target).toBe("Dup");
|
|
2731
|
+
expect(source.warnings[0].relationship).toBe("knows");
|
|
2732
|
+
expect(source.warnings[0].candidate_count).toBe(2);
|
|
2733
|
+
expect(await store.getLinks(source.id, { direction: "outbound" })).toHaveLength(0);
|
|
2734
|
+
});
|
|
2735
|
+
|
|
2736
|
+
// vault#570 — a content [[wikilink]] to a note created LATER in the same
|
|
2737
|
+
// create-note batch must resolve silently (forward-ref), exactly like
|
|
2738
|
+
// structured `links` already do — no spurious `unresolved_link` warning
|
|
2739
|
+
// just because the classification pass runs before this fix would have.
|
|
2740
|
+
it("create-note batch: a content [[wikilink]] to a note created LATER in the same batch resolves without warning", async () => {
|
|
2741
|
+
const tools = generateMcpTools(store);
|
|
2742
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2743
|
+
const result = await createNote.execute({
|
|
2744
|
+
notes: [
|
|
2745
|
+
{ path: "WikiA", content: "links to [[WikiB]]" },
|
|
2746
|
+
{ path: "WikiB", content: "the target" },
|
|
2747
|
+
],
|
|
2748
|
+
}) as any[];
|
|
2749
|
+
const a = result.find((n: any) => n.path === "WikiA");
|
|
2750
|
+
expect(a.warnings).toBeUndefined();
|
|
2751
|
+
const links = await store.getLinks(a.id, { direction: "outbound" });
|
|
2752
|
+
expect(links).toHaveLength(1);
|
|
2753
|
+
expect(links[0]!.relationship).toBe("wikilink");
|
|
2754
|
+
});
|
|
2755
|
+
|
|
2756
|
+
// vault#570 — update-note's content replace path gets the same
|
|
2757
|
+
// unresolved_link/ambiguous_link treatment as create-note.
|
|
2758
|
+
it("update-note content replace with a [[wikilink]] to a missing target: warns (unresolved_link)", async () => {
|
|
2759
|
+
const note = await store.createNote("plain content", { path: "Updatable" });
|
|
2760
|
+
const tools = generateMcpTools(store);
|
|
2761
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2762
|
+
|
|
2763
|
+
const result = await updateNote.execute({
|
|
2764
|
+
id: note.id,
|
|
2765
|
+
content: "now references [[Not Yet Real]]",
|
|
2766
|
+
force: true,
|
|
2767
|
+
}) as any;
|
|
2768
|
+
|
|
2769
|
+
expect(result.warnings).toBeDefined();
|
|
2770
|
+
expect(result.warnings[0].code).toBe("unresolved_link");
|
|
2771
|
+
expect(result.warnings[0].target).toBe("Not Yet Real");
|
|
2772
|
+
|
|
2773
|
+
// A tags/links-only update on the SAME note must NOT re-surface the
|
|
2774
|
+
// warning — nothing about content changed on this call.
|
|
2775
|
+
const tagOnly = await updateNote.execute({
|
|
2776
|
+
id: note.id,
|
|
2777
|
+
tags: { add: ["x"] },
|
|
2778
|
+
force: true,
|
|
2779
|
+
}) as any;
|
|
2780
|
+
expect(tagOnly.warnings).toBeUndefined();
|
|
2781
|
+
});
|
|
2782
|
+
|
|
2783
|
+
it("update-note content replace with a [[wikilink]] to an AMBIGUOUS target: ambiguous_link, no edge", async () => {
|
|
2784
|
+
await store.createNote("A", { path: "Folder1/Both" });
|
|
2785
|
+
await store.createNote("B", { path: "Folder2/Both" });
|
|
2786
|
+
const note = await store.createNote("plain content", { path: "Updatable2" });
|
|
2787
|
+
const tools = generateMcpTools(store);
|
|
2788
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2789
|
+
|
|
2790
|
+
const result = await updateNote.execute({
|
|
2791
|
+
id: note.id,
|
|
2792
|
+
content: "now references [[Both]]",
|
|
2793
|
+
force: true,
|
|
2794
|
+
}) as any;
|
|
2795
|
+
|
|
2796
|
+
expect(result.warnings).toBeDefined();
|
|
2797
|
+
expect(result.warnings[0].code).toBe("ambiguous_link");
|
|
2798
|
+
expect(result.warnings[0].candidate_count).toBe(2);
|
|
2799
|
+
expect(await store.getLinks(note.id, { direction: "outbound" })).toHaveLength(0);
|
|
2800
|
+
});
|
|
2801
|
+
|
|
2358
2802
|
it("update-note tool updates created_at", async () => {
|
|
2359
2803
|
const note = await store.createNote("Test");
|
|
2360
2804
|
const tools = generateMcpTools(store);
|
|
@@ -3347,6 +3791,36 @@ describe("MCP tools", async () => {
|
|
|
3347
3791
|
expect(result.content).toBe("By Path");
|
|
3348
3792
|
});
|
|
3349
3793
|
|
|
3794
|
+
// ---- Title-fallback id resolution (additive — vault "title-fallback link + id resolution") ----
|
|
3795
|
+
|
|
3796
|
+
it("query-notes single note by H1 title when id AND path both miss", async () => {
|
|
3797
|
+
await store.createNote("# My Great Note\n\nBody.", { path: "Inbox/2026-07-10-xyz" });
|
|
3798
|
+
const tools = generateMcpTools(store);
|
|
3799
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3800
|
+
const result = await query.execute({ id: "My Great Note" }) as any;
|
|
3801
|
+
expect(result.content).toBe("# My Great Note\n\nBody.");
|
|
3802
|
+
expect(result.path).toBe("Inbox/2026-07-10-xyz");
|
|
3803
|
+
});
|
|
3804
|
+
|
|
3805
|
+
it("query-notes exact path still wins over a same-named title on another note", async () => {
|
|
3806
|
+
const byPath = await store.createNote("Path note", { path: "My Great Note" });
|
|
3807
|
+
await store.createNote("# My Great Note\n\nOther body.", { path: "Inbox/other" });
|
|
3808
|
+
const tools = generateMcpTools(store);
|
|
3809
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3810
|
+
const result = await query.execute({ id: "My Great Note" }) as any;
|
|
3811
|
+
expect(result.id).toBe(byPath.id);
|
|
3812
|
+
expect(result.content).toBe("Path note");
|
|
3813
|
+
});
|
|
3814
|
+
|
|
3815
|
+
it("query-notes stays unresolved (not_found) when 2+ notes share the same H1 title", async () => {
|
|
3816
|
+
await store.createNote("# Dup Note\n\nA.", { path: "Inbox/dup-a" });
|
|
3817
|
+
await store.createNote("# Dup Note\n\nB.", { path: "Inbox/dup-b" });
|
|
3818
|
+
const tools = generateMcpTools(store);
|
|
3819
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3820
|
+
const result = await query.execute({ id: "Dup Note" }) as any;
|
|
3821
|
+
expect(result.error_type).toBe("not_found");
|
|
3822
|
+
});
|
|
3823
|
+
|
|
3350
3824
|
it("query-notes by tag", async () => {
|
|
3351
3825
|
await store.createNote("Test", { tags: ["daily"] });
|
|
3352
3826
|
const tools = generateMcpTools(store);
|
|
@@ -3875,10 +4349,205 @@ describe("MCP tools", async () => {
|
|
|
3875
4349
|
d: { type: "boolean" },
|
|
3876
4350
|
e: { type: "array" },
|
|
3877
4351
|
f: { type: "object" },
|
|
4352
|
+
g: { type: "reference" },
|
|
3878
4353
|
},
|
|
3879
4354
|
}) as any;
|
|
3880
4355
|
expect(result.fields.a.type).toBe("string");
|
|
3881
4356
|
expect(result.fields.f.type).toBe("object");
|
|
4357
|
+
expect(result.fields.g.type).toBe("reference");
|
|
4358
|
+
});
|
|
4359
|
+
|
|
4360
|
+
// ---- Typed reference field: indexed value + auto-link (vault#typed-reference-field) ----
|
|
4361
|
+
|
|
4362
|
+
describe("typed reference field", () => {
|
|
4363
|
+
it("create-note with a reference-typed field indexes the value AND creates the link", async () => {
|
|
4364
|
+
const target = await store.createNote("Alice", { path: "People/Alice" });
|
|
4365
|
+
await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
|
|
4366
|
+
const tools = generateMcpTools(store);
|
|
4367
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
4368
|
+
const result = await createNote.execute({
|
|
4369
|
+
content: "Ship it",
|
|
4370
|
+
tags: ["task"],
|
|
4371
|
+
metadata: { assignee: "People/Alice" },
|
|
4372
|
+
}) as any;
|
|
4373
|
+
|
|
4374
|
+
// (a) the value half — stored + readable like a string field.
|
|
4375
|
+
expect(result.metadata.assignee).toBe("People/Alice");
|
|
4376
|
+
|
|
4377
|
+
// (b) the link half — a graph edge to the resolved target, relationship = field name.
|
|
4378
|
+
const links = await store.getLinks(result.id, { direction: "outbound" });
|
|
4379
|
+
const assigneeLink = links.find((l) => l.relationship === "assignee");
|
|
4380
|
+
expect(assigneeLink).toBeDefined();
|
|
4381
|
+
expect(assigneeLink!.targetId).toBe(target.id);
|
|
4382
|
+
});
|
|
4383
|
+
|
|
4384
|
+
it("resolves a reference field by note ID as well as path/title", async () => {
|
|
4385
|
+
const target = await store.createNote("Bob", { id: "bob-id" });
|
|
4386
|
+
await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
|
|
4387
|
+
const tools = generateMcpTools(store);
|
|
4388
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
4389
|
+
const result = await createNote.execute({
|
|
4390
|
+
content: "Ship it",
|
|
4391
|
+
tags: ["task"],
|
|
4392
|
+
metadata: { assignee: "bob-id" },
|
|
4393
|
+
}) as any;
|
|
4394
|
+
|
|
4395
|
+
const links = await store.getLinks(result.id, { direction: "outbound" });
|
|
4396
|
+
expect(links.find((l) => l.relationship === "assignee")?.targetId).toBe(target.id);
|
|
4397
|
+
});
|
|
4398
|
+
|
|
4399
|
+
it("querying by a reference field's value works (plain metadata filter and, when indexed, the eq operator)", async () => {
|
|
4400
|
+
await store.createNote("Alice", { path: "People/Alice", id: "alice" });
|
|
4401
|
+
const tools = generateMcpTools(store);
|
|
4402
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
4403
|
+
await updateTag.execute({
|
|
4404
|
+
tag: "task",
|
|
4405
|
+
fields: { assignee: { type: "reference", indexed: true } },
|
|
4406
|
+
});
|
|
4407
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
4408
|
+
const created = await createNote.execute({
|
|
4409
|
+
content: "Ship it",
|
|
4410
|
+
tags: ["task"],
|
|
4411
|
+
metadata: { assignee: "alice" },
|
|
4412
|
+
}) as any;
|
|
4413
|
+
|
|
4414
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
4415
|
+
const plain = await queryNotes.execute({ tags: ["task"], metadata: { assignee: "alice" } }) as any[];
|
|
4416
|
+
expect(plain.map((n: any) => n.id)).toContain(created.id);
|
|
4417
|
+
|
|
4418
|
+
const viaOperator = await queryNotes.execute({
|
|
4419
|
+
tags: ["task"],
|
|
4420
|
+
metadata: { assignee: { eq: "alice" } },
|
|
4421
|
+
}) as any[];
|
|
4422
|
+
expect(viaOperator.map((n: any) => n.id)).toContain(created.id);
|
|
4423
|
+
});
|
|
4424
|
+
|
|
4425
|
+
it("update-note changing a reference field's value moves the link (old edge dropped, new edge created)", async () => {
|
|
4426
|
+
const alice = await store.createNote("Alice", { id: "alice" });
|
|
4427
|
+
const bob = await store.createNote("Bob", { id: "bob" });
|
|
4428
|
+
await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
|
|
4429
|
+
const tools = generateMcpTools(store);
|
|
4430
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
4431
|
+
const note = await createNote.execute({
|
|
4432
|
+
content: "Ship it",
|
|
4433
|
+
tags: ["task"],
|
|
4434
|
+
metadata: { assignee: "alice" },
|
|
4435
|
+
}) as any;
|
|
4436
|
+
|
|
4437
|
+
let links = await store.getLinks(note.id, { direction: "outbound" });
|
|
4438
|
+
expect(links.filter((l) => l.relationship === "assignee").map((l) => l.targetId)).toEqual([alice.id]);
|
|
4439
|
+
|
|
4440
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
4441
|
+
await updateNote.execute({ id: note.id, metadata: { assignee: "bob" }, force: true });
|
|
4442
|
+
|
|
4443
|
+
links = await store.getLinks(note.id, { direction: "outbound" });
|
|
4444
|
+
const assigneeLinks = links.filter((l) => l.relationship === "assignee");
|
|
4445
|
+
expect(assigneeLinks).toHaveLength(1);
|
|
4446
|
+
expect(assigneeLinks[0]!.targetId).toBe(bob.id);
|
|
4447
|
+
|
|
4448
|
+
const fresh = await store.getNote(note.id);
|
|
4449
|
+
expect(fresh!.metadata!.assignee).toBe("bob");
|
|
4450
|
+
});
|
|
4451
|
+
|
|
4452
|
+
it("update-note clearing a reference field drops the link", async () => {
|
|
4453
|
+
await store.createNote("Alice", { id: "alice" });
|
|
4454
|
+
await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
|
|
4455
|
+
const tools = generateMcpTools(store);
|
|
4456
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
4457
|
+
const note = await createNote.execute({
|
|
4458
|
+
content: "Ship it",
|
|
4459
|
+
tags: ["task"],
|
|
4460
|
+
metadata: { assignee: "alice" },
|
|
4461
|
+
}) as any;
|
|
4462
|
+
|
|
4463
|
+
let links = await store.getLinks(note.id, { direction: "outbound" });
|
|
4464
|
+
expect(links.some((l) => l.relationship === "assignee")).toBe(true);
|
|
4465
|
+
|
|
4466
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
4467
|
+
// RFC 7386 tombstone — null deletes the key.
|
|
4468
|
+
await updateNote.execute({ id: note.id, metadata: { assignee: null }, force: true });
|
|
4469
|
+
|
|
4470
|
+
links = await store.getLinks(note.id, { direction: "outbound" });
|
|
4471
|
+
expect(links.some((l) => l.relationship === "assignee")).toBe(false);
|
|
4472
|
+
|
|
4473
|
+
const fresh = await store.getNote(note.id);
|
|
4474
|
+
expect(fresh!.metadata).not.toHaveProperty("assignee");
|
|
4475
|
+
});
|
|
4476
|
+
|
|
4477
|
+
it("re-writing a reference field with the SAME value is a no-op (link untouched)", async () => {
|
|
4478
|
+
const alice = await store.createNote("Alice", { id: "alice" });
|
|
4479
|
+
await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
|
|
4480
|
+
const tools = generateMcpTools(store);
|
|
4481
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
4482
|
+
const note = await createNote.execute({
|
|
4483
|
+
content: "Ship it",
|
|
4484
|
+
tags: ["task"],
|
|
4485
|
+
metadata: { assignee: "alice" },
|
|
4486
|
+
}) as any;
|
|
4487
|
+
|
|
4488
|
+
const before = await store.getLinks(note.id, { direction: "outbound" });
|
|
4489
|
+
const beforeLink = before.find((l) => l.relationship === "assignee");
|
|
4490
|
+
expect(beforeLink).toBeDefined();
|
|
4491
|
+
|
|
4492
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
4493
|
+
await updateNote.execute({ id: note.id, metadata: { assignee: "alice" }, force: true });
|
|
4494
|
+
|
|
4495
|
+
const after = await store.getLinks(note.id, { direction: "outbound" });
|
|
4496
|
+
const afterLinks = after.filter((l) => l.relationship === "assignee");
|
|
4497
|
+
expect(afterLinks).toHaveLength(1);
|
|
4498
|
+
expect(afterLinks[0]!.targetId).toBe(alice.id);
|
|
4499
|
+
// Same createdAt — proves the edge was never dropped and recreated.
|
|
4500
|
+
expect(afterLinks[0]!.createdAt).toBe(beforeLink!.createdAt);
|
|
4501
|
+
});
|
|
4502
|
+
|
|
4503
|
+
it("a reference field's unresolved target queues and backfills when the target note is created later", async () => {
|
|
4504
|
+
await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
|
|
4505
|
+
const tools = generateMcpTools(store);
|
|
4506
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
4507
|
+
const note = await createNote.execute({
|
|
4508
|
+
content: "Ship it",
|
|
4509
|
+
tags: ["task"],
|
|
4510
|
+
metadata: { assignee: "Not Yet Created" },
|
|
4511
|
+
}) as any;
|
|
4512
|
+
|
|
4513
|
+
let links = await store.getLinks(note.id, { direction: "outbound" });
|
|
4514
|
+
expect(links.some((l) => l.relationship === "assignee")).toBe(false);
|
|
4515
|
+
|
|
4516
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
4517
|
+
const broken = await queryNotes.execute({ has_broken_links: true }) as any[];
|
|
4518
|
+
expect(broken.map((n: any) => n.id)).toContain(note.id);
|
|
4519
|
+
|
|
4520
|
+
const target = await store.createNote("here now", { path: "Not Yet Created" });
|
|
4521
|
+
|
|
4522
|
+
links = await store.getLinks(note.id, { direction: "outbound" });
|
|
4523
|
+
const assigneeLink = links.find((l) => l.relationship === "assignee");
|
|
4524
|
+
expect(assigneeLink).toBeDefined();
|
|
4525
|
+
expect(assigneeLink!.targetId).toBe(target.id);
|
|
4526
|
+
});
|
|
4527
|
+
|
|
4528
|
+
it("a content-only or tags-only update does not touch a note's reference-field link", async () => {
|
|
4529
|
+
const alice = await store.createNote("Alice", { id: "alice" });
|
|
4530
|
+
await store.upsertTagSchema("task", { fields: { assignee: { type: "reference" } } });
|
|
4531
|
+
const tools = generateMcpTools(store);
|
|
4532
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
4533
|
+
const note = await createNote.execute({
|
|
4534
|
+
content: "Ship it",
|
|
4535
|
+
tags: ["task"],
|
|
4536
|
+
metadata: { assignee: "alice" },
|
|
4537
|
+
}) as any;
|
|
4538
|
+
|
|
4539
|
+
const before = await store.getLinks(note.id, { direction: "outbound" });
|
|
4540
|
+
const beforeLink = before.find((l) => l.relationship === "assignee");
|
|
4541
|
+
|
|
4542
|
+
// Content-only update — no `metadata` key in the call at all.
|
|
4543
|
+
await store.updateNote(note.id, { content: "Ship it faster" });
|
|
4544
|
+
|
|
4545
|
+
const after = await store.getLinks(note.id, { direction: "outbound" });
|
|
4546
|
+
const afterLink = after.find((l) => l.relationship === "assignee");
|
|
4547
|
+
expect(afterLink).toBeDefined();
|
|
4548
|
+
expect(afterLink!.targetId).toBe(alice.id);
|
|
4549
|
+
expect(afterLink!.createdAt).toBe(beforeLink!.createdAt);
|
|
4550
|
+
});
|
|
3882
4551
|
});
|
|
3883
4552
|
|
|
3884
4553
|
it("find-path works with ID/path resolution", async () => {
|