@openparachute/vault 0.7.0-rc.8 → 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 +1151 -0
- package/core/src/cursor.ts +1 -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 +587 -79
- package/core/src/notes.ts +371 -18
- package/core/src/query-warnings.ts +60 -12
- package/core/src/schema-defaults.ts +7 -1
- package/core/src/seed-packs.test.ts +14 -0
- package/core/src/seed-packs.ts +42 -5
- package/core/src/store.ts +129 -5
- package/core/src/tag-schemas.ts +20 -7
- package/core/src/types.ts +98 -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 +244 -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 +100 -19
- package/src/routes.ts +469 -39
- 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 +923 -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);
|
|
@@ -3376,6 +3850,93 @@ describe("MCP tools", async () => {
|
|
|
3376
3850
|
expect(result.map((n) => n.content)).toEqual(["Orphan"]);
|
|
3377
3851
|
});
|
|
3378
3852
|
|
|
3853
|
+
// ---- has_broken_links / include_broken_links (vault#555) ----
|
|
3854
|
+
|
|
3855
|
+
it("query-notes has_broken_links=true surfaces only notes with a dangling wikilink", async () => {
|
|
3856
|
+
await store.createNote("[[Nonexistent Target]]", { id: "mq-broken", path: "mq-broken" });
|
|
3857
|
+
await store.createNote("no links here", { id: "mq-clean", path: "mq-clean" });
|
|
3858
|
+
|
|
3859
|
+
const tools = generateMcpTools(store);
|
|
3860
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3861
|
+
const result = await query.execute({ has_broken_links: true, include_content: true }) as any[];
|
|
3862
|
+
expect(result.map((n) => n.path)).toEqual(["mq-broken"]);
|
|
3863
|
+
});
|
|
3864
|
+
|
|
3865
|
+
it("query-notes has_broken_links=false excludes notes with a dangling link", async () => {
|
|
3866
|
+
await store.createNote("[[Nonexistent Target]]", { id: "mq-broken2", path: "mq-broken2" });
|
|
3867
|
+
await store.createNote("no links here", { id: "mq-clean2", path: "mq-clean2" });
|
|
3868
|
+
|
|
3869
|
+
const tools = generateMcpTools(store);
|
|
3870
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3871
|
+
const result = await query.execute({ has_broken_links: false, include_content: true }) as any[];
|
|
3872
|
+
expect(result.map((n) => n.path).sort()).toEqual(["mq-clean2"]);
|
|
3873
|
+
});
|
|
3874
|
+
|
|
3875
|
+
it("query-notes has_broken_links is safe on a vault where no link has ever gone unresolved", async () => {
|
|
3876
|
+
// Fresh store, beforeEach — the unresolved_wikilinks table has never
|
|
3877
|
+
// been created. true should match nothing (not throw); false should
|
|
3878
|
+
// be a no-op (matches everything).
|
|
3879
|
+
await store.createNote("plain note", { id: "mq-nolinks", path: "mq-nolinks" });
|
|
3880
|
+
const tools = generateMcpTools(store);
|
|
3881
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3882
|
+
const truthy = await query.execute({ has_broken_links: true, include_content: true }) as any[];
|
|
3883
|
+
expect(truthy).toEqual([]);
|
|
3884
|
+
const falsy = await query.execute({ has_broken_links: false, include_content: true }) as any[];
|
|
3885
|
+
expect(falsy.map((n) => n.path)).toEqual(["mq-nolinks"]);
|
|
3886
|
+
});
|
|
3887
|
+
|
|
3888
|
+
it("a target created later resolves the wikilink and the note drops out of has_broken_links:true", async () => {
|
|
3889
|
+
await store.createNote("[[Later Target]]", { id: "mq-fixable", path: "mq-fixable" });
|
|
3890
|
+
const tools = generateMcpTools(store);
|
|
3891
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3892
|
+
expect((await query.execute({ has_broken_links: true }) as any[]).length).toBe(1);
|
|
3893
|
+
|
|
3894
|
+
await store.createNote("here now", { path: "Later Target" });
|
|
3895
|
+
expect((await query.execute({ has_broken_links: true }) as any[]).length).toBe(0);
|
|
3896
|
+
});
|
|
3897
|
+
|
|
3898
|
+
it("query-notes include_broken_links surfaces {target, relationship} for a single note", async () => {
|
|
3899
|
+
await store.createNote("[[Missing Note]]", { id: "mq-single-broken", path: "mq-single-broken" });
|
|
3900
|
+
const tools = generateMcpTools(store);
|
|
3901
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3902
|
+
const result = await query.execute({ id: "mq-single-broken", include_broken_links: true }) as any;
|
|
3903
|
+
expect(result.broken_links).toEqual([{ target: "Missing Note", relationship: "wikilink" }]);
|
|
3904
|
+
});
|
|
3905
|
+
|
|
3906
|
+
it("query-notes include_broken_links surfaces a structured link's unresolved_link entry too", async () => {
|
|
3907
|
+
const created = await generateMcpTools(store).find((t) => t.name === "create-note")!.execute({
|
|
3908
|
+
content: "body",
|
|
3909
|
+
path: "mq-structured-broken",
|
|
3910
|
+
links: [{ target: "Does Not Exist", relationship: "depends-on" }],
|
|
3911
|
+
}) as any;
|
|
3912
|
+
const tools = generateMcpTools(store);
|
|
3913
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3914
|
+
const result = await query.execute({ id: created.id, include_broken_links: true }) as any;
|
|
3915
|
+
expect(result.broken_links).toEqual([{ target: "Does Not Exist", relationship: "depends-on" }]);
|
|
3916
|
+
});
|
|
3917
|
+
|
|
3918
|
+
it("query-notes include_broken_links is [] for a note with no dangling links", async () => {
|
|
3919
|
+
await store.createNote("clean", { id: "mq-clean-single", path: "mq-clean-single" });
|
|
3920
|
+
const tools = generateMcpTools(store);
|
|
3921
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3922
|
+
const result = await query.execute({ id: "mq-clean-single", include_broken_links: true }) as any;
|
|
3923
|
+
expect(result.broken_links).toEqual([]);
|
|
3924
|
+
});
|
|
3925
|
+
|
|
3926
|
+
it("query-notes include_broken_links works in list mode, batched across the page", async () => {
|
|
3927
|
+
await store.createNote("[[Ghost A]]", { id: "mq-list-a", path: "mq-list-a" });
|
|
3928
|
+
await store.createNote("[[Ghost B]]", { id: "mq-list-b", path: "mq-list-b" });
|
|
3929
|
+
await store.createNote("clean", { id: "mq-list-c", path: "mq-list-c" });
|
|
3930
|
+
|
|
3931
|
+
const tools = generateMcpTools(store);
|
|
3932
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3933
|
+
const result = await query.execute({ include_broken_links: true, include_content: true }) as any[];
|
|
3934
|
+
const byId = new Map(result.map((n: any) => [n.id, n.broken_links]));
|
|
3935
|
+
expect(byId.get("mq-list-a")).toEqual([{ target: "Ghost A", relationship: "wikilink" }]);
|
|
3936
|
+
expect(byId.get("mq-list-b")).toEqual([{ target: "Ghost B", relationship: "wikilink" }]);
|
|
3937
|
+
expect(byId.get("mq-list-c")).toEqual([]);
|
|
3938
|
+
});
|
|
3939
|
+
|
|
3379
3940
|
it("query-notes metadata operator query routes through the indexed column", async () => {
|
|
3380
3941
|
const { declareField } = await import("./indexed-fields.js");
|
|
3381
3942
|
declareField(db, "priority", "INTEGER", "project");
|
|
@@ -3788,10 +4349,205 @@ describe("MCP tools", async () => {
|
|
|
3788
4349
|
d: { type: "boolean" },
|
|
3789
4350
|
e: { type: "array" },
|
|
3790
4351
|
f: { type: "object" },
|
|
4352
|
+
g: { type: "reference" },
|
|
3791
4353
|
},
|
|
3792
4354
|
}) as any;
|
|
3793
4355
|
expect(result.fields.a.type).toBe("string");
|
|
3794
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
|
+
});
|
|
3795
4551
|
});
|
|
3796
4552
|
|
|
3797
4553
|
it("find-path works with ID/path resolution", async () => {
|
|
@@ -5188,6 +5944,401 @@ describe("update-note if_missing=create (vault#309)", async () => {
|
|
|
5188
5944
|
});
|
|
5189
5945
|
});
|
|
5190
5946
|
|
|
5947
|
+
// ---------------------------------------------------------------------------
|
|
5948
|
+
// create-note `if_exists` — idempotent upsert on a path conflict (vault#555)
|
|
5949
|
+
// ---------------------------------------------------------------------------
|
|
5950
|
+
|
|
5951
|
+
describe("create-note if_exists (vault#555)", async () => {
|
|
5952
|
+
let store: SqliteStore;
|
|
5953
|
+
beforeEach(() => {
|
|
5954
|
+
store = new SqliteStore(new Database(":memory:"));
|
|
5955
|
+
});
|
|
5956
|
+
|
|
5957
|
+
it("default (no if_exists) still throws path_conflict — back-compat", async () => {
|
|
5958
|
+
const tools = generateMcpTools(store);
|
|
5959
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5960
|
+
await create.execute({ content: "first", path: "Inbox/dup" });
|
|
5961
|
+
await expect(create.execute({ content: "second", path: "Inbox/dup" }))
|
|
5962
|
+
.rejects.toThrow(/path_conflict/);
|
|
5963
|
+
});
|
|
5964
|
+
|
|
5965
|
+
it('if_exists:"error" behaves identically to omitting it', async () => {
|
|
5966
|
+
const tools = generateMcpTools(store);
|
|
5967
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5968
|
+
await create.execute({ content: "first", path: "Inbox/dup-explicit" });
|
|
5969
|
+
await expect(create.execute({ content: "second", path: "Inbox/dup-explicit", if_exists: "error" }))
|
|
5970
|
+
.rejects.toThrow(/path_conflict/);
|
|
5971
|
+
});
|
|
5972
|
+
|
|
5973
|
+
it('if_exists:"ignore" returns the existing note untouched, no mutation', async () => {
|
|
5974
|
+
const tools = generateMcpTools(store);
|
|
5975
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
5976
|
+
const first = await create.execute({
|
|
5977
|
+
content: "original",
|
|
5978
|
+
path: "Inbox/ignore-me",
|
|
5979
|
+
tags: ["daily"],
|
|
5980
|
+
metadata: { v: 1 },
|
|
5981
|
+
}) as any;
|
|
5982
|
+
|
|
5983
|
+
const second = await create.execute({
|
|
5984
|
+
content: "attempted overwrite",
|
|
5985
|
+
path: "Inbox/ignore-me",
|
|
5986
|
+
tags: ["other"],
|
|
5987
|
+
metadata: { v: 2 },
|
|
5988
|
+
if_exists: "ignore",
|
|
5989
|
+
}) as any;
|
|
5990
|
+
|
|
5991
|
+
expect(second.existed).toBe(true);
|
|
5992
|
+
expect(second.id).toBe(first.id);
|
|
5993
|
+
expect(second.content).toBe("original"); // untouched
|
|
5994
|
+
expect(second.metadata?.v).toBe(1); // untouched
|
|
5995
|
+
expect(second.tags).toEqual(["daily"]); // "other" was never applied
|
|
5996
|
+
|
|
5997
|
+
// Confirm on disk too — no side effects at all.
|
|
5998
|
+
const onDisk = await store.getNoteByPath("Inbox/ignore-me");
|
|
5999
|
+
expect(onDisk!.content).toBe("original");
|
|
6000
|
+
expect(onDisk!.tags).toEqual(["daily"]);
|
|
6001
|
+
});
|
|
6002
|
+
|
|
6003
|
+
it('if_exists:"ignore" does not run schema-default backfill (genuinely zero mutation)', async () => {
|
|
6004
|
+
const tools = generateMcpTools(store);
|
|
6005
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6006
|
+
// Create the note BEFORE the schema declares a default, so the
|
|
6007
|
+
// existing row is missing the field the schema would now backfill.
|
|
6008
|
+
const first = await create.execute({ content: "x", path: "Inbox/predates-schema", tags: ["task"] }) as any;
|
|
6009
|
+
expect(first.metadata?.priority).toBeUndefined();
|
|
6010
|
+
|
|
6011
|
+
await store.upsertTagSchema("task", {
|
|
6012
|
+
fields: { priority: { type: "string", enum: ["high", "low"], default: "high" } },
|
|
6013
|
+
});
|
|
6014
|
+
|
|
6015
|
+
const second = await create.execute({
|
|
6016
|
+
content: "y",
|
|
6017
|
+
path: "Inbox/predates-schema",
|
|
6018
|
+
tags: ["task"],
|
|
6019
|
+
if_exists: "ignore",
|
|
6020
|
+
}) as any;
|
|
6021
|
+
expect(second.existed).toBe(true);
|
|
6022
|
+
expect(second.metadata?.priority).toBeUndefined(); // no backfill — zero mutation promise
|
|
6023
|
+
});
|
|
6024
|
+
|
|
6025
|
+
it('if_exists:"update" full-replaces content, RFC-7386-merges metadata, and unions tags/links', async () => {
|
|
6026
|
+
await store.createNote("target body", { id: "t-upd-target", path: "Targets/upd" });
|
|
6027
|
+
|
|
6028
|
+
const tools = generateMcpTools(store);
|
|
6029
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6030
|
+
const first = await create.execute({
|
|
6031
|
+
content: "v1",
|
|
6032
|
+
path: "Inbox/update-me",
|
|
6033
|
+
tags: ["daily"],
|
|
6034
|
+
metadata: { a: 1, b: 2 },
|
|
6035
|
+
}) as any;
|
|
6036
|
+
|
|
6037
|
+
const second = await create.execute({
|
|
6038
|
+
content: "v2",
|
|
6039
|
+
path: "Inbox/update-me",
|
|
6040
|
+
tags: ["extra"],
|
|
6041
|
+
metadata: { b: null, c: 3 }, // b deleted (RFC 7386), c added, a untouched
|
|
6042
|
+
links: [{ target: "t-upd-target", relationship: "relates-to" }],
|
|
6043
|
+
if_exists: "update",
|
|
6044
|
+
}) as any;
|
|
6045
|
+
|
|
6046
|
+
expect(second.existed).toBe(true);
|
|
6047
|
+
expect(second.id).toBe(first.id);
|
|
6048
|
+
expect(second.content).toBe("v2"); // full replace
|
|
6049
|
+
expect(second.metadata).toEqual({ a: 1, c: 3 }); // merged, b deleted
|
|
6050
|
+
expect(second.tags?.sort()).toEqual(["daily", "extra"]); // union, nothing removed
|
|
6051
|
+
|
|
6052
|
+
const links = await store.getLinks(second.id, { direction: "outbound" });
|
|
6053
|
+
expect(links.find((l) => l.relationship === "relates-to")?.targetId).toBe("t-upd-target");
|
|
6054
|
+
});
|
|
6055
|
+
|
|
6056
|
+
it('if_exists:"update" with an omitted field leaves it untouched', async () => {
|
|
6057
|
+
const tools = generateMcpTools(store);
|
|
6058
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6059
|
+
await create.execute({ content: "v1", path: "Inbox/partial-update", metadata: { keep: "me" } });
|
|
6060
|
+
|
|
6061
|
+
const second = await create.execute({
|
|
6062
|
+
path: "Inbox/partial-update", // no `content` — should stay "v1"
|
|
6063
|
+
metadata: { added: true },
|
|
6064
|
+
if_exists: "update",
|
|
6065
|
+
}) as any;
|
|
6066
|
+
expect(second.content).toBe("v1");
|
|
6067
|
+
expect(second.metadata).toEqual({ keep: "me", added: true });
|
|
6068
|
+
});
|
|
6069
|
+
|
|
6070
|
+
it('if_exists:"replace" wholesale-overwrites content + metadata but keeps id/createdAt and unions tags', async () => {
|
|
6071
|
+
const tools = generateMcpTools(store);
|
|
6072
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6073
|
+
const first = await create.execute({
|
|
6074
|
+
content: "v1",
|
|
6075
|
+
path: "Inbox/replace-me",
|
|
6076
|
+
tags: ["daily"],
|
|
6077
|
+
metadata: { a: 1, b: 2 },
|
|
6078
|
+
}) as any;
|
|
6079
|
+
|
|
6080
|
+
const second = await create.execute({
|
|
6081
|
+
content: "v2",
|
|
6082
|
+
path: "Inbox/replace-me",
|
|
6083
|
+
tags: ["extra"],
|
|
6084
|
+
metadata: { c: 3 }, // a and b are NOT merged in — wholesale replace
|
|
6085
|
+
if_exists: "replace",
|
|
6086
|
+
}) as any;
|
|
6087
|
+
|
|
6088
|
+
expect(second.existed).toBe(true);
|
|
6089
|
+
expect(second.id).toBe(first.id);
|
|
6090
|
+
expect(second.createdAt).toBe(first.createdAt);
|
|
6091
|
+
expect(second.content).toBe("v2");
|
|
6092
|
+
expect(second.metadata).toEqual({ c: 3 }); // a/b dropped, not merged
|
|
6093
|
+
expect(second.tags?.sort()).toEqual(["daily", "extra"]); // tags stay additive
|
|
6094
|
+
});
|
|
6095
|
+
|
|
6096
|
+
it('if_exists:"replace" with omitted content/metadata clears them to empty defaults', async () => {
|
|
6097
|
+
const tools = generateMcpTools(store);
|
|
6098
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6099
|
+
await create.execute({ content: "v1", path: "Inbox/replace-blank", metadata: { a: 1 } });
|
|
6100
|
+
|
|
6101
|
+
const second = await create.execute({
|
|
6102
|
+
path: "Inbox/replace-blank",
|
|
6103
|
+
if_exists: "replace",
|
|
6104
|
+
}) as any;
|
|
6105
|
+
expect(second.content).toBe("");
|
|
6106
|
+
// An empty `{}` metadata collapses to `undefined` on read — the same
|
|
6107
|
+
// convention every note with no metadata follows (rowToNote), not
|
|
6108
|
+
// something special about `replace`.
|
|
6109
|
+
expect(second.metadata).toBeUndefined();
|
|
6110
|
+
});
|
|
6111
|
+
|
|
6112
|
+
it("if_exists is a no-op without a path — a pathless create can never conflict", async () => {
|
|
6113
|
+
const tools = generateMcpTools(store);
|
|
6114
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6115
|
+
const a = await create.execute({ content: "one", if_exists: "ignore" }) as any;
|
|
6116
|
+
const b = await create.execute({ content: "two", if_exists: "ignore" }) as any;
|
|
6117
|
+
expect(a.id).not.toBe(b.id);
|
|
6118
|
+
// `if_exists` was still passed, so `existed` is attached (there was
|
|
6119
|
+
// simply nothing to conflict with) — `false`, not absent.
|
|
6120
|
+
expect(a.existed).toBe(false);
|
|
6121
|
+
expect(b.existed).toBe(false);
|
|
6122
|
+
});
|
|
6123
|
+
|
|
6124
|
+
it("plain create-note calls (no if_exists) see zero response-shape change", async () => {
|
|
6125
|
+
const tools = generateMcpTools(store);
|
|
6126
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6127
|
+
const result = await create.execute({ content: "plain" }) as any;
|
|
6128
|
+
expect("existed" in result).toBe(false);
|
|
6129
|
+
});
|
|
6130
|
+
|
|
6131
|
+
it("existed:false when if_exists is set but no conflict occurs (fresh insert)", async () => {
|
|
6132
|
+
const tools = generateMcpTools(store);
|
|
6133
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6134
|
+
const result = await create.execute({ content: "fresh", path: "Inbox/fresh-one", if_exists: "update" }) as any;
|
|
6135
|
+
expect(result.existed).toBe(false);
|
|
6136
|
+
});
|
|
6137
|
+
|
|
6138
|
+
it("respects a per-item extension when disambiguating the conflict (vault#328)", async () => {
|
|
6139
|
+
const tools = generateMcpTools(store);
|
|
6140
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6141
|
+
// Two notes at the same path, different extensions — legal per vault#328.
|
|
6142
|
+
await create.execute({ content: "csv body", path: "Reports/q1", extension: "csv" });
|
|
6143
|
+
await create.execute({ content: "md body", path: "Reports/q1", extension: "md" });
|
|
6144
|
+
|
|
6145
|
+
// ignore against the CSV one specifically.
|
|
6146
|
+
const result = await create.execute({
|
|
6147
|
+
content: "attempted",
|
|
6148
|
+
path: "Reports/q1",
|
|
6149
|
+
extension: "csv",
|
|
6150
|
+
if_exists: "ignore",
|
|
6151
|
+
}) as any;
|
|
6152
|
+
expect(result.existed).toBe(true);
|
|
6153
|
+
expect(result.content).toBe("csv body");
|
|
6154
|
+
expect(result.extension).toBe("csv");
|
|
6155
|
+
});
|
|
6156
|
+
|
|
6157
|
+
it("batch: if_exists is per-item, not inherited from a top-level default", async () => {
|
|
6158
|
+
const tools = generateMcpTools(store);
|
|
6159
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6160
|
+
await create.execute({ content: "existing", path: "Inbox/batch-conflict" });
|
|
6161
|
+
|
|
6162
|
+
// Top-level if_exists is NOT merged into batch items (matches
|
|
6163
|
+
// if_missing's contract on update-note) — the batch item below has no
|
|
6164
|
+
// if_exists of its own, so it hits the default "error" path and the
|
|
6165
|
+
// WHOLE batch rolls back, even though a top-level if_exists was passed.
|
|
6166
|
+
await expect(create.execute({
|
|
6167
|
+
if_exists: "ignore",
|
|
6168
|
+
notes: [{ content: "conflict", path: "Inbox/batch-conflict" }],
|
|
6169
|
+
})).rejects.toThrow(/path_conflict/);
|
|
6170
|
+
});
|
|
6171
|
+
|
|
6172
|
+
it("batch: mixes fresh creates and if_exists hits in the same call, in order", async () => {
|
|
6173
|
+
const tools = generateMcpTools(store);
|
|
6174
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6175
|
+
await create.execute({ content: "pre-existing", path: "Inbox/batch-b" });
|
|
6176
|
+
|
|
6177
|
+
const result = await create.execute({
|
|
6178
|
+
notes: [
|
|
6179
|
+
{ content: "a", path: "Inbox/batch-a", if_exists: "ignore" },
|
|
6180
|
+
{ content: "b-new", path: "Inbox/batch-b", if_exists: "ignore" },
|
|
6181
|
+
{ content: "c", path: "Inbox/batch-c", if_exists: "ignore" },
|
|
6182
|
+
],
|
|
6183
|
+
}) as any[];
|
|
6184
|
+
|
|
6185
|
+
expect(result).toHaveLength(3);
|
|
6186
|
+
expect(result[0].existed).toBe(false);
|
|
6187
|
+
expect(result[1].existed).toBe(true);
|
|
6188
|
+
expect(result[1].content).toBe("pre-existing"); // untouched
|
|
6189
|
+
expect(result[2].existed).toBe(false);
|
|
6190
|
+
});
|
|
6191
|
+
|
|
6192
|
+
it("a genuine schema violation on if_exists:update still rejects and mutates nothing", async () => {
|
|
6193
|
+
await store.upsertTagSchema("task", {
|
|
6194
|
+
fields: { status: { type: "string", enum: ["open", "done"], strict: true, required: true } },
|
|
6195
|
+
});
|
|
6196
|
+
const tools = generateMcpTools(store);
|
|
6197
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6198
|
+
await create.execute({ content: "v1", path: "Inbox/strict-target", tags: ["task"], metadata: { status: "open" } });
|
|
6199
|
+
|
|
6200
|
+
await expect(create.execute({
|
|
6201
|
+
content: "v2",
|
|
6202
|
+
path: "Inbox/strict-target",
|
|
6203
|
+
metadata: { status: "NOT-A-VALID-ENUM" },
|
|
6204
|
+
if_exists: "update",
|
|
6205
|
+
})).rejects.toThrow();
|
|
6206
|
+
|
|
6207
|
+
const onDisk = await store.getNoteByPath("Inbox/strict-target");
|
|
6208
|
+
expect(onDisk!.content).toBe("v1"); // untouched — rejected before any write
|
|
6209
|
+
});
|
|
6210
|
+
|
|
6211
|
+
it("if_exists:update re-validates against the CURRENT schema without re-supplying satisfied required fields", async () => {
|
|
6212
|
+
await store.upsertTagSchema("task", {
|
|
6213
|
+
fields: { status: { type: "string", enum: ["open", "done"], strict: true, required: true } },
|
|
6214
|
+
});
|
|
6215
|
+
const tools = generateMcpTools(store);
|
|
6216
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6217
|
+
await create.execute({ content: "v1", path: "Inbox/status-set", tags: ["task"], metadata: { status: "open" } });
|
|
6218
|
+
|
|
6219
|
+
// Only touching content — `status` is already satisfied by the
|
|
6220
|
+
// EXISTING note, so re-validating the merged/projected shape (not the
|
|
6221
|
+
// raw incoming item) must NOT demand it again.
|
|
6222
|
+
const result = await create.execute({
|
|
6223
|
+
content: "v2",
|
|
6224
|
+
path: "Inbox/status-set",
|
|
6225
|
+
if_exists: "update",
|
|
6226
|
+
}) as any;
|
|
6227
|
+
expect(result.content).toBe("v2");
|
|
6228
|
+
expect(result.metadata?.status).toBe("open");
|
|
6229
|
+
});
|
|
6230
|
+
|
|
6231
|
+
// vault#555 CRITICAL 2 (W8 fix-2 bug class): a tags-ONLY if_exists:"update"
|
|
6232
|
+
// (no content/metadata) must still bump updated_at — store.tagNote genuinely
|
|
6233
|
+
// mutates the note, and a frozen updated_at breaks cursor polling + sync.
|
|
6234
|
+
it('if_exists:"update" with ONLY tags added still advances updated_at', async () => {
|
|
6235
|
+
const tools = generateMcpTools(store);
|
|
6236
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6237
|
+
const first = await create.execute({ content: "body", path: "Inbox/tagonly", tags: ["alpha"] }) as any;
|
|
6238
|
+
const before = (await store.getNoteByPath("Inbox/tagonly"))!.updatedAt!;
|
|
6239
|
+
|
|
6240
|
+
// Ensure a strictly-later wall-clock so the bump is observable even if the
|
|
6241
|
+
// two writes land in the same millisecond.
|
|
6242
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
6243
|
+
|
|
6244
|
+
const second = await create.execute({
|
|
6245
|
+
path: "Inbox/tagonly",
|
|
6246
|
+
tags: ["beta"], // ONLY a tag change — no content, no metadata
|
|
6247
|
+
if_exists: "update",
|
|
6248
|
+
}) as any;
|
|
6249
|
+
expect(second.existed).toBe(true);
|
|
6250
|
+
expect(second.tags?.sort()).toEqual(["alpha", "beta"]); // tag actually added
|
|
6251
|
+
const after = (await store.getNoteByPath("Inbox/tagonly"))!.updatedAt!;
|
|
6252
|
+
expect(after > before).toBe(true); // updated_at advanced
|
|
6253
|
+
expect(second.id).toBe(first.id);
|
|
6254
|
+
});
|
|
6255
|
+
|
|
6256
|
+
it('if_exists:"update" with ONLY links added still advances updated_at', async () => {
|
|
6257
|
+
await store.createNote("target", { id: "tgt-linkonly", path: "Targets/linkonly" });
|
|
6258
|
+
const tools = generateMcpTools(store);
|
|
6259
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6260
|
+
await create.execute({ content: "body", path: "Inbox/linkonly", tags: ["alpha"] });
|
|
6261
|
+
const before = (await store.getNoteByPath("Inbox/linkonly"))!.updatedAt!;
|
|
6262
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
6263
|
+
|
|
6264
|
+
await create.execute({
|
|
6265
|
+
path: "Inbox/linkonly",
|
|
6266
|
+
links: [{ target: "tgt-linkonly", relationship: "relates-to" }],
|
|
6267
|
+
if_exists: "update",
|
|
6268
|
+
});
|
|
6269
|
+
const after = (await store.getNoteByPath("Inbox/linkonly"))!.updatedAt!;
|
|
6270
|
+
expect(after > before).toBe(true);
|
|
6271
|
+
});
|
|
6272
|
+
});
|
|
6273
|
+
|
|
6274
|
+
// ---------------------------------------------------------------------------
|
|
6275
|
+
// create-note `summary` — compact batch response shape (vault#555)
|
|
6276
|
+
// ---------------------------------------------------------------------------
|
|
6277
|
+
|
|
6278
|
+
describe("create-note summary (vault#555)", async () => {
|
|
6279
|
+
let store: SqliteStore;
|
|
6280
|
+
beforeEach(() => {
|
|
6281
|
+
store = new SqliteStore(new Database(":memory:"));
|
|
6282
|
+
});
|
|
6283
|
+
|
|
6284
|
+
it("summary:true returns {created, ids, failed} instead of N full note objects", async () => {
|
|
6285
|
+
const tools = generateMcpTools(store);
|
|
6286
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6287
|
+
const result = await create.execute({
|
|
6288
|
+
summary: true,
|
|
6289
|
+
notes: [
|
|
6290
|
+
{ content: "a", path: "Inbox/sum-a" },
|
|
6291
|
+
{ content: "b", path: "Inbox/sum-b" },
|
|
6292
|
+
{ content: "c", path: "Inbox/sum-c" },
|
|
6293
|
+
],
|
|
6294
|
+
}) as any;
|
|
6295
|
+
expect(result.created).toBe(3);
|
|
6296
|
+
expect(result.ids).toHaveLength(3);
|
|
6297
|
+
expect(result.failed).toEqual([]);
|
|
6298
|
+
// Not the full-object shape.
|
|
6299
|
+
expect(result.notes).toBeUndefined();
|
|
6300
|
+
expect(Array.isArray(result)).toBe(false);
|
|
6301
|
+
|
|
6302
|
+
// The ids really do resolve to the notes just created.
|
|
6303
|
+
const onDisk = await store.getNoteByPath("Inbox/sum-a");
|
|
6304
|
+
expect(result.ids).toContain(onDisk!.id);
|
|
6305
|
+
});
|
|
6306
|
+
|
|
6307
|
+
it("summary:true `created` excludes if_exists hits — only counts brand-new inserts", async () => {
|
|
6308
|
+
const tools = generateMcpTools(store);
|
|
6309
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6310
|
+
await create.execute({ content: "pre-existing", path: "Inbox/sum-existing" });
|
|
6311
|
+
|
|
6312
|
+
const result = await create.execute({
|
|
6313
|
+
summary: true,
|
|
6314
|
+
notes: [
|
|
6315
|
+
{ content: "fresh", path: "Inbox/sum-fresh", if_exists: "ignore" },
|
|
6316
|
+
{ content: "ignored", path: "Inbox/sum-existing", if_exists: "ignore" },
|
|
6317
|
+
],
|
|
6318
|
+
}) as any;
|
|
6319
|
+
expect(result.created).toBe(1); // only the fresh one
|
|
6320
|
+
expect(result.ids).toHaveLength(2); // both ids are still reported
|
|
6321
|
+
});
|
|
6322
|
+
|
|
6323
|
+
it("summary is ignored on a single-note (non-batch) call", async () => {
|
|
6324
|
+
const tools = generateMcpTools(store);
|
|
6325
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6326
|
+
const result = await create.execute({ content: "solo", summary: true }) as any;
|
|
6327
|
+
expect(result.content).toBe("solo"); // full note object, not a summary
|
|
6328
|
+
expect(result.created).toBeUndefined();
|
|
6329
|
+
});
|
|
6330
|
+
|
|
6331
|
+
it("without summary, batch still returns full note objects (back-compat)", async () => {
|
|
6332
|
+
const tools = generateMcpTools(store);
|
|
6333
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
6334
|
+
const result = await create.execute({
|
|
6335
|
+
notes: [{ content: "a", path: "Inbox/sum-nosummary" }],
|
|
6336
|
+
}) as any[];
|
|
6337
|
+
expect(Array.isArray(result)).toBe(true);
|
|
6338
|
+
expect(result[0].content).toBe("a");
|
|
6339
|
+
});
|
|
6340
|
+
});
|
|
6341
|
+
|
|
5191
6342
|
// ---------------------------------------------------------------------------
|
|
5192
6343
|
// Schema inheritance via parent_names + `_default` universal parent — vault#270
|
|
5193
6344
|
// ---------------------------------------------------------------------------
|