@openparachute/vault 0.7.2-rc.7 → 0.7.3-rc.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/core.test.ts +86 -7
- package/core/src/mcp.ts +24 -0
- package/core/src/notes.ts +6 -1
- package/core/src/query-warnings.ts +20 -0
- package/core/src/seed-packs.ts +19 -4
- package/core/src/types.ts +8 -0
- package/package.json +1 -1
- package/src/auth.ts +8 -2
- package/src/contract-honest-queries.test.ts +88 -0
- package/src/contract-search.test.ts +41 -0
- package/src/routes.ts +27 -0
- package/src/scribe-discovery.test.ts +76 -1
- package/src/scribe-discovery.ts +28 -9
- package/src/vault.test.ts +27 -2
package/core/src/core.test.ts
CHANGED
|
@@ -3910,6 +3910,78 @@ describe("MCP tools", async () => {
|
|
|
3910
3910
|
expect(result.updatedAt).toBe(note.updatedAt);
|
|
3911
3911
|
});
|
|
3912
3912
|
|
|
3913
|
+
describe("metadata is always present on the wire (V1.1)", () => {
|
|
3914
|
+
it("a note created with no metadata reads back with an empty-object `metadata` key, not an absent one", async () => {
|
|
3915
|
+
const note = await store.createNote("No metadata here");
|
|
3916
|
+
expect("metadata" in note).toBe(true);
|
|
3917
|
+
expect(note.metadata).toEqual({});
|
|
3918
|
+
// The real acid test: does the key survive a JSON round-trip, or does
|
|
3919
|
+
// `JSON.stringify` drop it because it's `undefined`?
|
|
3920
|
+
expect(JSON.parse(JSON.stringify(note))).toHaveProperty("metadata", {});
|
|
3921
|
+
|
|
3922
|
+
const fetched = await store.getNote(note.id);
|
|
3923
|
+
expect(fetched).toHaveProperty("metadata", {});
|
|
3924
|
+
expect(JSON.parse(JSON.stringify(fetched))).toHaveProperty("metadata", {});
|
|
3925
|
+
|
|
3926
|
+
// `tags` has always been unconditionally present ([]) — confirm that
|
|
3927
|
+
// invariant still holds alongside the metadata fix.
|
|
3928
|
+
expect(note.tags).toEqual([]);
|
|
3929
|
+
});
|
|
3930
|
+
|
|
3931
|
+
it("a note whose metadata was explicitly written as {} also reads back with metadata: {}", async () => {
|
|
3932
|
+
const note = await store.createNote("Explicit empty metadata", { metadata: {} });
|
|
3933
|
+
expect(note.metadata).toEqual({});
|
|
3934
|
+
const fetched = await store.getNote(note.id);
|
|
3935
|
+
expect(fetched!.metadata).toEqual({});
|
|
3936
|
+
});
|
|
3937
|
+
|
|
3938
|
+
it("a note WITH real metadata is unchanged by the fix", async () => {
|
|
3939
|
+
const note = await store.createNote("Has metadata", { metadata: { source: "import", version: 2 } });
|
|
3940
|
+
expect(note.metadata).toEqual({ source: "import", version: 2 });
|
|
3941
|
+
const fetched = await store.getNote(note.id);
|
|
3942
|
+
expect(fetched!.metadata).toEqual({ source: "import", version: 2 });
|
|
3943
|
+
});
|
|
3944
|
+
|
|
3945
|
+
it("queryNotes list results carry metadata: {} on metadata-less notes", async () => {
|
|
3946
|
+
await store.createNote("List entry, no metadata");
|
|
3947
|
+
const results = await store.queryNotes({});
|
|
3948
|
+
expect(results.length).toBeGreaterThan(0);
|
|
3949
|
+
for (const r of results) {
|
|
3950
|
+
expect(r).toHaveProperty("metadata", {});
|
|
3951
|
+
}
|
|
3952
|
+
});
|
|
3953
|
+
|
|
3954
|
+
it("searchNotes results carry metadata: {} on metadata-less notes", async () => {
|
|
3955
|
+
await store.createNote("Findable via search, no metadata");
|
|
3956
|
+
const results = await store.searchNotes("Findable");
|
|
3957
|
+
expect(results.length).toBeGreaterThan(0);
|
|
3958
|
+
for (const r of results) {
|
|
3959
|
+
expect(r).toHaveProperty("metadata", {});
|
|
3960
|
+
}
|
|
3961
|
+
});
|
|
3962
|
+
|
|
3963
|
+
it("MCP query-notes (single by id) carries metadata: {} on a metadata-less note", async () => {
|
|
3964
|
+
const note = await store.createNote("MCP single, no metadata");
|
|
3965
|
+
const tools = generateMcpTools(store);
|
|
3966
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3967
|
+
const result = await query.execute({ id: note.id }) as any;
|
|
3968
|
+
expect(result).toHaveProperty("metadata", {});
|
|
3969
|
+
expect(JSON.parse(JSON.stringify(result))).toHaveProperty("metadata", {});
|
|
3970
|
+
});
|
|
3971
|
+
|
|
3972
|
+
it("MCP query-notes (list) carries metadata: {} on metadata-less notes", async () => {
|
|
3973
|
+
await store.createNote("MCP list, no metadata");
|
|
3974
|
+
const tools = generateMcpTools(store);
|
|
3975
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3976
|
+
const result = await query.execute({}) as any;
|
|
3977
|
+
const list = Array.isArray(result) ? result : result.notes;
|
|
3978
|
+
expect(list.length).toBeGreaterThan(0);
|
|
3979
|
+
for (const r of list) {
|
|
3980
|
+
expect(r).toHaveProperty("metadata", {});
|
|
3981
|
+
}
|
|
3982
|
+
});
|
|
3983
|
+
});
|
|
3984
|
+
|
|
3913
3985
|
it("query-notes single note by path", async () => {
|
|
3914
3986
|
await store.createNote("By Path", { path: "Projects/README" });
|
|
3915
3987
|
const tools = generateMcpTools(store);
|
|
@@ -4173,14 +4245,21 @@ describe("MCP tools", async () => {
|
|
|
4173
4245
|
expect(projects).toHaveLength(3);
|
|
4174
4246
|
expect(projects.every((n) => n.path!.startsWith("Projects"))).toBe(true);
|
|
4175
4247
|
|
|
4176
|
-
// limit + offset
|
|
4177
|
-
|
|
4248
|
+
// limit + offset. Returning exactly `limit` rows with no cursor also
|
|
4249
|
+
// trips the V1.3 truncation-honesty warning (results.length === limit,
|
|
4250
|
+
// an inherent heuristic — it can't distinguish "exactly this many rows
|
|
4251
|
+
// exist" from "capped, more may follow" without a lookahead), which
|
|
4252
|
+
// wraps the response in { notes, warnings } instead of a bare array —
|
|
4253
|
+
// same envelope shape the tool already uses for other warnings.
|
|
4254
|
+
const pageResult = await query.execute({
|
|
4178
4255
|
path_prefix: "Projects",
|
|
4179
4256
|
sort: "asc",
|
|
4180
4257
|
limit: 2,
|
|
4181
4258
|
offset: 1,
|
|
4182
|
-
}) as any
|
|
4259
|
+
}) as any;
|
|
4260
|
+
const page = Array.isArray(pageResult) ? pageResult : pageResult.notes;
|
|
4183
4261
|
expect(page).toHaveLength(2);
|
|
4262
|
+
expect(pageResult.warnings?.some((w: any) => w.code === "truncated" && w.limit === 2)).toBe(true);
|
|
4184
4263
|
});
|
|
4185
4264
|
|
|
4186
4265
|
it("query-notes full-text search works", async () => {
|
|
@@ -6614,10 +6693,10 @@ describe("create-note if_exists (vault#555)", async () => {
|
|
|
6614
6693
|
if_exists: "replace",
|
|
6615
6694
|
}) as any;
|
|
6616
6695
|
expect(second.content).toBe("");
|
|
6617
|
-
// An empty `{}` metadata
|
|
6618
|
-
// convention every note with no metadata follows (rowToNote
|
|
6619
|
-
// something special about `replace`.
|
|
6620
|
-
expect(second.metadata).
|
|
6696
|
+
// An empty `{}` metadata reads back as `{}`, not an absent key — the
|
|
6697
|
+
// same convention every note with no metadata follows (rowToNote,
|
|
6698
|
+
// vault V1.1), not something special about `replace`.
|
|
6699
|
+
expect(second.metadata).toEqual({});
|
|
6621
6700
|
});
|
|
6622
6701
|
|
|
6623
6702
|
it("if_exists is a no-op without a path — a pathless create can never conflict", async () => {
|
package/core/src/mcp.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
collectUnknownTagWarnings,
|
|
11
11
|
emptySearchWarning,
|
|
12
12
|
ignoredParamWarning,
|
|
13
|
+
truncatedResultsWarning,
|
|
13
14
|
computeSearchDidYouMean,
|
|
14
15
|
searchDidYouMeanWarning,
|
|
15
16
|
type QueryWarning,
|
|
@@ -771,6 +772,22 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
771
772
|
// never leaks via `did_you_mean`.
|
|
772
773
|
let queryWarnings: QueryWarning[] = [];
|
|
773
774
|
if (params.search) {
|
|
775
|
+
// `offset` under full-text search (vault contracts-brief V1.2):
|
|
776
|
+
// `searchNotes` has no offset parameter at all — FTS5 ranks by
|
|
777
|
+
// relevance, not a stable row order, so paging by offset over it
|
|
778
|
+
// is unsound. Rather than silently return page 1 with no signal,
|
|
779
|
+
// flag it. Presence check (`!== undefined`) so `offset: 0` — a
|
|
780
|
+
// caller being explicit about "start" — still warns. The
|
|
781
|
+
// structured-query branch (below) has real offset support and is
|
|
782
|
+
// unaffected.
|
|
783
|
+
if (params.offset !== undefined) {
|
|
784
|
+
queryWarnings.push(
|
|
785
|
+
ignoredParamWarning(
|
|
786
|
+
"offset",
|
|
787
|
+
"full-text search has no stable row order to offset into — results are always page 1, ranked by relevance",
|
|
788
|
+
),
|
|
789
|
+
);
|
|
790
|
+
}
|
|
774
791
|
// Normalize tag param
|
|
775
792
|
const tags = normalizeTags(params.tag);
|
|
776
793
|
const mode: SearchMode = searchMode ?? "literal";
|
|
@@ -887,6 +904,13 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
887
904
|
nextCursor = page.next_cursor;
|
|
888
905
|
} else {
|
|
889
906
|
results = await store.queryNotes(queryOpts);
|
|
907
|
+
// Truncation-honesty (vault contracts-brief V1.3): no cursor was
|
|
908
|
+
// requested, so `next_cursor` never surfaces as an honest
|
|
909
|
+
// "more may follow" signal — this is that signal. Mirrors the
|
|
910
|
+
// REST structured-query path in src/routes.ts.
|
|
911
|
+
if (results.length === queryOpts.limit) {
|
|
912
|
+
queryWarnings.push(truncatedResultsWarning(queryOpts.limit));
|
|
913
|
+
}
|
|
890
914
|
}
|
|
891
915
|
}
|
|
892
916
|
|
package/core/src/notes.ts
CHANGED
|
@@ -2980,7 +2980,12 @@ interface NoteRow {
|
|
|
2980
2980
|
}
|
|
2981
2981
|
|
|
2982
2982
|
function rowToNote(row: NoteRow): Note {
|
|
2983
|
-
|
|
2983
|
+
// `metadata` is always present on the wire — NULL/"{}"/unparseable all
|
|
2984
|
+
// collapse to `{}` rather than an absent key. `tags` is already always
|
|
2985
|
+
// `[]` (see getNote/queryNotes/searchNotes hydration below); this keeps
|
|
2986
|
+
// the note shape internally consistent and fixes a real third-party
|
|
2987
|
+
// crash on clients that assumed `note.metadata` always exists.
|
|
2988
|
+
let metadata: Record<string, unknown> = {};
|
|
2984
2989
|
if (row.metadata && row.metadata !== "{}") {
|
|
2985
2990
|
try { metadata = JSON.parse(row.metadata); } catch {}
|
|
2986
2991
|
}
|
|
@@ -219,6 +219,26 @@ export function ignoredParamWarning(param: string, reason: string): QueryWarning
|
|
|
219
219
|
};
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
/**
|
|
223
|
+
* `truncated` warning (vault contracts-brief V1.3) — a structured, non-
|
|
224
|
+
* cursor list query returned exactly `limit` rows with no watermark to page
|
|
225
|
+
* from. That's ambiguous on its own: it might be the whole result set
|
|
226
|
+
* (coincidence), or there might be more rows silently cut off. Combined
|
|
227
|
+
* with the engine's default order (`created_at ASC` — oldest first,
|
|
228
|
+
* core/src/notes.ts) this is the concrete failure mode: a bare `GET
|
|
229
|
+
* /api/notes` on a >limit-note vault returns the OLDEST rows with zero
|
|
230
|
+
* signal that the NEWEST ones didn't make it. `?cursor=` (bootstrap or
|
|
231
|
+
* watermark) sidesteps this entirely — the warning only fires in its
|
|
232
|
+
* absence.
|
|
233
|
+
*/
|
|
234
|
+
export function truncatedResultsWarning(limit: number): QueryWarning {
|
|
235
|
+
return {
|
|
236
|
+
code: "truncated",
|
|
237
|
+
message: `${limit} = limit rows returned; there may be more. Pass ?cursor= to page, or sort=desc for newest-first.`,
|
|
238
|
+
limit,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
222
242
|
/**
|
|
223
243
|
* Cheap zero-result search suggestion (vault#551 WS2B — mirrors the tag
|
|
224
244
|
* `did_you_mean` above via the SAME `suggestSimilarTag` scorer, just over a
|
package/core/src/seed-packs.ts
CHANGED
|
@@ -382,6 +382,16 @@ file one document, or bring in one small batch — then stop and show them what
|
|
|
382
382
|
happened. Structure grows from real notes (that's Part 2's design vocabulary).
|
|
383
383
|
Setup is a relationship over many sessions, not an install step.
|
|
384
384
|
|
|
385
|
+
**Capture first, ask second.** If the person's very first message already names
|
|
386
|
+
something real — a live project, a decision they're weighing, a deadline —
|
|
387
|
+
**capture THAT as the first note from what they've told you, before asking for
|
|
388
|
+
more detail.** Don't interview them into an empty vault: the note you can
|
|
389
|
+
already write is worth more than the three you're still asking about, and your
|
|
390
|
+
questions land better as the follow-up to a captured thing ("got the commission
|
|
391
|
+
down — when's it due, and who's the client?") than as the opener. A good first
|
|
392
|
+
conversation that leaves the vault empty is a miss, not a success — something
|
|
393
|
+
they said should have stuck.
|
|
394
|
+
|
|
385
395
|
Five short guides ship in this vault for the person to read (tagged \`#guide\`):
|
|
386
396
|
[[Welcome to your vault 🪂]], [[Capture anything]], [[Tags and the graph]],
|
|
387
397
|
[[Connect your AI]], and [[Yours to keep]]. Point them there for anything you'd
|
|
@@ -484,7 +494,12 @@ These three axes are the heart of vault design. Use the right one for the job:
|
|
|
484
494
|
marked \`indexed: true\` to make it **queryable with operators** (\`query-notes
|
|
485
495
|
{ tag: "meeting", metadata: { held_on: { gte: "2026-01-01" } } }\`); indexing
|
|
486
496
|
is opt-in per field, not automatic. Add a schema (and index a field) when you
|
|
487
|
-
find yourself wanting to filter or sort on a value, not before.
|
|
497
|
+
find yourself wanting to filter or sort on a value, not before. **Field names
|
|
498
|
+
are global across the vault, not scoped per tag** — two tags that declare the
|
|
499
|
+
same field name must agree on its type. So prefer a tag-specific name
|
|
500
|
+
(\`price_tier\` on \`#restaurant\`, \`book_rating\` on \`#book\`) over a generic
|
|
501
|
+
shared one (a \`rating\` that means 1–10 on one tag and \`$\`–\`$$$$\` on another
|
|
502
|
+
collides); when in doubt, name the field for the tag it belongs to.
|
|
488
503
|
|
|
489
504
|
Rule of thumb: **type with tags, file with paths, make-it-queryable with
|
|
490
505
|
schemas.** Start minimal — invent tags as real notes need them, declare a
|
|
@@ -502,7 +517,7 @@ update-tag {
|
|
|
502
517
|
fields: {
|
|
503
518
|
held_on: { type: "string", indexed: true }, // queryable with operators
|
|
504
519
|
status: { type: "string", enum: ["scheduled", "done"], default: "scheduled" }, // explicit default — declare one to auto-fill
|
|
505
|
-
|
|
520
|
+
meeting_rating: { type: "integer" } // no default — stays unset until written; tag-specific name, not a bare rating
|
|
506
521
|
}
|
|
507
522
|
}
|
|
508
523
|
\`\`\`
|
|
@@ -556,8 +571,8 @@ A few behaviors worth knowing before you write at scale:
|
|
|
556
571
|
- **A schema field only back-fills when you declare an explicit \`default\`.**
|
|
557
572
|
When a note gets a tag whose schema declares a field with a \`default\`, an
|
|
558
573
|
unset field is filled with THAT value. A field with no \`default\` stays
|
|
559
|
-
genuinely absent — \`query-notes { metadata: {
|
|
560
|
-
reliably finds notes that never set \`
|
|
574
|
+
genuinely absent — \`query-notes { metadata: { meeting_rating: { exists: false } } }\`
|
|
575
|
+
reliably finds notes that never set \`meeting_rating\`. Declare \`default\` on a field
|
|
561
576
|
only when "unset" and "explicitly set to X" should read the same; leave it
|
|
562
577
|
off when you need to tell them apart.
|
|
563
578
|
- **Validation is advisory, never blocking — EXCEPT an \`indexed: true\`
|
package/core/src/types.ts
CHANGED
|
@@ -31,6 +31,13 @@ export interface Note {
|
|
|
31
31
|
* `core/src/portable-md.ts:supportsInlineFrontmatter`.
|
|
32
32
|
*/
|
|
33
33
|
extension?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Always present on notes read back from the store — NULL/`"{}"`/
|
|
36
|
+
* unparseable metadata all collapse to `{}` rather than an absent key
|
|
37
|
+
* (mirrors `tags`, which is likewise always `[]`). Optional here only so
|
|
38
|
+
* write-input shapes (`createNote`/`updateNote` options) can share this
|
|
39
|
+
* type without forcing every caller to pass an empty object.
|
|
40
|
+
*/
|
|
34
41
|
metadata?: Record<string, unknown>;
|
|
35
42
|
createdAt: string; // ISO-8601
|
|
36
43
|
updatedAt?: string;
|
|
@@ -352,6 +359,7 @@ export interface NoteIndex {
|
|
|
352
359
|
lastUpdatedBy?: string | null;
|
|
353
360
|
lastUpdatedVia?: string | null;
|
|
354
361
|
tags?: string[];
|
|
362
|
+
/** Always present (`{}` when empty) — see `Note.metadata`. */
|
|
355
363
|
metadata?: Record<string, unknown>;
|
|
356
364
|
byteSize: number;
|
|
357
365
|
preview: string;
|
package/package.json
CHANGED
package/src/auth.ts
CHANGED
|
@@ -280,14 +280,20 @@ export function isMethodAllowed(method: string, permission: TokenPermission): bo
|
|
|
280
280
|
return READ_METHODS.has(method);
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
// RFC 7235: the auth-scheme token is case-insensitive (`Bearer`/`bearer`/
|
|
284
|
+
// `BEARER` are all the same scheme) — only the token68/credentials that
|
|
285
|
+
// follow are case-sensitive. `\s+` (not a single literal space) tolerates
|
|
286
|
+
// the rare client that sends more than one space after the scheme.
|
|
287
|
+
const BEARER_PREFIX = /^Bearer\s+/i;
|
|
288
|
+
|
|
283
289
|
/**
|
|
284
290
|
* Extract API key/token from request.
|
|
285
291
|
* Priority: Authorization header → X-API-Key header → ?key= query param.
|
|
286
292
|
*/
|
|
287
293
|
export function extractApiKey(req: Request): string | null {
|
|
288
294
|
const authHeader = req.headers.get("authorization");
|
|
289
|
-
if (authHeader
|
|
290
|
-
return authHeader.
|
|
295
|
+
if (authHeader && BEARER_PREFIX.test(authHeader)) {
|
|
296
|
+
return authHeader.replace(BEARER_PREFIX, "");
|
|
291
297
|
}
|
|
292
298
|
const xApiKey = req.headers.get("x-api-key");
|
|
293
299
|
if (xApiKey) return xApiKey;
|
|
@@ -248,6 +248,48 @@ describe("contract: honest queries — warnings channel (#550)", () => {
|
|
|
248
248
|
});
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
+
describe("contract: truncation-honesty warning (V1.3)", () => {
|
|
252
|
+
it("a structured non-cursor list that hits its limit carries a `truncated` warning", async () => {
|
|
253
|
+
for (let i = 0; i < 3; i++) await store.createNote(`note ${i}`);
|
|
254
|
+
const res = await getNotes("limit=2");
|
|
255
|
+
expect(res.status).toBe(200);
|
|
256
|
+
const body: any[] = await res.json();
|
|
257
|
+
expect(body.length).toBe(2); // the limit itself is unaffected — warning only
|
|
258
|
+
const warnings = decodeWarningsHeader(res);
|
|
259
|
+
expect(warnings).not.toBeNull();
|
|
260
|
+
const truncated = warnings!.find((w) => w.code === "truncated");
|
|
261
|
+
expect(truncated).toBeDefined();
|
|
262
|
+
expect(truncated.limit).toBe(2);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("an under-limit list carries no `truncated` warning", async () => {
|
|
266
|
+
await store.createNote("only one");
|
|
267
|
+
const res = await getNotes("limit=50");
|
|
268
|
+
expect(res.status).toBe(200);
|
|
269
|
+
const warnings = decodeWarningsHeader(res);
|
|
270
|
+
expect(warnings?.some((w) => w.code === "truncated") ?? false).toBe(false);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("cursor mode never carries a `truncated` warning — `next_cursor` is already the honest signal", async () => {
|
|
274
|
+
for (let i = 0; i < 3; i++) await store.createNote(`note ${i}`);
|
|
275
|
+
const res = await getNotes("limit=2&cursor=");
|
|
276
|
+
expect(res.status).toBe(200);
|
|
277
|
+
const body: any = await res.json();
|
|
278
|
+
expect(body.notes.length).toBe(2);
|
|
279
|
+
expect(typeof body.next_cursor).toBe("string");
|
|
280
|
+
expect((body.warnings ?? []).some((w: any) => w.code === "truncated")).toBe(false);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("MCP query-notes mirrors the same truncated warning on a limit-hit non-cursor list", async () => {
|
|
284
|
+
for (let i = 0; i < 3; i++) await store.createNote(`mcp note ${i}`);
|
|
285
|
+
const tools = generateMcpTools(store);
|
|
286
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
287
|
+
const result = (await query.execute({ limit: 2 })) as any;
|
|
288
|
+
expect(result.warnings).toBeDefined();
|
|
289
|
+
expect(result.warnings.some((w: any) => w.code === "truncated" && w.limit === 2)).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
251
293
|
describe("contract: honest queries — cursor bootstrap (#550, the P1)", () => {
|
|
252
294
|
it("an empty cursor (`?cursor=`) engages the {notes, next_cursor} envelope on the VERY FIRST call, and the returned next_cursor sees only notes written after it", async () => {
|
|
253
295
|
const n1 = await store.createNote("existing note");
|
|
@@ -410,3 +452,49 @@ describe("contract: honest queries — find-path hydration (#550, additive)", ()
|
|
|
410
452
|
]);
|
|
411
453
|
});
|
|
412
454
|
});
|
|
455
|
+
|
|
456
|
+
describe("contract: metadata always present on the wire (V1.1)", () => {
|
|
457
|
+
it("GET /api/notes?id= on a metadata-less note carries `metadata: {}`, not an absent key", async () => {
|
|
458
|
+
const note = await store.createNote("No metadata here");
|
|
459
|
+
const res = await getNotes(`id=${note.id}`);
|
|
460
|
+
expect(res.status).toBe(200);
|
|
461
|
+
const body: any = await res.json();
|
|
462
|
+
expect(body).toHaveProperty("metadata");
|
|
463
|
+
expect(body.metadata).toEqual({});
|
|
464
|
+
// `tags` has always been unconditionally present — confirm it still is.
|
|
465
|
+
expect(body.tags).toEqual([]);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("GET /api/notes (list) carries `metadata: {}` on metadata-less entries", async () => {
|
|
469
|
+
await store.createNote("List entry, no metadata");
|
|
470
|
+
const res = await getNotes("");
|
|
471
|
+
expect(res.status).toBe(200);
|
|
472
|
+
const body: any = await res.json();
|
|
473
|
+
expect(Array.isArray(body)).toBe(true);
|
|
474
|
+
expect(body.length).toBeGreaterThan(0);
|
|
475
|
+
for (const entry of body) {
|
|
476
|
+
expect(entry).toHaveProperty("metadata");
|
|
477
|
+
expect(entry.metadata).toEqual({});
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("GET /api/notes?search= carries `metadata: {}` on metadata-less results", async () => {
|
|
482
|
+
await store.createNote("Findable via search, no metadata");
|
|
483
|
+
const res = await getNotes("search=Findable");
|
|
484
|
+
expect(res.status).toBe(200);
|
|
485
|
+
const body: any = await res.json();
|
|
486
|
+
expect(Array.isArray(body)).toBe(true);
|
|
487
|
+
expect(body.length).toBeGreaterThan(0);
|
|
488
|
+
for (const entry of body) {
|
|
489
|
+
expect(entry).toHaveProperty("metadata");
|
|
490
|
+
expect(entry.metadata).toEqual({});
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("a note WITH real metadata is unaffected — the fix only changes the empty case", async () => {
|
|
495
|
+
const note = await store.createNote("Has metadata", { metadata: { source: "import" } });
|
|
496
|
+
const res = await getNotes(`id=${note.id}`);
|
|
497
|
+
const body: any = await res.json();
|
|
498
|
+
expect(body.metadata).toEqual({ source: "import" });
|
|
499
|
+
});
|
|
500
|
+
});
|
|
@@ -305,6 +305,32 @@ describe("contract: search — escaping edge cases, tag-scope, warnings (#551)",
|
|
|
305
305
|
expect(warnings!.some((w: any) => w.code === "ignored_param" && w.param === "search_mode")).toBe(true);
|
|
306
306
|
});
|
|
307
307
|
|
|
308
|
+
it("offset alongside search is a no-op that surfaces an ignored_param warning, results unchanged (V1.2)", async () => {
|
|
309
|
+
await store.createNote("findable one", { tags: ["probe"] });
|
|
310
|
+
await store.createNote("findable two", { tags: ["probe"] });
|
|
311
|
+
|
|
312
|
+
const plain = await search("search=findable&tag=probe");
|
|
313
|
+
const withOffset = await search("search=findable&tag=probe&offset=1");
|
|
314
|
+
expect(withOffset.status).toBe(200);
|
|
315
|
+
|
|
316
|
+
const plainBody = await bodyOf(plain);
|
|
317
|
+
const offsetBody = await bodyOf(withOffset);
|
|
318
|
+
// Page 1 either way — offset has no effect on FTS5 relevance ordering.
|
|
319
|
+
expect(offsetBody.map((n: any) => n.id).sort()).toEqual(plainBody.map((n: any) => n.id).sort());
|
|
320
|
+
|
|
321
|
+
const warnings = decodeWarningsHeader(withOffset);
|
|
322
|
+
expect(warnings).not.toBeNull();
|
|
323
|
+
expect(warnings!.some((w: any) => w.code === "ignored_param" && w.param === "offset")).toBe(true);
|
|
324
|
+
|
|
325
|
+
// No search → no warning (offset only means nothing UNDER search; the
|
|
326
|
+
// structured-query path honors it for real).
|
|
327
|
+
const structuredNoWarning = await search("tag=probe&offset=1");
|
|
328
|
+
const structuredWarnings = decodeWarningsHeader(structuredNoWarning);
|
|
329
|
+
expect(
|
|
330
|
+
structuredWarnings?.some((w: any) => w.code === "ignored_param" && w.param === "offset") ?? false,
|
|
331
|
+
).toBe(false);
|
|
332
|
+
});
|
|
333
|
+
|
|
308
334
|
it("an unrecognized search_mode value is a structured 400 invalid_query", async () => {
|
|
309
335
|
const res = await search("search=widgets&search_mode=bogus");
|
|
310
336
|
expect(res.status).toBe(400);
|
|
@@ -460,6 +486,21 @@ describe("contract: search — MCP parity (#551)", () => {
|
|
|
460
486
|
expect(result.warnings.some((w: any) => w.code === "ignored_param" && w.param === "search_mode")).toBe(true);
|
|
461
487
|
});
|
|
462
488
|
|
|
489
|
+
it("query-notes: offset alongside search is a no-op that surfaces an ignored_param warning (V1.2)", async () => {
|
|
490
|
+
await store.createNote("mcp findable", { tags: ["probe"] });
|
|
491
|
+
const tools = generateMcpTools(store);
|
|
492
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
493
|
+
const result = (await query.execute({ search: "findable", offset: 5 })) as any;
|
|
494
|
+
expect(result.warnings).toBeDefined();
|
|
495
|
+
expect(result.warnings.some((w: any) => w.code === "ignored_param" && w.param === "offset")).toBe(true);
|
|
496
|
+
|
|
497
|
+
// Structured query (no search) — offset is honored for real, no warning.
|
|
498
|
+
const structured = (await query.execute({ tag: "probe", offset: 0 })) as any;
|
|
499
|
+
expect(
|
|
500
|
+
(structured.warnings ?? []).some((w: any) => w.code === "ignored_param" && w.param === "offset"),
|
|
501
|
+
).toBe(false);
|
|
502
|
+
});
|
|
503
|
+
|
|
463
504
|
it("query-notes: search_mode:\"advanced\" + malformed syntax throws a structured invalid_search_syntax error", async () => {
|
|
464
505
|
const tools = generateMcpTools(store);
|
|
465
506
|
const query = tools.find((t) => t.name === "query-notes")!;
|
package/src/routes.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
collectUnknownTagWarnings,
|
|
18
18
|
emptySearchWarning,
|
|
19
19
|
ignoredParamWarning,
|
|
20
|
+
truncatedResultsWarning,
|
|
20
21
|
computeSearchDidYouMean,
|
|
21
22
|
searchDidYouMeanWarning,
|
|
22
23
|
type QueryWarning,
|
|
@@ -1254,6 +1255,23 @@ async function handleNotesInner(
|
|
|
1254
1255
|
const sort = (parseQuery(url, "sort") as "asc" | "desc" | null) ?? undefined;
|
|
1255
1256
|
|
|
1256
1257
|
const searchWarnings: QueryWarning[] = [];
|
|
1258
|
+
// `offset` under full-text search (vault contracts-brief V1.2):
|
|
1259
|
+
// `searchNotes` has no offset parameter at all — FTS5 ranks by
|
|
1260
|
+
// relevance, not a stable row order, so paging by offset over it
|
|
1261
|
+
// is unsound. Rather than silently return page 1 with no signal
|
|
1262
|
+
// (the pre-existing behavior — `?search=x&offset=50` == `?search=x`),
|
|
1263
|
+
// flag it. Presence check (not truthiness) so `offset=0` — a caller
|
|
1264
|
+
// being explicit about "start" — still warns; it's equally
|
|
1265
|
+
// meaningless here. The structured-query path (below) has real
|
|
1266
|
+
// offset support and is unaffected.
|
|
1267
|
+
if (parseQuery(url, "offset") !== null) {
|
|
1268
|
+
searchWarnings.push(
|
|
1269
|
+
ignoredParamWarning(
|
|
1270
|
+
"offset",
|
|
1271
|
+
"full-text search has no stable row order to offset into — results are always page 1, ranked by relevance",
|
|
1272
|
+
),
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1257
1275
|
let rawResults: Note[];
|
|
1258
1276
|
// "Only whitespace/quotes" (vault#551 edge case) — short-circuit
|
|
1259
1277
|
// BEFORE ever calling FTS5 rather than risk a syntax error (or a
|
|
@@ -1558,6 +1576,15 @@ async function handleNotesInner(
|
|
|
1558
1576
|
}
|
|
1559
1577
|
throw e;
|
|
1560
1578
|
}
|
|
1579
|
+
// Truncation-honesty (vault contracts-brief V1.3): captured BEFORE
|
|
1580
|
+
// near/tag-scope filtering, which can only shrink the set further —
|
|
1581
|
+
// this reflects whether the underlying store query actually hit its
|
|
1582
|
+
// `limit` (i.e. there may be more rows the caller never saw). Cursor
|
|
1583
|
+
// mode is exempt — it already carries `next_cursor` as the honest
|
|
1584
|
+
// "more may follow" signal, so a second warning would be redundant.
|
|
1585
|
+
if (!cursorMode && results.length === queryOpts.limit) {
|
|
1586
|
+
queryWarnings.push(truncatedResultsWarning(queryOpts.limit));
|
|
1587
|
+
}
|
|
1561
1588
|
|
|
1562
1589
|
// Near-scope filter (graph neighborhood)
|
|
1563
1590
|
const nearNoteId = parseQuery(url, "near[note_id]");
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, test, expect, beforeEach } from "bun:test";
|
|
10
|
-
import { resolveScribeUrl, clearScribeUrlCache } from "./scribe-discovery.ts";
|
|
10
|
+
import { resolveScribeUrl, getCachedScribeUrl, clearScribeUrlCache } from "./scribe-discovery.ts";
|
|
11
11
|
|
|
12
12
|
function mkManifest(services: Array<{ name: string; port: number; origin?: string }>): typeof import("./services-manifest.ts").readManifest {
|
|
13
13
|
return () => ({
|
|
@@ -75,3 +75,78 @@ describe("resolveScribeUrl", () => {
|
|
|
75
75
|
expect(resolveScribeUrl(env, manifest)).toBe("http://127.0.0.1:1943");
|
|
76
76
|
});
|
|
77
77
|
});
|
|
78
|
+
|
|
79
|
+
describe("getCachedScribeUrl (vault contracts-brief V1.5 — TTL, not process-lifetime)", () => {
|
|
80
|
+
// A manifest fn that counts calls, so tests can prove whether the cache
|
|
81
|
+
// actually skipped a re-read (rather than just asserting the RESULT,
|
|
82
|
+
// which would pass even if the cache silently didn't cache at all).
|
|
83
|
+
function mkCountingManifest(port: number): { impl: typeof import("./services-manifest.ts").readManifest; calls: () => number } {
|
|
84
|
+
let calls = 0;
|
|
85
|
+
const impl = (() => {
|
|
86
|
+
calls++;
|
|
87
|
+
return {
|
|
88
|
+
services: [
|
|
89
|
+
{ name: "parachute-scribe", port, paths: ["/parachute-scribe"], health: "/health", version: "0.0.0-test" },
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
}) as unknown as typeof import("./services-manifest.ts").readManifest;
|
|
93
|
+
return { impl, calls: () => calls };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
test("within the TTL window, repeated calls reuse the cached value (manifest read once)", () => {
|
|
97
|
+
const env = {} as NodeJS.ProcessEnv;
|
|
98
|
+
const { impl, calls } = mkCountingManifest(1943);
|
|
99
|
+
let t = 1_000_000;
|
|
100
|
+
const now = () => t;
|
|
101
|
+
|
|
102
|
+
expect(getCachedScribeUrl(env, impl, console, now)).toBe("http://127.0.0.1:1943");
|
|
103
|
+
t += 1_000; // well inside the 30s TTL
|
|
104
|
+
expect(getCachedScribeUrl(env, impl, console, now)).toBe("http://127.0.0.1:1943");
|
|
105
|
+
expect(calls()).toBe(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("after the TTL expires, the next call re-resolves — a scribe that appeared mid-process is picked up without a restart", () => {
|
|
109
|
+
const env = {} as NodeJS.ProcessEnv;
|
|
110
|
+
// First manifest: no scribe registered yet.
|
|
111
|
+
let manifestCalls = 0;
|
|
112
|
+
let portToReturn: number | null = null;
|
|
113
|
+
const impl = (() => {
|
|
114
|
+
manifestCalls++;
|
|
115
|
+
return {
|
|
116
|
+
services: portToReturn === null
|
|
117
|
+
? []
|
|
118
|
+
: [{ name: "parachute-scribe", port: portToReturn, paths: ["/parachute-scribe"], health: "/health", version: "0.0.0-test" }],
|
|
119
|
+
};
|
|
120
|
+
}) as unknown as typeof import("./services-manifest.ts").readManifest;
|
|
121
|
+
let t = 1_000_000;
|
|
122
|
+
const now = () => t;
|
|
123
|
+
|
|
124
|
+
// Nothing registered yet — vault boots before scribe.
|
|
125
|
+
expect(getCachedScribeUrl(env, impl, console, now)).toBeUndefined();
|
|
126
|
+
|
|
127
|
+
// Scribe installs and registers itself mid-process — still within TTL,
|
|
128
|
+
// so the stale "undefined" is what a caller sees (this is the bound the
|
|
129
|
+
// TTL accepts, not the bug being fixed).
|
|
130
|
+
portToReturn = 1943;
|
|
131
|
+
t += 1_000;
|
|
132
|
+
expect(getCachedScribeUrl(env, impl, console, now)).toBeUndefined();
|
|
133
|
+
expect(manifestCalls).toBe(1); // still cached — didn't re-read
|
|
134
|
+
|
|
135
|
+
// TTL elapses — next probe re-reads and picks up the now-live scribe,
|
|
136
|
+
// with no vault restart in between.
|
|
137
|
+
t += 30_000;
|
|
138
|
+
expect(getCachedScribeUrl(env, impl, console, now)).toBe("http://127.0.0.1:1943");
|
|
139
|
+
expect(manifestCalls).toBe(2);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("clearScribeUrlCache forces a fresh resolve even within the TTL window", () => {
|
|
143
|
+
const env = {} as NodeJS.ProcessEnv;
|
|
144
|
+
const { impl, calls } = mkCountingManifest(1943);
|
|
145
|
+
const now = () => 1_000_000;
|
|
146
|
+
|
|
147
|
+
expect(getCachedScribeUrl(env, impl, console, now)).toBe("http://127.0.0.1:1943");
|
|
148
|
+
clearScribeUrlCache();
|
|
149
|
+
expect(getCachedScribeUrl(env, impl, console, now)).toBe("http://127.0.0.1:1943");
|
|
150
|
+
expect(calls()).toBe(2);
|
|
151
|
+
});
|
|
152
|
+
});
|
package/src/scribe-discovery.ts
CHANGED
|
@@ -68,24 +68,43 @@ export function resolveScribeUrl(
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
71
|
+
* Short-TTL cache (vault contracts-brief V1.5 — was process-lifetime).
|
|
72
|
+
* `resolveScribeUrl` is just an env read + a `services.json` stat/read, cheap
|
|
73
|
+
* enough to redo on this cadence — but the transcription capability probe
|
|
74
|
+
* (`GET /api/vault`, `src/transcription/capability.ts`) can be hit often
|
|
75
|
+
* enough that re-reading the manifest on EVERY call is wasteful. A TTL
|
|
76
|
+
* splits the difference: scribe installing/moving/starting mid-process (the
|
|
77
|
+
* real deploy sequencing — hub can bring scribe up after vault) is picked up
|
|
78
|
+
* within `SCRIBE_URL_CACHE_TTL_MS` instead of requiring a vault restart,
|
|
79
|
+
* while a steady-state deployment still avoids a filesystem read per
|
|
80
|
+
* request. Previously this was a `let ... = null` sentinel computed once at
|
|
81
|
+
* first call and reused for the rest of the process — that's the staleness
|
|
82
|
+
* this replaces.
|
|
76
83
|
*
|
|
77
84
|
* Tests should pass an explicit `env` + `readManifestImpl` to `resolveScribeUrl`
|
|
78
|
-
* directly to bypass the cache
|
|
85
|
+
* directly to bypass the cache, or use the `now` injection seam here to
|
|
86
|
+
* simulate TTL expiry without real sleeps.
|
|
79
87
|
*/
|
|
88
|
+
const SCRIBE_URL_CACHE_TTL_MS = 30_000;
|
|
89
|
+
|
|
80
90
|
let cachedScribeUrl: string | undefined | null = null;
|
|
91
|
+
let cachedAt = 0;
|
|
81
92
|
|
|
82
|
-
export function getCachedScribeUrl(
|
|
83
|
-
|
|
84
|
-
|
|
93
|
+
export function getCachedScribeUrl(
|
|
94
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
95
|
+
readManifestImpl: typeof readManifest = readManifest,
|
|
96
|
+
logger: { warn?: (...args: unknown[]) => void } = console,
|
|
97
|
+
now: () => number = Date.now,
|
|
98
|
+
): string | undefined {
|
|
99
|
+
const t = now();
|
|
100
|
+
if (cachedScribeUrl === null || t - cachedAt >= SCRIBE_URL_CACHE_TTL_MS) {
|
|
101
|
+
cachedScribeUrl = resolveScribeUrl(env, readManifestImpl, logger);
|
|
102
|
+
cachedAt = t;
|
|
85
103
|
}
|
|
86
104
|
return cachedScribeUrl;
|
|
87
105
|
}
|
|
88
106
|
|
|
89
107
|
export function clearScribeUrlCache(): void {
|
|
90
108
|
cachedScribeUrl = null;
|
|
109
|
+
cachedAt = 0;
|
|
91
110
|
}
|
package/src/vault.test.ts
CHANGED
|
@@ -272,9 +272,9 @@ describe("metadata", async () => {
|
|
|
272
272
|
expect(published[0].content).toBe("Published");
|
|
273
273
|
});
|
|
274
274
|
|
|
275
|
-
test("notes without metadata return
|
|
275
|
+
test("notes without metadata return metadata: {} (vault V1.1 — not an absent key)", async () => {
|
|
276
276
|
const note = await store.createNote("Plain note");
|
|
277
|
-
expect(note.metadata).
|
|
277
|
+
expect(note.metadata).toEqual({});
|
|
278
278
|
});
|
|
279
279
|
|
|
280
280
|
test("creates link with metadata", async () => {
|
|
@@ -7602,6 +7602,31 @@ describe("extractApiKey", () => {
|
|
|
7602
7602
|
expect(extractApiKey(req)).toBe("pvt_abc123");
|
|
7603
7603
|
});
|
|
7604
7604
|
|
|
7605
|
+
// RFC 7235: the auth-scheme token is case-insensitive (V1.4). A client
|
|
7606
|
+
// sending `bearer`/`BEARER`/`BeArEr` must authenticate identically to
|
|
7607
|
+
// one sending the canonical `Bearer` — only the credentials that follow
|
|
7608
|
+
// the scheme are case-sensitive, and those come through verbatim.
|
|
7609
|
+
test("extracts from a lowercase `bearer` scheme (RFC 7235 case-insensitivity)", () => {
|
|
7610
|
+
const req = new Request("http://localhost/api/notes", {
|
|
7611
|
+
headers: { Authorization: "bearer pvt_abc123" },
|
|
7612
|
+
});
|
|
7613
|
+
expect(extractApiKey(req)).toBe("pvt_abc123");
|
|
7614
|
+
});
|
|
7615
|
+
|
|
7616
|
+
test("extracts from a mixed-case `BeArEr` scheme", () => {
|
|
7617
|
+
const req = new Request("http://localhost/api/notes", {
|
|
7618
|
+
headers: { Authorization: "BeArEr pvt_abc123" },
|
|
7619
|
+
});
|
|
7620
|
+
expect(extractApiKey(req)).toBe("pvt_abc123");
|
|
7621
|
+
});
|
|
7622
|
+
|
|
7623
|
+
test("the token itself stays case-sensitive — only the scheme is folded", () => {
|
|
7624
|
+
const req = new Request("http://localhost/api/notes", {
|
|
7625
|
+
headers: { Authorization: "bearer PvT_MixedCase123" },
|
|
7626
|
+
});
|
|
7627
|
+
expect(extractApiKey(req)).toBe("PvT_MixedCase123");
|
|
7628
|
+
});
|
|
7629
|
+
|
|
7605
7630
|
test("extracts from X-API-Key header", () => {
|
|
7606
7631
|
const req = new Request("http://localhost/api/notes", {
|
|
7607
7632
|
headers: { "X-API-Key": "pvk_xyz789" },
|