@openparachute/vault 0.7.0-rc.5 → 0.7.0-rc.7
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/attribution.test.ts +11 -3
- package/core/src/contract-typed-index.test.ts +228 -11
- package/core/src/core.test.ts +38 -8
- package/core/src/doctor.ts +43 -13
- package/core/src/mcp.ts +66 -26
- package/core/src/notes.ts +85 -20
- package/core/src/query-warnings.ts +110 -0
- package/core/src/schema-defaults.ts +48 -7
- package/core/src/schema.ts +314 -10
- package/core/src/search-fts-v25.test.ts +362 -0
- package/core/src/search-query.ts +0 -0
- package/core/src/seed-packs.ts +20 -13
- package/core/src/store.ts +18 -0
- package/core/src/tag-schemas.ts +121 -6
- package/core/src/types.ts +20 -0
- package/package.json +1 -1
- package/src/contract-search.test.ts +168 -0
- package/src/mcp-query-notes-search-scope.test.ts +53 -0
- package/src/onboarding-seed.test.ts +5 -2
- package/src/routes.ts +42 -53
- package/src/vault.test.ts +33 -27
|
@@ -316,6 +316,111 @@ describe("contract: search — escaping edge cases, tag-scope, warnings (#551)",
|
|
|
316
316
|
});
|
|
317
317
|
});
|
|
318
318
|
|
|
319
|
+
describe("contract: search — recall + ranking legibility (WS2B/C, #551, schema v25)", () => {
|
|
320
|
+
it("a note's TITLE (path) is now searchable — a term appearing ONLY in path, not content", async () => {
|
|
321
|
+
await store.createNote("nothing in this body matches the query term", {
|
|
322
|
+
path: "quarterly-budget-review",
|
|
323
|
+
});
|
|
324
|
+
const res = await search("search=budget&include_content=true");
|
|
325
|
+
expect(res.status).toBe(200);
|
|
326
|
+
const body = await bodyOf(res);
|
|
327
|
+
expect(body.some((n: any) => n.path === "quarterly-budget-review")).toBe(true);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("a title match outranks a passing body-only mention — legible via the score field", async () => {
|
|
331
|
+
await store.createNote("only a passing mention of espresso here, nothing more", {
|
|
332
|
+
path: "coffee-notes",
|
|
333
|
+
});
|
|
334
|
+
await store.createNote("everything about espresso — brewing, grinding, tasting", {
|
|
335
|
+
path: "espresso",
|
|
336
|
+
});
|
|
337
|
+
const res = await search("search=espresso&include_content=true");
|
|
338
|
+
expect(res.status).toBe(200);
|
|
339
|
+
const body = await bodyOf(res);
|
|
340
|
+
const titleMatchIdx = body.findIndex((n: any) => n.path === "espresso");
|
|
341
|
+
const bodyMatchIdx = body.findIndex((n: any) => n.path === "coffee-notes");
|
|
342
|
+
expect(titleMatchIdx).toBeGreaterThanOrEqual(0);
|
|
343
|
+
expect(bodyMatchIdx).toBeGreaterThanOrEqual(0);
|
|
344
|
+
expect(titleMatchIdx).toBeLessThan(bodyMatchIdx);
|
|
345
|
+
for (const n of body) expect(typeof n.score).toBe("number");
|
|
346
|
+
const scoreOf = (path: string) => body.find((n: any) => n.path === path).score;
|
|
347
|
+
expect(scoreOf("espresso")).toBeGreaterThan(scoreOf("coffee-notes"));
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("score is present on the LEAN (default, no include_content) shape too", async () => {
|
|
351
|
+
await store.createNote(NOTES.bothWords);
|
|
352
|
+
const res = await search("search=widgets");
|
|
353
|
+
expect(res.status).toBe(200);
|
|
354
|
+
const body = await bodyOf(res);
|
|
355
|
+
expect(body.length).toBeGreaterThan(0);
|
|
356
|
+
for (const n of body) expect(typeof n.score).toBe("number");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("porter stemming: singular query matches a plural-only body word", async () => {
|
|
360
|
+
await store.createNote("the firefighters responded quickly to the call");
|
|
361
|
+
const res = await search("search=firefighter&include_content=true");
|
|
362
|
+
expect(res.status).toBe(200);
|
|
363
|
+
const body = await bodyOf(res);
|
|
364
|
+
expect(body.some((n: any) => n.content.includes("firefighters"))).toBe(true);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("a zero-result search with a nearby indexed term surfaces a search_did_you_mean warning", async () => {
|
|
368
|
+
await store.createNote("a note that discusses Vasquez and the incident report");
|
|
369
|
+
const res = await search("search=Vasqez");
|
|
370
|
+
expect(res.status).toBe(200);
|
|
371
|
+
const body = await bodyOf(res);
|
|
372
|
+
expect(body).toEqual([]);
|
|
373
|
+
const warnings = decodeWarningsHeader(res);
|
|
374
|
+
expect(warnings).not.toBeNull();
|
|
375
|
+
const w = warnings!.find((x: any) => x.code === "search_did_you_mean");
|
|
376
|
+
expect(w).toBeDefined();
|
|
377
|
+
expect(w.did_you_mean).toBe("vasquez");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("a genuinely zero-result search with NO close vocabulary match carries no did_you_mean warning", async () => {
|
|
381
|
+
await store.createNote(NOTES.bothWords);
|
|
382
|
+
const res = await search("search=zzzznonexistentword");
|
|
383
|
+
expect(res.status).toBe(200);
|
|
384
|
+
const body = await bodyOf(res);
|
|
385
|
+
expect(body).toEqual([]);
|
|
386
|
+
const warnings = decodeWarningsHeader(res);
|
|
387
|
+
if (warnings) {
|
|
388
|
+
expect(warnings.some((w: any) => w.code === "search_did_you_mean")).toBe(false);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("did_you_mean is suppressed for a tag-scoped session (no leak of vault-wide vocabulary)", async () => {
|
|
393
|
+
// Out-of-scope note carries the near-match term; the scoped token's
|
|
394
|
+
// own allowlist ("health") sees nothing else. The did_you_mean
|
|
395
|
+
// suggestion is computed against the WHOLE vault's FTS5 vocabulary
|
|
396
|
+
// regardless of scope, so a scoped caller must never see it — same
|
|
397
|
+
// "no leak" stance as `unknown_tag`'s did_you_mean.
|
|
398
|
+
await store.createNote("a note that discusses Vasquez and the incident report", { tags: ["work"] });
|
|
399
|
+
const scopedReq = new Request(`${BASE}/notes?search=Vasqez`, { method: "GET" });
|
|
400
|
+
const res = await handleNotes(scopedReq, store, "", undefined, {
|
|
401
|
+
allowed: new Set(["health"]),
|
|
402
|
+
raw: ["health"],
|
|
403
|
+
});
|
|
404
|
+
expect(res.status).toBe(200);
|
|
405
|
+
const body = await bodyOf(res);
|
|
406
|
+
expect(body).toEqual([]);
|
|
407
|
+
const warnings = decodeWarningsHeader(res);
|
|
408
|
+
expect(warnings?.some((w: any) => w.code === "search_did_you_mean")).toBeFalsy();
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('search_mode:"advanced" — a lone leading hyphen gets a caller-actionable hint, not raw "no such column" text', async () => {
|
|
412
|
+
await store.createNote("espresso and coffee content");
|
|
413
|
+
const res = await search(`search=${encodeURIComponent("-espresso")}&search_mode=advanced`);
|
|
414
|
+
expect(res.status).toBe(400);
|
|
415
|
+
const body = await bodyOf(res);
|
|
416
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
417
|
+
expect(body.error_type).toBe("invalid_search_syntax");
|
|
418
|
+
expect(typeof body.hint).toBe("string");
|
|
419
|
+
expect(body.hint).toMatch(/BINARY operator/i);
|
|
420
|
+
expect(body.hint).not.toMatch(/^no such column/i);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
319
424
|
describe("contract: search — MCP parity (#551)", () => {
|
|
320
425
|
it("query-notes: literal-by-default finds punctuation content the same as REST", async () => {
|
|
321
426
|
const tools = generateMcpTools(store);
|
|
@@ -356,4 +461,67 @@ describe("contract: search — MCP parity (#551)", () => {
|
|
|
356
461
|
const desc = (await query.execute({ search: "mcpsortprobe", sort: "desc", include_content: true })) as any[];
|
|
357
462
|
expect(desc.map((n: any) => n.content)).toEqual(["mcpsortprobe beta", "mcpsortprobe alpha"]);
|
|
358
463
|
});
|
|
464
|
+
|
|
465
|
+
it("query-notes: title match outranks a body-only mention, and results carry a score (parity with REST)", async () => {
|
|
466
|
+
await store.createNote("only a passing mention of espresso here, nothing more", { path: "coffee-notes" });
|
|
467
|
+
await store.createNote("everything about espresso — brewing, grinding, tasting", { path: "espresso" });
|
|
468
|
+
const tools = generateMcpTools(store);
|
|
469
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
470
|
+
const result = (await query.execute({ search: "espresso" })) as any[];
|
|
471
|
+
const titleIdx = result.findIndex((n: any) => n.path === "espresso");
|
|
472
|
+
const bodyIdx = result.findIndex((n: any) => n.path === "coffee-notes");
|
|
473
|
+
expect(titleIdx).toBeGreaterThanOrEqual(0);
|
|
474
|
+
expect(bodyIdx).toBeGreaterThanOrEqual(0);
|
|
475
|
+
expect(titleIdx).toBeLessThan(bodyIdx);
|
|
476
|
+
for (const n of result) expect(typeof n.score).toBe("number");
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("query-notes: a note's title (path) is searchable (parity with REST)", async () => {
|
|
480
|
+
await store.createNote("nothing in this body matches the query term", { path: "quarterly-budget-review" });
|
|
481
|
+
const tools = generateMcpTools(store);
|
|
482
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
483
|
+
const result = (await query.execute({ search: "budget" })) as any[];
|
|
484
|
+
expect(result.some((n: any) => n.path === "quarterly-budget-review")).toBe(true);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("query-notes: porter stemming matches a plural-only body word (parity with REST)", async () => {
|
|
488
|
+
await store.createNote("the firefighters responded quickly to the call");
|
|
489
|
+
const tools = generateMcpTools(store);
|
|
490
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
491
|
+
const result = (await query.execute({ search: "firefighter", include_content: true })) as any[];
|
|
492
|
+
expect(result.some((n: any) => n.content.includes("firefighters"))).toBe(true);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("query-notes: zero-result search with a nearby indexed term surfaces search_did_you_mean", async () => {
|
|
496
|
+
await store.createNote("a note that discusses Vasquez and the incident report");
|
|
497
|
+
const tools = generateMcpTools(store);
|
|
498
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
499
|
+
const result = (await query.execute({ search: "Vasqez" })) as any;
|
|
500
|
+
// Zero notes + a non-empty warnings array — per the response-shape
|
|
501
|
+
// contract (vault#550) that's the `{notes, warnings}` envelope, not a
|
|
502
|
+
// bare array (a bare array can't carry `warnings` at all).
|
|
503
|
+
expect(Array.isArray(result)).toBe(false);
|
|
504
|
+
expect(result.notes).toEqual([]);
|
|
505
|
+
expect(result.warnings).toBeDefined();
|
|
506
|
+
const w = result.warnings.find((x: any) => x.code === "search_did_you_mean");
|
|
507
|
+
expect(w).toBeDefined();
|
|
508
|
+
expect(w.did_you_mean).toBe("vasquez");
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('query-notes: search_mode:"advanced" — a lone leading hyphen gets a caller-actionable hint, not raw "no such column" text', async () => {
|
|
512
|
+
await store.createNote("espresso and coffee content");
|
|
513
|
+
const tools = generateMcpTools(store);
|
|
514
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
515
|
+
let err: any;
|
|
516
|
+
try {
|
|
517
|
+
await query.execute({ search: "-espresso", search_mode: "advanced" });
|
|
518
|
+
} catch (e) {
|
|
519
|
+
err = e;
|
|
520
|
+
}
|
|
521
|
+
expect(err).toBeDefined();
|
|
522
|
+
expect(err.error_type).toBe("invalid_search_syntax");
|
|
523
|
+
expect(typeof err.hint).toBe("string");
|
|
524
|
+
expect(err.hint).toMatch(/BINARY operator/i);
|
|
525
|
+
expect(err.hint).not.toMatch(/^no such column/i);
|
|
526
|
+
});
|
|
359
527
|
});
|
|
@@ -134,3 +134,56 @@ describe("MCP query-notes search × tag-scope — combined enforcement (vault#55
|
|
|
134
134
|
}
|
|
135
135
|
});
|
|
136
136
|
});
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* NIT 1 (auth-561 review, #565): did_you_mean on a zero-result search is
|
|
140
|
+
* computed against the WHOLE vault's FTS5 vocabulary regardless of scope
|
|
141
|
+
* (`computeSearchDidYouMean` is scope-unaware by architecture, like every
|
|
142
|
+
* warning core produces). The MCP query-notes wrapper
|
|
143
|
+
* (`applyTagScopeWrappers`, src/mcp-tools.ts) strips the ENTIRE `warnings`
|
|
144
|
+
* array for a tag-scoped session, which covers `search_did_you_mean`
|
|
145
|
+
* generically — but the security property (a scoped token must not learn an
|
|
146
|
+
* out-of-scope note's vocabulary via a spelling suggestion) deserves a
|
|
147
|
+
* direct test, mirroring the REST test in src/contract-search.test.ts.
|
|
148
|
+
*/
|
|
149
|
+
function warningsOf(result: unknown): any[] {
|
|
150
|
+
if (result && typeof result === "object" && "warnings" in result && Array.isArray((result as any).warnings)) {
|
|
151
|
+
return (result as any).warnings;
|
|
152
|
+
}
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
describe("MCP query-notes did_you_mean × tag-scope suppression (#565 NIT 1)", () => {
|
|
157
|
+
test("a scoped session's zero-result search does NOT surface an out-of-scope term via search_did_you_mean", async () => {
|
|
158
|
+
seedVault("journal");
|
|
159
|
+
const store = getVaultStore("journal");
|
|
160
|
+
|
|
161
|
+
// The near-match term "Vasquez" lives ONLY in an out-of-scope note.
|
|
162
|
+
await store.createNote("a note that discusses Vasquez and the incident report", { tags: ["work"] });
|
|
163
|
+
|
|
164
|
+
const scopedTool = await queryNotesTool("journal", ["health"]);
|
|
165
|
+
const scopedResult = await scopedTool.execute({ search: "Vasqez" }); // typo → zero results
|
|
166
|
+
expect(idsOf(scopedResult)).toEqual([]);
|
|
167
|
+
// The wrapper strips warnings wholesale for a scoped session — no
|
|
168
|
+
// search_did_you_mean leaking the out-of-scope "vasquez".
|
|
169
|
+
expect(warningsOf(scopedResult).some((w: any) => w.code === "search_did_you_mean")).toBe(false);
|
|
170
|
+
// Belt-and-suspenders: the out-of-scope term must not appear ANYWHERE in
|
|
171
|
+
// the serialized response (no did_you_mean field slipping through under a
|
|
172
|
+
// different shape).
|
|
173
|
+
expect(JSON.stringify(scopedResult).toLowerCase()).not.toContain("vasquez");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("control: an UNSCOPED session's zero-result search DOES surface the suggestion (proves it's suppressed by scope, not simply absent)", async () => {
|
|
177
|
+
seedVault("journal");
|
|
178
|
+
const store = getVaultStore("journal");
|
|
179
|
+
|
|
180
|
+
await store.createNote("a note that discusses Vasquez and the incident report", { tags: ["work"] });
|
|
181
|
+
|
|
182
|
+
const unscopedTool = await queryNotesTool("journal", null);
|
|
183
|
+
const result = await unscopedTool.execute({ search: "Vasqez" });
|
|
184
|
+
expect(idsOf(result)).toEqual([]);
|
|
185
|
+
const w = warningsOf(result).find((x: any) => x.code === "search_did_you_mean");
|
|
186
|
+
expect(w).toBeDefined();
|
|
187
|
+
expect(w.did_you_mean).toBe("vasquez");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -166,8 +166,11 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
|
|
|
166
166
|
expect(gs!.content).toContain("## Write gotchas");
|
|
167
167
|
expect(gs!.content).toContain("if_updated_at"); // optimistic concurrency
|
|
168
168
|
expect(gs!.content).toContain("force: true");
|
|
169
|
-
// schema-default back-fill
|
|
170
|
-
expect(gs!.content).toContain("
|
|
169
|
+
// schema-default back-fill is explicit-`default`-only (vault#553 Decision B).
|
|
170
|
+
expect(gs!.content).toContain("explicit `default`");
|
|
171
|
+
expect(gs!.content).toContain("exists: false");
|
|
172
|
+
// indexed⇒strict type enforcement (vault#553 Decision A).
|
|
173
|
+
expect(gs!.content).toContain("field's TYPE is always enforced");
|
|
171
174
|
});
|
|
172
175
|
|
|
173
176
|
test("A3 (reshaped): the welcome ring resolves to real link edges; Getting Started has no dangling Surface Starter link", async () => {
|
package/src/routes.ts
CHANGED
|
@@ -13,7 +13,14 @@
|
|
|
13
13
|
|
|
14
14
|
import type { Store, Note, QueryOpts } from "../core/src/types.ts";
|
|
15
15
|
import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
collectUnknownTagWarnings,
|
|
18
|
+
emptySearchWarning,
|
|
19
|
+
ignoredParamWarning,
|
|
20
|
+
computeSearchDidYouMean,
|
|
21
|
+
searchDidYouMeanWarning,
|
|
22
|
+
type QueryWarning,
|
|
23
|
+
} from "../core/src/query-warnings.ts";
|
|
17
24
|
import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "../core/src/search-query.ts";
|
|
18
25
|
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
19
26
|
import { transactionAsync } from "../core/src/txn.ts";
|
|
@@ -24,7 +31,7 @@ import {
|
|
|
24
31
|
contentRangeRequiresContent,
|
|
25
32
|
type ContentRange,
|
|
26
33
|
} from "../core/src/content-range.ts";
|
|
27
|
-
import { attachValidationStatus, enforceStrictWrite } from "../core/src/mcp.ts";
|
|
34
|
+
import { attachValidationStatus, enforceStrictWrite, applySchemaDefaults } from "../core/src/mcp.ts";
|
|
28
35
|
import type { ValidationWarning } from "../core/src/schema-defaults.ts";
|
|
29
36
|
import { logStrictBypass } from "./scopes.ts";
|
|
30
37
|
import * as linkOps from "../core/src/links.ts";
|
|
@@ -48,7 +55,7 @@ import {
|
|
|
48
55
|
tagScopeForbidden,
|
|
49
56
|
tagsWithinScope,
|
|
50
57
|
} from "./tag-scope.ts";
|
|
51
|
-
import { ParentCycleError } from "../core/src/tag-schemas.ts";
|
|
58
|
+
import { ParentCycleError, InvalidFieldDefaultError } from "../core/src/tag-schemas.ts";
|
|
52
59
|
import { findTokensReferencingTag } from "./token-store.ts";
|
|
53
60
|
|
|
54
61
|
/**
|
|
@@ -1103,6 +1110,22 @@ async function handleNotesInner(
|
|
|
1103
1110
|
}
|
|
1104
1111
|
throw e;
|
|
1105
1112
|
}
|
|
1113
|
+
// Zero-result `did_you_mean` (vault#551 WS2B) — cheap (a bounded
|
|
1114
|
+
// FTS5-vocabulary scan) and ONLY computed on the already-rare
|
|
1115
|
+
// empty-result path, mirroring `unknown_tag`'s did_you_mean.
|
|
1116
|
+
// Gated on `tagScope.allowed === null` (unscoped) — same stance
|
|
1117
|
+
// as `collectUnknownTagWarnings` below: the FTS5 vocabulary spans
|
|
1118
|
+
// the whole vault regardless of scope, so surfacing a suggestion
|
|
1119
|
+
// to a scoped token would leak out-of-scope content's existence
|
|
1120
|
+
// across the scope boundary. Unlike the MCP path, REST enforces
|
|
1121
|
+
// tag-scope inline (no post-hoc wrapper to strip it for us), so
|
|
1122
|
+
// the gate has to live here explicitly.
|
|
1123
|
+
if (rawResults.length === 0 && tagScope.allowed === null) {
|
|
1124
|
+
const suggestion = computeSearchDidYouMean(db, search);
|
|
1125
|
+
if (suggestion) {
|
|
1126
|
+
searchWarnings.push(searchDidYouMeanWarning(search, suggestion));
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1106
1129
|
}
|
|
1107
1130
|
// Tag-scope: drop any result the token isn't permitted to see. Filter
|
|
1108
1131
|
// happens after the store query so an empty post-filter list still
|
|
@@ -2668,6 +2691,18 @@ export async function handleTags(
|
|
|
2668
2691
|
400,
|
|
2669
2692
|
);
|
|
2670
2693
|
}
|
|
2694
|
+
if (err instanceof InvalidFieldDefaultError) {
|
|
2695
|
+
// vault#553 Decision B — a declared `default` doesn't conform to its
|
|
2696
|
+
// own field's type/enum. Own-field error (no cross-tag data), so no
|
|
2697
|
+
// scrub needed. Mirrors IndexedFieldError's 400 shape/posture — see
|
|
2698
|
+
// store.upsertTagRecord's pre-validate comment for why REST hits
|
|
2699
|
+
// this (fail-fast, single violation) while MCP's update-tag usually
|
|
2700
|
+
// reports it bundled via `TagFieldConflictError` instead.
|
|
2701
|
+
return json(
|
|
2702
|
+
{ error: err.message, error_type: "invalid_field_default", field: err.field },
|
|
2703
|
+
400,
|
|
2704
|
+
);
|
|
2705
|
+
}
|
|
2671
2706
|
if (err instanceof ParentCycleError) {
|
|
2672
2707
|
// vault#552: parent_names would close a cycle. Nothing was
|
|
2673
2708
|
// persisted. Scope-scrub the cycle path for a tag-scoped caller —
|
|
@@ -3735,56 +3770,10 @@ export async function handleStorage(
|
|
|
3735
3770
|
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
3736
3771
|
}
|
|
3737
3772
|
|
|
3738
|
-
//
|
|
3739
|
-
//
|
|
3740
|
-
//
|
|
3741
|
-
|
|
3742
|
-
// Returns the IDs of notes whose metadata was actually default-filled, so
|
|
3743
|
-
// the caller can re-read ONLY the mutated notes (and skip the re-read when
|
|
3744
|
-
// nothing changed). Mirrors the core/src/mcp.ts contract.
|
|
3745
|
-
async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tags: string[]): Promise<string[]> {
|
|
3746
|
-
const schemas = tagSchemaOps.getTagSchemaMap(db);
|
|
3747
|
-
if (Object.keys(schemas).length === 0) return [];
|
|
3748
|
-
|
|
3749
|
-
const defaults: Record<string, unknown> = {};
|
|
3750
|
-
for (const tag of tags) {
|
|
3751
|
-
const schema = schemas[tag];
|
|
3752
|
-
if (!schema?.fields) continue;
|
|
3753
|
-
for (const [field, fieldSchema] of Object.entries(schema.fields)) {
|
|
3754
|
-
if (!(field in defaults)) {
|
|
3755
|
-
defaults[field] = defaultForField(fieldSchema);
|
|
3756
|
-
}
|
|
3757
|
-
}
|
|
3758
|
-
}
|
|
3759
|
-
if (Object.keys(defaults).length === 0) return [];
|
|
3760
|
-
|
|
3761
|
-
const mutated: string[] = [];
|
|
3762
|
-
for (const noteId of noteIds) {
|
|
3763
|
-
const note = await store.getNote(noteId);
|
|
3764
|
-
if (!note) continue;
|
|
3765
|
-
const existing = (note.metadata as Record<string, unknown>) ?? {};
|
|
3766
|
-
const missing: Record<string, unknown> = {};
|
|
3767
|
-
for (const [field, value] of Object.entries(defaults)) {
|
|
3768
|
-
if (!(field in existing)) missing[field] = value;
|
|
3769
|
-
}
|
|
3770
|
-
if (Object.keys(missing).length === 0) continue;
|
|
3771
|
-
await store.updateNote(noteId, {
|
|
3772
|
-
metadata: { ...existing, ...missing },
|
|
3773
|
-
skipUpdatedAt: true,
|
|
3774
|
-
});
|
|
3775
|
-
mutated.push(noteId);
|
|
3776
|
-
}
|
|
3777
|
-
return mutated;
|
|
3778
|
-
}
|
|
3779
|
-
|
|
3780
|
-
function defaultForField(field: { type: string; enum?: string[] }): unknown {
|
|
3781
|
-
if (field.enum && field.enum.length > 0) return field.enum[0];
|
|
3782
|
-
switch (field.type) {
|
|
3783
|
-
case "boolean": return false;
|
|
3784
|
-
case "integer": return 0;
|
|
3785
|
-
default: return "";
|
|
3786
|
-
}
|
|
3787
|
-
}
|
|
3773
|
+
// applySchemaDefaults lives in core/src/mcp.ts (vault#553) — REST used to
|
|
3774
|
+
// carry a byte-identical duplicate; importing it means REST and MCP can
|
|
3775
|
+
// never drift on this behavior again, and the cloud runtime (which imports
|
|
3776
|
+
// core directly) inherits it without any handler-side code.
|
|
3788
3777
|
|
|
3789
3778
|
function removeWikilinkBrackets(content: string, targetPath: string): string {
|
|
3790
3779
|
const escaped = targetPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
package/src/vault.test.ts
CHANGED
|
@@ -979,7 +979,7 @@ describe("scoped MCP wrapper", async () => {
|
|
|
979
979
|
closeAllStores();
|
|
980
980
|
});
|
|
981
981
|
|
|
982
|
-
test("create-note with schema tag
|
|
982
|
+
test("create-note with schema tag applies explicit defaults; undeclared fields stay absent (vault#553 Decision B)", async () => {
|
|
983
983
|
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
984
984
|
const { writeVaultConfig } = await import("./config.ts");
|
|
985
985
|
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
@@ -995,8 +995,8 @@ describe("scoped MCP wrapper", async () => {
|
|
|
995
995
|
await vaultStore.upsertTagSchema("person", {
|
|
996
996
|
description: "A person",
|
|
997
997
|
fields: {
|
|
998
|
-
first_appeared: { type: "string", description: "When" },
|
|
999
|
-
relationship: { type: "string", description: "How" },
|
|
998
|
+
first_appeared: { type: "string", description: "When", default: "unknown" },
|
|
999
|
+
relationship: { type: "string", description: "How" }, // no default — stays absent
|
|
1000
1000
|
},
|
|
1001
1001
|
});
|
|
1002
1002
|
|
|
@@ -1004,19 +1004,19 @@ describe("scoped MCP wrapper", async () => {
|
|
|
1004
1004
|
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
1005
1005
|
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
1006
1006
|
|
|
1007
|
-
// Create a note tagged person with no metadata —
|
|
1007
|
+
// Create a note tagged person with no metadata — the EXPLICIT default
|
|
1008
|
+
// auto-populates; the field with no `default` stays absent.
|
|
1008
1009
|
const result = await createNote.execute({
|
|
1009
1010
|
content: "Alice",
|
|
1010
1011
|
tags: ["person"],
|
|
1011
1012
|
}) as any;
|
|
1012
1013
|
expect(result.content).toBe("Alice");
|
|
1013
1014
|
|
|
1014
|
-
// Verify defaults were written
|
|
1015
1015
|
const fresh = await queryNotes.execute({ id: result.id }) as any;
|
|
1016
|
-
expect(fresh.metadata.first_appeared).toBe("");
|
|
1017
|
-
expect(fresh.metadata.relationship).
|
|
1016
|
+
expect(fresh.metadata.first_appeared).toBe("unknown");
|
|
1017
|
+
expect(fresh.metadata.relationship).toBeUndefined();
|
|
1018
1018
|
|
|
1019
|
-
// Create with explicit metadata — preserved
|
|
1019
|
+
// Create with explicit metadata — preserved (overrides the default).
|
|
1020
1020
|
const result2 = await createNote.execute({
|
|
1021
1021
|
content: "Bob",
|
|
1022
1022
|
tags: ["person"],
|
|
@@ -1029,7 +1029,7 @@ describe("scoped MCP wrapper", async () => {
|
|
|
1029
1029
|
closeAllStores();
|
|
1030
1030
|
});
|
|
1031
1031
|
|
|
1032
|
-
test("update-note tags.add with schema
|
|
1032
|
+
test("update-note tags.add with schema applies explicit defaults; undeclared fields stay absent (vault#553 Decision B)", async () => {
|
|
1033
1033
|
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1034
1034
|
const { writeVaultConfig } = await import("./config.ts");
|
|
1035
1035
|
const { getVaultStore, closeAllStores: close } = await import("./vault-store.ts");
|
|
@@ -1045,16 +1045,16 @@ describe("scoped MCP wrapper", async () => {
|
|
|
1045
1045
|
await vaultStore.upsertTagSchema("person", {
|
|
1046
1046
|
description: "A person",
|
|
1047
1047
|
fields: {
|
|
1048
|
-
first_appeared: { type: "string", description: "When" },
|
|
1049
|
-
relationship: { type: "string", description: "How" },
|
|
1048
|
+
first_appeared: { type: "string", description: "When", default: "unknown" },
|
|
1049
|
+
relationship: { type: "string", description: "How" }, // no default — stays absent
|
|
1050
1050
|
},
|
|
1051
1051
|
});
|
|
1052
1052
|
await vaultStore.upsertTagSchema("project", {
|
|
1053
1053
|
description: "A project",
|
|
1054
1054
|
fields: {
|
|
1055
|
-
status: { type: "string", enum: ["active", "completed", "abandoned"], description: "Status" },
|
|
1056
|
-
active: { type: "boolean", description: "Is active" },
|
|
1057
|
-
priority: { type: "integer", description: "Priority level" },
|
|
1055
|
+
status: { type: "string", enum: ["active", "completed", "abandoned"], description: "Status", default: "active" },
|
|
1056
|
+
active: { type: "boolean", description: "Is active", default: false },
|
|
1057
|
+
priority: { type: "integer", description: "Priority level" }, // no default — stays absent
|
|
1058
1058
|
},
|
|
1059
1059
|
});
|
|
1060
1060
|
const tools = generateScopedMcpTools(vaultName);
|
|
@@ -1066,10 +1066,11 @@ describe("scoped MCP wrapper", async () => {
|
|
|
1066
1066
|
const note = await createNote.execute({ content: "Alice" }) as any;
|
|
1067
1067
|
await updateNote.execute({ id: note.id, tags: { add: ["person"] }, force: true });
|
|
1068
1068
|
const after = await queryNotes.execute({ id: note.id }) as any;
|
|
1069
|
-
expect(after.metadata.first_appeared).toBe("");
|
|
1070
|
-
expect(after.metadata.relationship).
|
|
1069
|
+
expect(after.metadata.first_appeared).toBe("unknown");
|
|
1070
|
+
expect(after.metadata.relationship).toBeUndefined();
|
|
1071
1071
|
|
|
1072
|
-
// Tag note that already has partial metadata — only missing fields
|
|
1072
|
+
// Tag note that already has partial metadata — only missing fields WITH a
|
|
1073
|
+
// declared default get populated; the field with no default stays absent.
|
|
1073
1074
|
const note2 = await createNote.execute({
|
|
1074
1075
|
content: "Bob",
|
|
1075
1076
|
metadata: { first_appeared: "2023-11" },
|
|
@@ -1077,22 +1078,22 @@ describe("scoped MCP wrapper", async () => {
|
|
|
1077
1078
|
await updateNote.execute({ id: note2.id, tags: { add: ["person"] }, force: true });
|
|
1078
1079
|
const after2 = await queryNotes.execute({ id: note2.id }) as any;
|
|
1079
1080
|
expect(after2.metadata.first_appeared).toBe("2023-11"); // preserved
|
|
1080
|
-
expect(after2.metadata.relationship).
|
|
1081
|
+
expect(after2.metadata.relationship).toBeUndefined(); // no default — stays absent
|
|
1081
1082
|
|
|
1082
|
-
// Tag with #project —
|
|
1083
|
+
// Tag with #project — declared defaults land; `priority` (no default) stays absent.
|
|
1083
1084
|
const note4 = await createNote.execute({ content: "My Project" }) as any;
|
|
1084
1085
|
await updateNote.execute({ id: note4.id, tags: { add: ["project"] }, force: true });
|
|
1085
1086
|
const after4 = await queryNotes.execute({ id: note4.id }) as any;
|
|
1086
1087
|
expect(after4.metadata.status).toBe("active");
|
|
1087
1088
|
expect(after4.metadata.active).toBe(false);
|
|
1088
|
-
expect(after4.metadata.priority).
|
|
1089
|
+
expect(after4.metadata.priority).toBeUndefined();
|
|
1089
1090
|
|
|
1090
|
-
// Multiple schema tags at once — all defaults merged
|
|
1091
|
+
// Multiple schema tags at once — all EXPLICIT defaults merged.
|
|
1091
1092
|
const note5 = await createNote.execute({ content: "Multi" }) as any;
|
|
1092
1093
|
await updateNote.execute({ id: note5.id, tags: { add: ["person", "project"] }, force: true });
|
|
1093
1094
|
const after5 = await queryNotes.execute({ id: note5.id }) as any;
|
|
1094
|
-
expect(after5.metadata.first_appeared).toBe("");
|
|
1095
|
-
expect(after5.metadata.relationship).
|
|
1095
|
+
expect(after5.metadata.first_appeared).toBe("unknown");
|
|
1096
|
+
expect(after5.metadata.relationship).toBeUndefined();
|
|
1096
1097
|
expect(after5.metadata.status).toBe("active");
|
|
1097
1098
|
expect(after5.metadata.active).toBe(false);
|
|
1098
1099
|
|
|
@@ -1566,9 +1567,12 @@ describe("scoped MCP wrapper", async () => {
|
|
|
1566
1567
|
});
|
|
1567
1568
|
|
|
1568
1569
|
const vaultStore = getVaultStore(vaultName);
|
|
1570
|
+
// vault#553 Decision B: backfill is explicit-`default`-only — declare
|
|
1571
|
+
// one so this test still exercises an actual defaults-write (and can
|
|
1572
|
+
// assert it lands without bumping updatedAt).
|
|
1569
1573
|
await vaultStore.upsertTagSchema("person", {
|
|
1570
1574
|
description: "A person",
|
|
1571
|
-
fields: { name: { type: "string" } },
|
|
1575
|
+
fields: { name: { type: "string", default: "unknown" } },
|
|
1572
1576
|
});
|
|
1573
1577
|
|
|
1574
1578
|
const tools = generateScopedMcpTools(vaultName);
|
|
@@ -1581,7 +1585,7 @@ describe("scoped MCP wrapper", async () => {
|
|
|
1581
1585
|
await updateNote.execute({ id: note.id, tags: { add: ["person"] }, force: true });
|
|
1582
1586
|
const after = await queryNotes.execute({ id: note.id }) as any;
|
|
1583
1587
|
expect(after.updatedAt).toBe(originalUpdatedAt);
|
|
1584
|
-
expect(after.metadata.name).toBe("");
|
|
1588
|
+
expect(after.metadata.name).toBe("unknown");
|
|
1585
1589
|
|
|
1586
1590
|
close();
|
|
1587
1591
|
});
|
|
@@ -2199,8 +2203,10 @@ describe("HTTP /notes", async () => {
|
|
|
2199
2203
|
// mapped over the pre-defaults in-memory objects, so default-filled
|
|
2200
2204
|
// metadata was missing from `POST /api/notes` responses.
|
|
2201
2205
|
test("POST /notes response reflects post-applySchemaDefaults state (vault#316)", async () => {
|
|
2206
|
+
// vault#553 Decision B: backfill is explicit-`default`-only — declare one
|
|
2207
|
+
// so this test still exercises the post-defaults re-read mechanism.
|
|
2202
2208
|
await store.upsertTagSchema("task", {
|
|
2203
|
-
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
2209
|
+
fields: { priority: { type: "string", enum: ["high", "low"], default: "high" } },
|
|
2204
2210
|
});
|
|
2205
2211
|
|
|
2206
2212
|
// Single: default lands in the returned metadata and agrees with disk.
|
|
@@ -2211,7 +2217,7 @@ describe("HTTP /notes", async () => {
|
|
|
2211
2217
|
);
|
|
2212
2218
|
expect(single.status).toBe(201);
|
|
2213
2219
|
const singleBody = await single.json() as any;
|
|
2214
|
-
expect(singleBody.metadata?.priority).toBe("high"); //
|
|
2220
|
+
expect(singleBody.metadata?.priority).toBe("high"); // explicit schema default
|
|
2215
2221
|
const onDisk = await store.getNoteByPath("Inbox/task-1");
|
|
2216
2222
|
expect((onDisk!.metadata as any)?.priority).toBe("high");
|
|
2217
2223
|
|