@openparachute/vault 0.7.0-rc.6 → 0.7.0-rc.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/core.test.ts +413 -0
- package/core/src/mcp.ts +194 -30
- package/core/src/notes.ts +97 -24
- package/core/src/query-warnings.ts +110 -0
- package/core/src/schema.ts +168 -9
- package/core/src/search-fts-v25.test.ts +362 -0
- package/core/src/search-query.ts +0 -0
- package/core/src/store.ts +22 -15
- package/core/src/tag-schemas.ts +115 -19
- package/core/src/types.ts +20 -0
- package/core/src/wikilinks.test.ts +113 -1
- package/core/src/wikilinks.ts +186 -21
- package/package.json +1 -1
- package/src/contract-errors.test.ts +73 -0
- package/src/contract-search.test.ts +168 -0
- package/src/mcp-http.test.ts +140 -0
- package/src/mcp-http.ts +73 -16
- package/src/mcp-query-notes-search-scope.test.ts +53 -0
- package/src/mcp-tools.ts +11 -0
- package/src/routes.ts +192 -26
- package/src/tag-field-conflict-scope.test.ts +44 -0
- package/src/tag-scope.ts +60 -0
- package/src/vault.test.ts +291 -4
package/core/src/core.test.ts
CHANGED
|
@@ -597,6 +597,22 @@ describe("renameTag cascade (vault#240 + #247)", async () => {
|
|
|
597
597
|
expect(fresh!.content).toContain("#other"); // untouched
|
|
598
598
|
});
|
|
599
599
|
|
|
600
|
+
// vault#555 fix 2 — a rename cascade's inline content rewrite is a real
|
|
601
|
+
// persisted-state change; `updated_at` must move or a cursor/sync-poll
|
|
602
|
+
// loop never sees the rewritten note.
|
|
603
|
+
it("1b. content rewrite bumps updated_at (vault#555)", async () => {
|
|
604
|
+
const note = await store.createNote("Today's #task is important.", { tags: ["task"] });
|
|
605
|
+
const before = note.updatedAt;
|
|
606
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
607
|
+
|
|
608
|
+
await store.renameTag("task", "todo");
|
|
609
|
+
|
|
610
|
+
const fresh = await store.getNote(note.id);
|
|
611
|
+
expect(fresh!.content).toContain("#todo");
|
|
612
|
+
expect(fresh!.updatedAt).not.toBe(before);
|
|
613
|
+
expect(new Date(fresh!.updatedAt) > new Date(before)).toBe(true);
|
|
614
|
+
});
|
|
615
|
+
|
|
600
616
|
it("2. cascades sub-tags recursively (task → todo, task/work → todo/work, task/work/client → todo/work/client)", async () => {
|
|
601
617
|
await store.createNote("a", { tags: ["task"] });
|
|
602
618
|
await store.createNote("b", { tags: ["task/work"] });
|
|
@@ -2253,6 +2269,92 @@ describe("MCP tools", async () => {
|
|
|
2253
2269
|
expect(links.some((l) => l.relationship === "mentions")).toBe(true);
|
|
2254
2270
|
});
|
|
2255
2271
|
|
|
2272
|
+
// vault#555 — structured `links` used to resolve by exact path only (no
|
|
2273
|
+
// basename/title fallback, unlike [[wikilinks]]). "Bare Title" against a
|
|
2274
|
+
// note filed at "People/Alice" is a basename match, same as `[[Alice]]`.
|
|
2275
|
+
it("create-note with links resolves targets by BASENAME (title), same as [[wikilinks]]", async () => {
|
|
2276
|
+
const tools = generateMcpTools(store);
|
|
2277
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2278
|
+
await store.createNote("Target", { path: "People/Alice" });
|
|
2279
|
+
const result = await createNote.execute({
|
|
2280
|
+
content: "Links to Alice by bare title",
|
|
2281
|
+
links: [{ target: "Alice", relationship: "knows" }],
|
|
2282
|
+
}) as any;
|
|
2283
|
+
const links = await store.getLinks(result.id, { direction: "outbound" });
|
|
2284
|
+
expect(links).toHaveLength(1);
|
|
2285
|
+
expect(links[0]!.relationship).toBe("knows");
|
|
2286
|
+
});
|
|
2287
|
+
|
|
2288
|
+
// vault#555 — a link to a note created LATER in the same create-note
|
|
2289
|
+
// batch used to silently drop (item 0 resolved before item 1 existed).
|
|
2290
|
+
// The equivalent forward-ref via a [[wikilink]] already resolved; this
|
|
2291
|
+
// makes structured `links` match.
|
|
2292
|
+
it("create-note batch: a link to a note created LATER in the same batch resolves (forward-ref)", async () => {
|
|
2293
|
+
const tools = generateMcpTools(store);
|
|
2294
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2295
|
+
const result = await createNote.execute({
|
|
2296
|
+
notes: [
|
|
2297
|
+
{ path: "A", content: "links to B", links: [{ target: "B", relationship: "knows" }] },
|
|
2298
|
+
{ path: "B", content: "the target" },
|
|
2299
|
+
],
|
|
2300
|
+
}) as any[];
|
|
2301
|
+
const a = result.find((n: any) => n.path === "A");
|
|
2302
|
+
const links = await store.getLinks(a.id, { direction: "outbound" });
|
|
2303
|
+
expect(links).toHaveLength(1);
|
|
2304
|
+
expect(links[0]!.relationship).toBe("knows");
|
|
2305
|
+
// No warning — it resolved within the batch.
|
|
2306
|
+
expect(a.warnings).toBeUndefined();
|
|
2307
|
+
});
|
|
2308
|
+
|
|
2309
|
+
// vault#555 — a target that genuinely can't resolve now must surface an
|
|
2310
|
+
// `unresolved_link` warning (never silent), AND lazily backfill the edge
|
|
2311
|
+
// the moment a matching note is created later — mirroring the
|
|
2312
|
+
// `unresolved_wikilinks` forward-ref contract.
|
|
2313
|
+
it("create-note with an unresolvable link target: warns now, backfills the edge when the target is created later", async () => {
|
|
2314
|
+
const tools = generateMcpTools(store);
|
|
2315
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2316
|
+
const source = await createNote.execute({
|
|
2317
|
+
content: "links to a note that doesn't exist yet",
|
|
2318
|
+
links: [{ target: "Nonexistent Target", relationship: "knows" }],
|
|
2319
|
+
}) as any;
|
|
2320
|
+
|
|
2321
|
+
expect(source.warnings).toBeDefined();
|
|
2322
|
+
expect(source.warnings).toHaveLength(1);
|
|
2323
|
+
expect(source.warnings[0].code).toBe("unresolved_link");
|
|
2324
|
+
expect(source.warnings[0].target).toBe("Nonexistent Target");
|
|
2325
|
+
expect(source.warnings[0].relationship).toBe("knows");
|
|
2326
|
+
expect(await store.getLinks(source.id, { direction: "outbound" })).toHaveLength(0);
|
|
2327
|
+
|
|
2328
|
+
// Creating the target note later backfills the edge automatically.
|
|
2329
|
+
const target = await store.createNote("here now", { path: "Nonexistent Target" });
|
|
2330
|
+
const links = await store.getLinks(source.id, { direction: "outbound" });
|
|
2331
|
+
expect(links).toHaveLength(1);
|
|
2332
|
+
expect(links[0]!.targetId).toBe(target.id);
|
|
2333
|
+
expect(links[0]!.relationship).toBe("knows");
|
|
2334
|
+
});
|
|
2335
|
+
|
|
2336
|
+
// vault#555 — structured-link forward-refs (any relationship) must
|
|
2337
|
+
// coexist with pending [[wikilink]] forward-refs on the SAME note without
|
|
2338
|
+
// one clobbering the other when the note's content is re-synced.
|
|
2339
|
+
it("pending structured-link forward-ref survives a wikilink content re-sync on the same note", async () => {
|
|
2340
|
+
const tools = generateMcpTools(store);
|
|
2341
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2342
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2343
|
+
const source = await createNote.execute({
|
|
2344
|
+
content: "no wikilinks yet",
|
|
2345
|
+
links: [{ target: "Future Note", relationship: "knows" }],
|
|
2346
|
+
}) as any;
|
|
2347
|
+
expect(source.warnings?.[0]?.code).toBe("unresolved_link");
|
|
2348
|
+
|
|
2349
|
+
// Editing content (which re-syncs [[wikilink]]-kind unresolved entries)
|
|
2350
|
+
// must not wipe the pending structured-link entry.
|
|
2351
|
+
await updateNote.execute({ id: source.id, content: "now with a [[Some Other Unresolved Note]]", force: true });
|
|
2352
|
+
|
|
2353
|
+
const target = await store.createNote("arrived", { path: "Future Note" });
|
|
2354
|
+
const links = await store.getLinks(source.id, { direction: "outbound" });
|
|
2355
|
+
expect(links.some((l) => l.targetId === target.id && l.relationship === "knows")).toBe(true);
|
|
2356
|
+
});
|
|
2357
|
+
|
|
2256
2358
|
it("update-note tool updates created_at", async () => {
|
|
2257
2359
|
const note = await store.createNote("Test");
|
|
2258
2360
|
const tools = generateMcpTools(store);
|
|
@@ -2287,6 +2389,47 @@ describe("MCP tools", async () => {
|
|
|
2287
2389
|
expect((await store.getNote(note.id))!.tags).toContain("daily");
|
|
2288
2390
|
});
|
|
2289
2391
|
|
|
2392
|
+
// vault#555 fix 2 — a tag-only (or links-only) update-note call with
|
|
2393
|
+
// `force: true` (no `if_updated_at`) used to leave `updated_at` frozen:
|
|
2394
|
+
// the mcp.ts `updates` object had no core fields, so `store.updateNote`
|
|
2395
|
+
// was never even called. Breaks cursor polling (ORDER BY updated_at) and
|
|
2396
|
+
// any updated_at-based sync filter.
|
|
2397
|
+
it("update-note tags-only mutation with force:true bumps updated_at", async () => {
|
|
2398
|
+
const note = await store.createNote("Test");
|
|
2399
|
+
const before = note.updatedAt;
|
|
2400
|
+
const tools = generateMcpTools(store);
|
|
2401
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2402
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
2403
|
+
|
|
2404
|
+
await updateNote.execute({ id: note.id, tags: { add: ["pinned"] }, force: true });
|
|
2405
|
+
const after1 = (await store.getNote(note.id))!.updatedAt;
|
|
2406
|
+
expect(after1).not.toBe(before);
|
|
2407
|
+
expect(new Date(after1) > new Date(before)).toBe(true);
|
|
2408
|
+
|
|
2409
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
2410
|
+
await updateNote.execute({ id: note.id, tags: { remove: ["pinned"] }, force: true });
|
|
2411
|
+
const after2 = (await store.getNote(note.id))!.updatedAt;
|
|
2412
|
+
expect(new Date(after2) > new Date(after1)).toBe(true);
|
|
2413
|
+
});
|
|
2414
|
+
|
|
2415
|
+
it("update-note links-only mutation with force:true bumps updated_at", async () => {
|
|
2416
|
+
await store.createNote("A", { id: "a" });
|
|
2417
|
+
await store.createNote("B", { id: "b" });
|
|
2418
|
+
const before = (await store.getNote("a"))!.updatedAt;
|
|
2419
|
+
const tools = generateMcpTools(store);
|
|
2420
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2421
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
2422
|
+
|
|
2423
|
+
await updateNote.execute({ id: "a", links: { add: [{ target: "b", relationship: "mentions" }] }, force: true });
|
|
2424
|
+
const after1 = (await store.getNote("a"))!.updatedAt;
|
|
2425
|
+
expect(new Date(after1) > new Date(before)).toBe(true);
|
|
2426
|
+
|
|
2427
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
2428
|
+
await updateNote.execute({ id: "a", links: { remove: [{ target: "b", relationship: "mentions" }] }, force: true });
|
|
2429
|
+
const after2 = (await store.getNote("a"))!.updatedAt;
|
|
2430
|
+
expect(new Date(after2) > new Date(after1)).toBe(true);
|
|
2431
|
+
});
|
|
2432
|
+
|
|
2290
2433
|
it("update-note links add/remove works", async () => {
|
|
2291
2434
|
await store.createNote("A", { id: "a" });
|
|
2292
2435
|
await store.createNote("B", { id: "b" });
|
|
@@ -2302,6 +2445,38 @@ describe("MCP tools", async () => {
|
|
|
2302
2445
|
expect(await store.getLinks("a", { direction: "outbound" })).toHaveLength(0);
|
|
2303
2446
|
});
|
|
2304
2447
|
|
|
2448
|
+
// vault#555 — update-note's links.add gets the same basename/title
|
|
2449
|
+
// resolution + lazy forward-ref queueing as create-note's.
|
|
2450
|
+
it("update-note links.add resolves by BASENAME and warns+queues when unresolvable", async () => {
|
|
2451
|
+
await store.createNote("A", { id: "a" });
|
|
2452
|
+
await store.createNote("Target", { path: "People/Bob" });
|
|
2453
|
+
const tools = generateMcpTools(store);
|
|
2454
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2455
|
+
|
|
2456
|
+
// Basename match
|
|
2457
|
+
const byTitle = await updateNote.execute({
|
|
2458
|
+
id: "a",
|
|
2459
|
+
links: { add: [{ target: "Bob", relationship: "knows" }] },
|
|
2460
|
+
force: true,
|
|
2461
|
+
}) as any;
|
|
2462
|
+
expect(byTitle.warnings).toBeUndefined();
|
|
2463
|
+
expect(await store.getLinks("a", { direction: "outbound" })).toHaveLength(1);
|
|
2464
|
+
|
|
2465
|
+
// Unresolvable target: warns, queues, backfills later.
|
|
2466
|
+
const unresolved = await updateNote.execute({
|
|
2467
|
+
id: "a",
|
|
2468
|
+
links: { add: [{ target: "Not Yet Real", relationship: "wants" }] },
|
|
2469
|
+
force: true,
|
|
2470
|
+
}) as any;
|
|
2471
|
+
expect(unresolved.warnings).toBeDefined();
|
|
2472
|
+
expect(unresolved.warnings[0].code).toBe("unresolved_link");
|
|
2473
|
+
expect(unresolved.warnings[0].target).toBe("Not Yet Real");
|
|
2474
|
+
|
|
2475
|
+
const target = await store.createNote("arrived", { path: "Not Yet Real" });
|
|
2476
|
+
const links = await store.getLinks("a", { direction: "outbound" });
|
|
2477
|
+
expect(links.some((l) => l.targetId === target.id && l.relationship === "wants")).toBe(true);
|
|
2478
|
+
});
|
|
2479
|
+
|
|
2305
2480
|
// vault feedback #8 — echo hydrated links on the update response when the
|
|
2306
2481
|
// request mutated links OR `include_links` is set, so callers don't re-query.
|
|
2307
2482
|
it("update-note echoes hydrated links when the update mutates links", async () => {
|
|
@@ -3030,6 +3205,126 @@ describe("MCP tools", async () => {
|
|
|
3030
3205
|
expect(err?.code).toBe("CONFLICT");
|
|
3031
3206
|
});
|
|
3032
3207
|
|
|
3208
|
+
// vault#555 fix 7 (INVESTIGATE) — a tester reported update-note batch
|
|
3209
|
+
// AND parallel singles failing 7/8 with "old_text not found" when
|
|
3210
|
+
// multiple items share the SAME content_edit.old_text, though each
|
|
3211
|
+
// succeeded standalone. The orchestrator could NOT reproduce live (repro
|
|
3212
|
+
// hit shell artifacts). Investigated here with proper in-memory tests:
|
|
3213
|
+
//
|
|
3214
|
+
// - Batch AND parallel-singles, N items EACH TARGETING A DIFFERENT
|
|
3215
|
+
// note, all sharing the same old_text: every item succeeds. Each
|
|
3216
|
+
// item resolves its OWN note's content fresh (there's no shared
|
|
3217
|
+
// mutable state across items/calls — `resolveNote` reads straight
|
|
3218
|
+
// off the DB per item, and the content_edit search+replace is pure
|
|
3219
|
+
// synchronous JS with no `await` between the read and the write, so
|
|
3220
|
+
// two "parallel" async calls can't interleave mid-computation
|
|
3221
|
+
// either). Re-run 20x below with zero failures.
|
|
3222
|
+
// - The one scenario that DOES reproduce "1 succeeds, 7 fail
|
|
3223
|
+
// content_edit_not_found" is N calls sharing the same old_text
|
|
3224
|
+
// against the SAME note — and that's CORRECT behavior (the first
|
|
3225
|
+
// call consumes the only occurrence; the rest correctly report it's
|
|
3226
|
+
// gone), not a bug. This is the most plausible explanation for what
|
|
3227
|
+
// was actually observed: a test harness that accidentally pointed
|
|
3228
|
+
// every call at one note (a variable/loop bug — "shell artifacts")
|
|
3229
|
+
// rather than N distinct notes.
|
|
3230
|
+
//
|
|
3231
|
+
// VERDICT: not reproducible as a real bug against different notes. No
|
|
3232
|
+
// fix applied — these tests document the correct behavior.
|
|
3233
|
+
it("update-note BATCH: N items with the SAME content_edit.old_text on DIFFERENT notes all succeed", async () => {
|
|
3234
|
+
const tools = generateMcpTools(store);
|
|
3235
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
3236
|
+
const N = 8;
|
|
3237
|
+
const notes = [];
|
|
3238
|
+
for (let i = 0; i < N; i++) {
|
|
3239
|
+
notes.push(await store.createNote(`prefix TARGET_TEXT suffix-${i}`, { path: `batch-ce-${i}` }));
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
const result = await updateNote.execute({
|
|
3243
|
+
notes: notes.map((n) => ({
|
|
3244
|
+
id: n.id,
|
|
3245
|
+
content_edit: { old_text: "TARGET_TEXT", new_text: "REPLACED" },
|
|
3246
|
+
force: true,
|
|
3247
|
+
})),
|
|
3248
|
+
}) as any[];
|
|
3249
|
+
|
|
3250
|
+
expect(result).toHaveLength(N);
|
|
3251
|
+
for (let i = 0; i < N; i++) {
|
|
3252
|
+
expect(result[i].content).toBe(`prefix REPLACED suffix-${i}`);
|
|
3253
|
+
}
|
|
3254
|
+
});
|
|
3255
|
+
|
|
3256
|
+
it("update-note PARALLEL SINGLES: N concurrent calls with the SAME content_edit.old_text on DIFFERENT notes all succeed", async () => {
|
|
3257
|
+
const tools = generateMcpTools(store);
|
|
3258
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
3259
|
+
const N = 8;
|
|
3260
|
+
const notes = [];
|
|
3261
|
+
for (let i = 0; i < N; i++) {
|
|
3262
|
+
notes.push(await store.createNote(`prefix TARGET_TEXT suffix-${i}`, { path: `parallel-ce-${i}` }));
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
const results = await Promise.all(
|
|
3266
|
+
notes.map((n) =>
|
|
3267
|
+
updateNote.execute({
|
|
3268
|
+
id: n.id,
|
|
3269
|
+
content_edit: { old_text: "TARGET_TEXT", new_text: "REPLACED" },
|
|
3270
|
+
force: true,
|
|
3271
|
+
}),
|
|
3272
|
+
),
|
|
3273
|
+
) as any[];
|
|
3274
|
+
|
|
3275
|
+
for (let i = 0; i < N; i++) {
|
|
3276
|
+
expect(results[i].content).toBe(`prefix REPLACED suffix-${i}`);
|
|
3277
|
+
}
|
|
3278
|
+
});
|
|
3279
|
+
|
|
3280
|
+
it("update-note PARALLEL SINGLES with shared old_text: 20 repeated trials, zero failures (flake hunt)", async () => {
|
|
3281
|
+
for (let trial = 0; trial < 20; trial++) {
|
|
3282
|
+
const trialDb = new Database(":memory:");
|
|
3283
|
+
const trialStore = new SqliteStore(trialDb);
|
|
3284
|
+
const tools = generateMcpTools(trialStore);
|
|
3285
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
3286
|
+
const N = 8;
|
|
3287
|
+
const notes = [];
|
|
3288
|
+
for (let i = 0; i < N; i++) {
|
|
3289
|
+
notes.push(await trialStore.createNote(`prefix TARGET_TEXT suffix-${i}`, { path: `flake-${i}` }));
|
|
3290
|
+
}
|
|
3291
|
+
const outcomes = await Promise.allSettled(
|
|
3292
|
+
notes.map((n) =>
|
|
3293
|
+
updateNote.execute({
|
|
3294
|
+
id: n.id,
|
|
3295
|
+
content_edit: { old_text: "TARGET_TEXT", new_text: "REPLACED" },
|
|
3296
|
+
force: true,
|
|
3297
|
+
}),
|
|
3298
|
+
),
|
|
3299
|
+
);
|
|
3300
|
+
const failures = outcomes.filter((o) => o.status === "rejected");
|
|
3301
|
+
expect(failures.length).toBe(0);
|
|
3302
|
+
}
|
|
3303
|
+
});
|
|
3304
|
+
|
|
3305
|
+
it("update-note with shared old_text against the SAME note: 1 succeeds, 7 correctly fail (documents the LIKELY source of the original report — not a bug)", async () => {
|
|
3306
|
+
const tools = generateMcpTools(store);
|
|
3307
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
3308
|
+
const note = await store.createNote("prefix TARGET_TEXT suffix", { path: "shared-ce" });
|
|
3309
|
+
|
|
3310
|
+
const outcomes = await Promise.allSettled(
|
|
3311
|
+
Array.from({ length: 8 }, () =>
|
|
3312
|
+
updateNote.execute({
|
|
3313
|
+
id: note.id,
|
|
3314
|
+
content_edit: { old_text: "TARGET_TEXT", new_text: "REPLACED" },
|
|
3315
|
+
force: true,
|
|
3316
|
+
}),
|
|
3317
|
+
),
|
|
3318
|
+
);
|
|
3319
|
+
const succeeded = outcomes.filter((o) => o.status === "fulfilled").length;
|
|
3320
|
+
const failed = outcomes.filter((o) => o.status === "rejected");
|
|
3321
|
+
expect(succeeded).toBe(1);
|
|
3322
|
+
expect(failed).toHaveLength(7);
|
|
3323
|
+
for (const f of failed) {
|
|
3324
|
+
expect((f as PromiseRejectedResult).reason.error_type).toBe("content_edit_not_found");
|
|
3325
|
+
}
|
|
3326
|
+
});
|
|
3327
|
+
|
|
3033
3328
|
it("query-notes single note by id", async () => {
|
|
3034
3329
|
const note = await store.createNote("Hello", { path: "test/note" });
|
|
3035
3330
|
const tools = generateMcpTools(store);
|
|
@@ -3433,6 +3728,72 @@ describe("MCP tools", async () => {
|
|
|
3433
3728
|
expect(result.fields.age.type).toBe("integer");
|
|
3434
3729
|
});
|
|
3435
3730
|
|
|
3731
|
+
// vault#555 fix 4 — a non-indexed field's `type` was never validated
|
|
3732
|
+
// anywhere; a genuinely unrecognized type string was accepted and
|
|
3733
|
+
// persisted verbatim, no error.
|
|
3734
|
+
it("update-tag rejects an unrecognized field type (invalid_type)", async () => {
|
|
3735
|
+
const tools = generateMcpTools(store);
|
|
3736
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
3737
|
+
let caught: any;
|
|
3738
|
+
try {
|
|
3739
|
+
await updateTag.execute({ tag: "widget", fields: { weird: { type: "frobnicator" } } });
|
|
3740
|
+
} catch (e) {
|
|
3741
|
+
caught = e;
|
|
3742
|
+
}
|
|
3743
|
+
expect(caught).toBeDefined();
|
|
3744
|
+
expect(caught.error_type).toBe("tag_field_conflict");
|
|
3745
|
+
expect(caught.violations).toHaveLength(1);
|
|
3746
|
+
expect(caught.violations[0].field).toBe("weird");
|
|
3747
|
+
expect(caught.violations[0].reason).toBe("invalid_type");
|
|
3748
|
+
|
|
3749
|
+
// Nothing persisted.
|
|
3750
|
+
const record = await store.getTagRecord("widget");
|
|
3751
|
+
expect(record?.fields ?? null).toBeFalsy();
|
|
3752
|
+
});
|
|
3753
|
+
|
|
3754
|
+
// vault#555 fix 5 — one call declaring TWO bad fields (an unrecognized
|
|
3755
|
+
// type AND a non-conforming default) reports BOTH violations together,
|
|
3756
|
+
// not just the first one encountered.
|
|
3757
|
+
it("update-tag reports an unrecognized type AND a bad default together in one call", async () => {
|
|
3758
|
+
const tools = generateMcpTools(store);
|
|
3759
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
3760
|
+
let caught: any;
|
|
3761
|
+
try {
|
|
3762
|
+
await updateTag.execute({
|
|
3763
|
+
tag: "widget",
|
|
3764
|
+
fields: {
|
|
3765
|
+
weird: { type: "frobnicator" },
|
|
3766
|
+
bad_default: { type: "string", enum: ["a", "b"], default: "zzz" },
|
|
3767
|
+
},
|
|
3768
|
+
});
|
|
3769
|
+
} catch (e) {
|
|
3770
|
+
caught = e;
|
|
3771
|
+
}
|
|
3772
|
+
expect(caught).toBeDefined();
|
|
3773
|
+
expect(caught.violations).toHaveLength(2);
|
|
3774
|
+
const byField = new Map(caught.violations.map((v: any) => [v.field, v.reason]));
|
|
3775
|
+
expect(byField.get("weird")).toBe("invalid_type");
|
|
3776
|
+
expect(byField.get("bad_default")).toBe("invalid_default");
|
|
3777
|
+
});
|
|
3778
|
+
|
|
3779
|
+
it("update-tag accepts every recognized field type", async () => {
|
|
3780
|
+
const tools = generateMcpTools(store);
|
|
3781
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
3782
|
+
const result = await updateTag.execute({
|
|
3783
|
+
tag: "widget",
|
|
3784
|
+
fields: {
|
|
3785
|
+
a: { type: "string" },
|
|
3786
|
+
b: { type: "number" },
|
|
3787
|
+
c: { type: "integer" },
|
|
3788
|
+
d: { type: "boolean" },
|
|
3789
|
+
e: { type: "array" },
|
|
3790
|
+
f: { type: "object" },
|
|
3791
|
+
},
|
|
3792
|
+
}) as any;
|
|
3793
|
+
expect(result.fields.a.type).toBe("string");
|
|
3794
|
+
expect(result.fields.f.type).toBe("object");
|
|
3795
|
+
});
|
|
3796
|
+
|
|
3436
3797
|
it("find-path works with ID/path resolution", async () => {
|
|
3437
3798
|
await store.createNote("A", { id: "a", path: "People/Alice" });
|
|
3438
3799
|
await store.createNote("B", { id: "b" });
|
|
@@ -4343,6 +4704,58 @@ describe("schema validation (tags.fields)", async () => {
|
|
|
4343
4704
|
expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
|
|
4344
4705
|
});
|
|
4345
4706
|
|
|
4707
|
+
// vault#555 fix 3 — an enum-membership violation on an INDEXED
|
|
4708
|
+
// (non-strict) field is stored, findable via `eq` (indexed⇒strict is
|
|
4709
|
+
// TYPE-only, not enum-domain — a ratified policy, not a bug), but the
|
|
4710
|
+
// advisory `enum_mismatch` warning used to be visible ONLY on the
|
|
4711
|
+
// one-time create/update response — a caller reading the note back via
|
|
4712
|
+
// query-notes (the natural way to re-check a stored value) saw nothing,
|
|
4713
|
+
// contradicting "advisory violations surface as warnings."
|
|
4714
|
+
it("query-notes surfaces validation_status (enum_mismatch on an indexed field) — single-id fetch", async () => {
|
|
4715
|
+
const tools = generateMcpTools(store);
|
|
4716
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
4717
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
4718
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4719
|
+
await updateTag.execute({
|
|
4720
|
+
tag: "widget",
|
|
4721
|
+
fields: { status: { type: "string", enum: ["a", "b"], indexed: true } },
|
|
4722
|
+
});
|
|
4723
|
+
const created = await create.execute({ content: "x", tags: ["widget"], metadata: { status: "bogus" } }) as any;
|
|
4724
|
+
|
|
4725
|
+
// Findable via eq — indexed⇒strict is TYPE-only, enum stays advisory.
|
|
4726
|
+
const found = await query.execute({ metadata: { status: { eq: "bogus" } }, include_content: true }) as any[];
|
|
4727
|
+
expect(found).toHaveLength(1);
|
|
4728
|
+
expect(found[0].validation_status?.warnings?.[0]?.reason).toBe("enum_mismatch");
|
|
4729
|
+
|
|
4730
|
+
// Single-id fetch also carries it.
|
|
4731
|
+
const single = await query.execute({ id: created.id }) as any;
|
|
4732
|
+
expect(single.validation_status.warnings[0].reason).toBe("enum_mismatch");
|
|
4733
|
+
expect(single.validation_status.warnings[0].field).toBe("status");
|
|
4734
|
+
});
|
|
4735
|
+
|
|
4736
|
+
it("query-notes surfaces validation_status on the default list path (lean NoteIndex shape)", async () => {
|
|
4737
|
+
await store.upsertTagSchema("task", {
|
|
4738
|
+
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
4739
|
+
});
|
|
4740
|
+
const tools = generateMcpTools(store);
|
|
4741
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
4742
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4743
|
+
await create.execute({ content: "x", tags: ["task"], metadata: { priority: "ULTRA" } });
|
|
4744
|
+
|
|
4745
|
+
// Default list mode (include_content omitted → lean NoteIndex).
|
|
4746
|
+
const list = await query.execute({ tag: "task" }) as any[];
|
|
4747
|
+
expect(list).toHaveLength(1);
|
|
4748
|
+
expect(list[0].validation_status?.warnings?.[0]?.reason).toBe("enum_mismatch");
|
|
4749
|
+
});
|
|
4750
|
+
|
|
4751
|
+
it("query-notes omits validation_status entirely when no tag declares fields (no behavior change)", async () => {
|
|
4752
|
+
await store.createNote("plain note", { tags: ["untagged-schema"] });
|
|
4753
|
+
const tools = generateMcpTools(store);
|
|
4754
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
4755
|
+
const list = await query.execute({ tag: "untagged-schema" }) as any[];
|
|
4756
|
+
expect(list[0].validation_status).toBeUndefined();
|
|
4757
|
+
});
|
|
4758
|
+
|
|
4346
4759
|
it("validation never blocks the write — note exists with warnings attached", async () => {
|
|
4347
4760
|
await store.upsertTagSchema("task", {
|
|
4348
4761
|
fields: { priority: { type: "boolean" } },
|