@openparachute/vault 0.7.0-rc.2 → 0.7.0-rc.3
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/mcp.ts +95 -19
- package/core/src/notes.ts +84 -9
- package/core/src/query-warnings.ts +35 -0
- package/core/src/search-query.test.ts +149 -0
- package/core/src/search-query.ts +0 -0
- package/core/src/store.ts +2 -1
- package/core/src/types.ts +13 -1
- package/package.json +1 -1
- package/src/contract-search.test.ts +263 -31
- package/src/mcp-http.ts +13 -0
- package/src/routes.ts +103 -16
- package/src/ws-subscribe.test.ts +14 -0
package/core/src/mcp.ts
CHANGED
|
@@ -5,7 +5,8 @@ import * as noteOps from "./notes.js";
|
|
|
5
5
|
import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
|
|
6
6
|
import { QueryError } from "./query-operators.js";
|
|
7
7
|
import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "./tag-hierarchy.js";
|
|
8
|
-
import { collectUnknownTagWarnings, type QueryWarning } from "./query-warnings.js";
|
|
8
|
+
import { collectUnknownTagWarnings, emptySearchWarning, ignoredParamWarning, type QueryWarning } from "./query-warnings.js";
|
|
9
|
+
import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "./search-query.js";
|
|
9
10
|
import * as linkOps from "./links.js";
|
|
10
11
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
11
12
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
@@ -243,7 +244,9 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
243
244
|
Response shape (vault#550 — three variants, pick by what you passed):
|
|
244
245
|
- Default (no \`cursor\`, no warnings): a bare array of notes.
|
|
245
246
|
- Cursor mode (\`cursor\` param present — including \`cursor: ""\` to bootstrap): \`{notes: [...], next_cursor}\`. See \`cursor\` below for the bootstrap flow.
|
|
246
|
-
- Warnings present (e.g. an unrecognized \`tag\`) and NOT in cursor mode: \`{notes: [...], warnings: [...]}\`. Cursor mode + warnings compose: \`{notes, next_cursor, warnings}\`. Absent \`warnings\` key means nothing to flag — don't assume its presence either way
|
|
247
|
+
- Warnings present (e.g. an unrecognized \`tag\`) and NOT in cursor mode: \`{notes: [...], warnings: [...]}\`. Cursor mode + warnings compose: \`{notes, next_cursor, warnings}\`. Absent \`warnings\` key means nothing to flag — don't assume its presence either way.
|
|
248
|
+
|
|
249
|
+
\`search\` is literal-by-default (vault#551): your text is escaped and phrase-quoted before it reaches FTS5, so ordinary punctuation ("didn't", "eleven-day", "18.6") is matched as literal content instead of being parsed as query syntax (a bare hyphen used to mean NOT; an apostrophe or decimal point used to break the parse and silently return \`[]\`). Pass \`search_mode: "advanced"\` to opt back into raw FTS5 syntax (AND/OR/NOT, manual phrase quoting, prefix \`*\`) — a malformed advanced query now throws a structured error instead of silently returning \`[]\`. \`sort\` is honored under \`search\` too: omit it for relevance ranking (default), or pass "asc"/"desc" to order by \`created_at\` instead.`,
|
|
247
250
|
inputSchema: {
|
|
248
251
|
type: "object",
|
|
249
252
|
properties: {
|
|
@@ -297,7 +300,17 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
297
300
|
],
|
|
298
301
|
description: "Filter by file extension (vault#328). Pass a single extension (e.g. \"csv\") or an array (e.g. [\"csv\", \"yaml\", \"json\"]). Notes default to \"md\"; case-insensitive match.",
|
|
299
302
|
},
|
|
300
|
-
search: {
|
|
303
|
+
search: {
|
|
304
|
+
type: "string",
|
|
305
|
+
description:
|
|
306
|
+
'Full-text search query. Literal-by-default (vault#551): your text is escaped and phrase-quoted before reaching FTS5, so punctuation ("didn\'t", "eleven-day", "18.6") is matched as literal content rather than parsed as FTS5 query syntax. Pass `search_mode: "advanced"` for raw FTS5 syntax (boolean/phrase/prefix operators). `sort` is honored under search (see below) — default is relevance ranking.',
|
|
307
|
+
},
|
|
308
|
+
search_mode: {
|
|
309
|
+
type: "string",
|
|
310
|
+
enum: [...SEARCH_MODES],
|
|
311
|
+
description:
|
|
312
|
+
'How `search` text is turned into an FTS5 query (vault#551). "literal" (DEFAULT): escape + phrase-quote the text so punctuation is literal content, not FTS5 syntax — the fix for `search: "didn\'t"` / "eleven-day" / "18.6" silently returning `[]`. "advanced": pass the text through to FTS5 raw, for callers who want boolean (AND/OR/NOT), manual phrase quoting, or prefix (`*`) syntax — a malformed advanced query throws a structured error (`error_type: "invalid_search_syntax"`) instead of silently returning `[]`. Has no effect without `search` (an `ignored_param` warning fires if you pass it without `search`). Omit for the default ("literal").',
|
|
313
|
+
},
|
|
301
314
|
metadata: {
|
|
302
315
|
type: "object",
|
|
303
316
|
description: "Filter by metadata values. Each value is either a primitive (exact match, scans JSON) or an operator object: `{eq|ne|gt|gte|lt|lte|in|not_in|exists: value}`. Operator objects require the field to be declared `indexed: true` in a tag schema — they route through the backing B-tree index. Multiple operators on one field AND together (e.g. `{gt: 5, lt: 10}`). `in`/`not_in` take arrays; `exists` takes a boolean.",
|
|
@@ -328,7 +341,12 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
328
341
|
required: ["note_id"],
|
|
329
342
|
description: "Scope results to notes within N hops of an anchor note",
|
|
330
343
|
},
|
|
331
|
-
sort: {
|
|
344
|
+
sort: {
|
|
345
|
+
type: "string",
|
|
346
|
+
enum: ["asc", "desc"],
|
|
347
|
+
description:
|
|
348
|
+
'Sort by created_at. Under a structured query this is the only ordering (default "asc"). Under `search` (vault#551): omit for FTS5 relevance ranking (default, unchanged) — pass "asc"/"desc" to EXPLICITLY switch to created_at ordering instead of relevance.',
|
|
349
|
+
},
|
|
332
350
|
limit: { type: "number", description: "Max results (default 50)" },
|
|
333
351
|
offset: { type: "number", description: "Pagination offset (default 0)" },
|
|
334
352
|
cursor: {
|
|
@@ -502,13 +520,37 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
502
520
|
expand = params.expand as TagExpandMode;
|
|
503
521
|
}
|
|
504
522
|
|
|
523
|
+
// `search_mode` (vault#551) — validate loudly (same policy as
|
|
524
|
+
// `expand` above) so a typo'd value doesn't silently fall back to
|
|
525
|
+
// the default. Resolved here (before the search/structured branch)
|
|
526
|
+
// because BOTH branches need to know whether it was passed: the
|
|
527
|
+
// search branch to select escaping behavior, the structured branch
|
|
528
|
+
// to warn that it's being ignored.
|
|
529
|
+
let searchMode: SearchMode | undefined;
|
|
530
|
+
if (params.search_mode !== undefined && params.search_mode !== null) {
|
|
531
|
+
if (typeof params.search_mode !== "string" || !isValidSearchMode(params.search_mode)) {
|
|
532
|
+
throw new QueryError(
|
|
533
|
+
`invalid \`search_mode\` value ${JSON.stringify(params.search_mode)} — must be one of ${SEARCH_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("literal").`,
|
|
534
|
+
"INVALID_QUERY",
|
|
535
|
+
{
|
|
536
|
+
error_type: "invalid_query",
|
|
537
|
+
field: "search_mode",
|
|
538
|
+
got: params.search_mode,
|
|
539
|
+
hint: `pass "literal" or "advanced", or omit for the default ("literal")`,
|
|
540
|
+
},
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
searchMode = params.search_mode;
|
|
544
|
+
}
|
|
545
|
+
|
|
505
546
|
// --- Full-text search ---
|
|
506
547
|
let results: Note[];
|
|
507
548
|
let nextCursor: string | null = null;
|
|
508
|
-
// Warnings channel (vault#550)
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
549
|
+
// Warnings channel (vault#550). `search=` warnings (`empty_search`,
|
|
550
|
+
// `ignored_param` for a stray `search_mode`) joined the channel at
|
|
551
|
+
// #551 — the rest (`unknown_tag`) stays structured-query only.
|
|
552
|
+
// Scope-unaware by design (see `core/src/query-warnings.ts` doc
|
|
553
|
+
// comment) — a tag-scoped MCP session gets these stripped by the
|
|
512
554
|
// `applyTagScopeWrappers` query-notes wrapper in `src/mcp-tools.ts`
|
|
513
555
|
// before the result reaches the caller, so an out-of-scope tag name
|
|
514
556
|
// never leaks via `did_you_mean`.
|
|
@@ -516,18 +558,47 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
516
558
|
if (params.search) {
|
|
517
559
|
// Normalize tag param
|
|
518
560
|
const tags = normalizeTags(params.tag);
|
|
519
|
-
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
}
|
|
561
|
+
const mode: SearchMode = searchMode ?? "literal";
|
|
562
|
+
// "Only whitespace/quotes" (vault#551 edge case): short-circuit
|
|
563
|
+
// BEFORE ever calling FTS5 — an empty/all-punctuation phrase can
|
|
564
|
+
// be a syntax error (or a meaningless always-empty query)
|
|
565
|
+
// depending on exactly how it degenerates, so this is checked
|
|
566
|
+
// here rather than left to the DB layer to (maybe) reject.
|
|
567
|
+
if (mode === "literal" && buildLiteralSearchQuery(params.search as string).isEmpty) {
|
|
568
|
+
results = [];
|
|
569
|
+
queryWarnings.push(emptySearchWarning());
|
|
570
|
+
} else {
|
|
571
|
+
// Route through `store.searchNotes` (not `noteOps.searchNotes`) so
|
|
572
|
+
// tag-hierarchy expansion fires for MCP callers the same as for
|
|
573
|
+
// HTTP REST callers — `tag: "manual"` matches descendants declared
|
|
574
|
+
// via `_tags/*` config notes. Mirrors the structured-query fix
|
|
575
|
+
// from #214; same class of bypass bug (tracked as #227). A
|
|
576
|
+
// malformed advanced-mode query throws here (structured
|
|
577
|
+
// `invalid_search_syntax`, vault#551) — uncaught on purpose, it
|
|
578
|
+
// propagates to `src/mcp-http.ts`, which maps it to a JSON-RPC
|
|
579
|
+
// error the same way it maps `invalid_query`.
|
|
580
|
+
results = await store.searchNotes(params.search as string, {
|
|
581
|
+
tags,
|
|
582
|
+
limit: (params.limit as number) ?? 50,
|
|
583
|
+
expand,
|
|
584
|
+
mode,
|
|
585
|
+
sort: params.sort as "asc" | "desc" | undefined,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
529
588
|
} else {
|
|
530
589
|
// --- Structured query ---
|
|
590
|
+
// `search_mode` only shapes how `search` text becomes an FTS5
|
|
591
|
+
// query — passing it without `search` is almost always a mistake
|
|
592
|
+
// (meant to pass `search` too), so flag it rather than silently
|
|
593
|
+
// doing nothing with it.
|
|
594
|
+
if (searchMode !== undefined) {
|
|
595
|
+
queryWarnings.push(
|
|
596
|
+
ignoredParamWarning(
|
|
597
|
+
"search_mode",
|
|
598
|
+
"no `search` was provided — search_mode only affects full-text search query parsing",
|
|
599
|
+
),
|
|
600
|
+
);
|
|
601
|
+
}
|
|
531
602
|
const tags = normalizeTags(params.tag);
|
|
532
603
|
// Accept canonical `exclude_tags` plus camelCase / singular aliases.
|
|
533
604
|
// LLM callers frequently pick the wrong name (training-data drift
|
|
@@ -574,7 +645,12 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
574
645
|
offset: params.offset as number | undefined,
|
|
575
646
|
cursor: cursorMode ? (params.cursor as string) : undefined,
|
|
576
647
|
};
|
|
577
|
-
|
|
648
|
+
// Concatenate (not overwrite) — `queryWarnings` may already carry
|
|
649
|
+
// the `ignored_param` warning for a stray `search_mode` pushed
|
|
650
|
+
// above (vault#551).
|
|
651
|
+
queryWarnings = queryWarnings.concat(
|
|
652
|
+
collectUnknownTagWarnings(db, queryOpts.tags, queryOpts.expand, store.getTagHierarchy()),
|
|
653
|
+
);
|
|
578
654
|
if (cursorMode) {
|
|
579
655
|
const page = await store.queryNotesPaged(queryOpts);
|
|
580
656
|
results = page.notes;
|
package/core/src/notes.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import { getIndexedField, releaseField } from "./indexed-fields.js";
|
|
23
23
|
import { computeExpandedTagCounts, loadTagHierarchy, stripTagHash } from "./tag-hierarchy.js";
|
|
24
24
|
import { chunkForInClause, IN_VIA_JSON_EACH, jsonEachParam } from "./sql-in.js";
|
|
25
|
+
import { buildLiteralSearchQuery, type SearchMode } from "./search-query.js";
|
|
25
26
|
|
|
26
27
|
let idCounter = 0;
|
|
27
28
|
|
|
@@ -1309,12 +1310,78 @@ export function queryNotesPaged(db: Database, opts: QueryOpts): QueryNotesPage {
|
|
|
1309
1310
|
return { notes, next_cursor };
|
|
1310
1311
|
}
|
|
1311
1312
|
|
|
1313
|
+
/**
|
|
1314
|
+
* Turn a thrown FTS5 MATCH error into the structured `invalid_search_syntax`
|
|
1315
|
+
* shape (vault#551, WS2A item 2). ALWAYS structured, never a raw rethrow —
|
|
1316
|
+
* a raw `SQLiteError` escaping to the transport is an unstructured 500
|
|
1317
|
+
* (REST) / generic `isError` text (MCP), which is exactly the swallowed-
|
|
1318
|
+
* failure class this wave is closing.
|
|
1319
|
+
*
|
|
1320
|
+
* The two modes carry different hints:
|
|
1321
|
+
* - advanced: the caller passed raw FTS5 syntax that FTS5 rejected — tell
|
|
1322
|
+
* them to fix it or drop back to literal.
|
|
1323
|
+
* - literal: escaping + control-char sanitization
|
|
1324
|
+
* (`buildLiteralSearchQuery`) make every input syntactically valid
|
|
1325
|
+
* FTS5, so this is unreachable by construction — the belt-and-suspenders
|
|
1326
|
+
* structured error (rather than a raw rethrow) means that IF the
|
|
1327
|
+
* invariant is ever broken it still surfaces as an honest error, not a
|
|
1328
|
+
* 500. Signals a vault bug worth reporting.
|
|
1329
|
+
*/
|
|
1330
|
+
function searchSyntaxError(rawQuery: string, err: unknown, mode: SearchMode): QueryError {
|
|
1331
|
+
const causeMessage = err instanceof Error ? err.message : String(err);
|
|
1332
|
+
const hint =
|
|
1333
|
+
mode === "advanced"
|
|
1334
|
+
? `FTS5 rejected this as advanced query syntax (${causeMessage}). Fix the syntax, or omit search_mode:"advanced" for literal (punctuation-safe) search.`
|
|
1335
|
+
: `FTS5 rejected the escaped literal query (${causeMessage}) — this should be impossible after literal-mode escaping + control-char sanitization; please report it as a vault bug.`;
|
|
1336
|
+
return new QueryError(`invalid search syntax: ${causeMessage}`, "INVALID_QUERY", {
|
|
1337
|
+
error_type: "invalid_search_syntax",
|
|
1338
|
+
field: "search",
|
|
1339
|
+
got: rawQuery,
|
|
1340
|
+
hint,
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1312
1344
|
export function searchNotes(
|
|
1313
1345
|
db: Database,
|
|
1314
1346
|
query: string,
|
|
1315
|
-
opts?: { tags?: string[]; limit?: number },
|
|
1347
|
+
opts?: { tags?: string[]; limit?: number; mode?: SearchMode; sort?: "asc" | "desc" },
|
|
1316
1348
|
): Note[] {
|
|
1317
1349
|
const limit = typeof opts?.limit === "number" ? opts.limit : 50;
|
|
1350
|
+
// Literal-by-default (vault#551): escape the caller's text so FTS5's own
|
|
1351
|
+
// query syntax (hyphen = NOT, apostrophe/period = tokenizer breakage,
|
|
1352
|
+
// etc.) is treated as ordinary content, not syntax. `search_mode:
|
|
1353
|
+
// "advanced"` opts back into raw FTS5 query syntax — today's pre-#551
|
|
1354
|
+
// behavior, unchanged — for callers who want boolean/phrase/prefix
|
|
1355
|
+
// operators.
|
|
1356
|
+
const mode: SearchMode = opts?.mode ?? "literal";
|
|
1357
|
+
let ftsQuery: string;
|
|
1358
|
+
if (mode === "literal") {
|
|
1359
|
+
const built = buildLiteralSearchQuery(query);
|
|
1360
|
+
// "Only whitespace/quotes" (vault#551 edge case): the caller (store.ts /
|
|
1361
|
+
// the REST + MCP entry points) is expected to short-circuit this case
|
|
1362
|
+
// itself (with an `empty_search` warning) before ever reaching here —
|
|
1363
|
+
// this is a defensive fallback for any direct-core caller that skips
|
|
1364
|
+
// that check.
|
|
1365
|
+
if (built.isEmpty) return [];
|
|
1366
|
+
ftsQuery = built.query;
|
|
1367
|
+
} else {
|
|
1368
|
+
ftsQuery = query;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// `sort` honored under search (vault#551 WS2A item 3): default stays FTS5
|
|
1372
|
+
// relevance (`rank`, unchanged); an EXPLICIT `sort: "asc"|"desc"` switches
|
|
1373
|
+
// to `created_at` ordering. Checked against the literal string values (not
|
|
1374
|
+
// truthiness) so an absent `sort` can never accidentally match a branch.
|
|
1375
|
+
// `n.id ${direction}` is appended as a deterministic tiebreaker — same
|
|
1376
|
+
// rationale as `queryNotes` (two notes at the same created_at millisecond
|
|
1377
|
+
// would otherwise return in arbitrary/unstable order). `rank` needs no
|
|
1378
|
+
// tiebreaker (bm25 score is effectively unique per row).
|
|
1379
|
+
const orderBy =
|
|
1380
|
+
opts?.sort === "asc"
|
|
1381
|
+
? "n.created_at ASC, n.id ASC"
|
|
1382
|
+
: opts?.sort === "desc"
|
|
1383
|
+
? "n.created_at DESC, n.id DESC"
|
|
1384
|
+
: "rank";
|
|
1318
1385
|
|
|
1319
1386
|
if (opts?.tags && opts.tags.length > 0) {
|
|
1320
1387
|
// Canonical-bare-tag guard backstop (vault#XXX) for direct-core callers.
|
|
@@ -1334,12 +1401,19 @@ export function searchNotes(
|
|
|
1334
1401
|
JOIN notes_fts fts ON fts.rowid = n.rowid
|
|
1335
1402
|
WHERE notes_fts MATCH ?
|
|
1336
1403
|
AND n.id IN (SELECT note_id FROM note_tags WHERE tag_name IN (${tagPlaceholders}))
|
|
1337
|
-
ORDER BY
|
|
1404
|
+
ORDER BY ${orderBy}
|
|
1338
1405
|
LIMIT ?
|
|
1339
|
-
`).all(
|
|
1406
|
+
`).all(ftsQuery, ...searchTags, limit) as NoteRow[];
|
|
1340
1407
|
return notesWithTags(db, rows);
|
|
1341
|
-
} catch {
|
|
1342
|
-
|
|
1408
|
+
} catch (err) {
|
|
1409
|
+
// Surface EVERY FTS5 error structured, never a raw rethrow (vault#551):
|
|
1410
|
+
// advanced mode expects it (the caller passed raw syntax); literal
|
|
1411
|
+
// mode should never reach it (escaping + control-char sanitization
|
|
1412
|
+
// make the query valid) but if the invariant breaks we still want an
|
|
1413
|
+
// honest error, not an unstructured 500. A QueryError we threw
|
|
1414
|
+
// ourselves (empty-limit validation, etc.) is re-raised untouched.
|
|
1415
|
+
if (err instanceof QueryError) throw err;
|
|
1416
|
+
throw searchSyntaxError(query, err, mode);
|
|
1343
1417
|
}
|
|
1344
1418
|
}
|
|
1345
1419
|
}
|
|
@@ -1349,12 +1423,13 @@ export function searchNotes(
|
|
|
1349
1423
|
SELECT n.* FROM notes n
|
|
1350
1424
|
JOIN notes_fts fts ON fts.rowid = n.rowid
|
|
1351
1425
|
WHERE notes_fts MATCH ?
|
|
1352
|
-
ORDER BY
|
|
1426
|
+
ORDER BY ${orderBy}
|
|
1353
1427
|
LIMIT ?
|
|
1354
|
-
`).all(
|
|
1428
|
+
`).all(ftsQuery, limit) as NoteRow[];
|
|
1355
1429
|
return notesWithTags(db, rows);
|
|
1356
|
-
} catch {
|
|
1357
|
-
|
|
1430
|
+
} catch (err) {
|
|
1431
|
+
if (err instanceof QueryError) throw err;
|
|
1432
|
+
throw searchSyntaxError(query, err, mode);
|
|
1358
1433
|
}
|
|
1359
1434
|
}
|
|
1360
1435
|
|
|
@@ -182,3 +182,38 @@ export function collectUnknownTagWarnings(
|
|
|
182
182
|
}
|
|
183
183
|
return warnings;
|
|
184
184
|
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* `empty_search` warning (vault#551) — the `search` string carried no
|
|
188
|
+
* literal content: blank/whitespace-only, or (in literal mode, where a
|
|
189
|
+
* manually-typed `"` is ordinary content rather than syntax) nothing but
|
|
190
|
+
* quote characters. Rather than let this degenerate into an FTS5 syntax
|
|
191
|
+
* error or a confusing always-empty-but-syntactically-valid query, the
|
|
192
|
+
* search path short-circuits BEFORE ever calling FTS5 and reports this
|
|
193
|
+
* warning alongside an honest `[]`. See `core/src/search-query.ts`
|
|
194
|
+
* `buildLiteralSearchQuery`, which is the choke point that detects this.
|
|
195
|
+
*/
|
|
196
|
+
export function emptySearchWarning(): QueryWarning {
|
|
197
|
+
return {
|
|
198
|
+
code: "empty_search",
|
|
199
|
+
message:
|
|
200
|
+
"the search query has no literal content (blank, or only whitespace/quote characters) — returning no results without querying FTS5.",
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* `ignored_param` warning (vault#551) — a query-shaping param was passed
|
|
206
|
+
* but has no effect given the rest of the request. First (and so far only)
|
|
207
|
+
* case: `search_mode` without `search` — the mode only shapes how `search`
|
|
208
|
+
* text is turned into an FTS5 query, so passing it alone almost always
|
|
209
|
+
* means the caller meant to pass `search` too. Generic over `param` /
|
|
210
|
+
* `reason` so a future ignored-param case can reuse the same shape instead
|
|
211
|
+
* of inventing a new warning code.
|
|
212
|
+
*/
|
|
213
|
+
export function ignoredParamWarning(param: string, reason: string): QueryWarning {
|
|
214
|
+
return {
|
|
215
|
+
code: "ignored_param",
|
|
216
|
+
message: `\`${param}\` has no effect: ${reason}`,
|
|
217
|
+
param,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
SEARCH_MODES,
|
|
4
|
+
escapeFtsToken,
|
|
5
|
+
buildLiteralSearchQuery,
|
|
6
|
+
isValidSearchMode,
|
|
7
|
+
} from "./search-query.js";
|
|
8
|
+
|
|
9
|
+
describe("escapeFtsToken", () => {
|
|
10
|
+
it("wraps a plain token in quotes", () => {
|
|
11
|
+
expect(escapeFtsToken("widgets")).toBe(`"widgets"`);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("doubles an embedded quote", () => {
|
|
15
|
+
expect(escapeFtsToken(`say"hi`)).toBe(`"say""hi"`);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("doubles multiple embedded quotes", () => {
|
|
19
|
+
expect(escapeFtsToken(`"eleven-day`)).toBe(`"""eleven-day"`);
|
|
20
|
+
expect(escapeFtsToken(`delay"`)).toBe(`"delay"""`);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("preserves punctuation that isn't a quote (hyphen, period, apostrophe)", () => {
|
|
24
|
+
expect(escapeFtsToken("eleven-day")).toBe(`"eleven-day"`);
|
|
25
|
+
expect(escapeFtsToken("18.6")).toBe(`"18.6"`);
|
|
26
|
+
expect(escapeFtsToken("didn't")).toBe(`"didn't"`);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("buildLiteralSearchQuery", () => {
|
|
31
|
+
it("wraps a single token", () => {
|
|
32
|
+
const r = buildLiteralSearchQuery("widgets");
|
|
33
|
+
expect(r.isEmpty).toBe(false);
|
|
34
|
+
expect(r.query).toBe(`"widgets"`);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("phrase-quotes each whitespace-delimited token and ANDs them (vault#551 root fix)", () => {
|
|
38
|
+
const r = buildLiteralSearchQuery("eleven-day capping delay");
|
|
39
|
+
expect(r.isEmpty).toBe(false);
|
|
40
|
+
expect(r.query).toBe(`"eleven-day" "capping" "delay"`);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("escapes an apostrophe as literal content, not syntax", () => {
|
|
44
|
+
const r = buildLiteralSearchQuery("didn't");
|
|
45
|
+
expect(r.isEmpty).toBe(false);
|
|
46
|
+
expect(r.query).toBe(`"didn't"`);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("escapes a decimal point as literal content, not syntax", () => {
|
|
50
|
+
const r = buildLiteralSearchQuery("18.6");
|
|
51
|
+
expect(r.isEmpty).toBe(false);
|
|
52
|
+
expect(r.query).toBe(`"18.6"`);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("collapses runs of whitespace between tokens", () => {
|
|
56
|
+
const r = buildLiteralSearchQuery("widgets gadgets");
|
|
57
|
+
expect(r.query).toBe(`"widgets" "gadgets"`);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("trims leading/trailing whitespace", () => {
|
|
61
|
+
const r = buildLiteralSearchQuery(" widgets ");
|
|
62
|
+
expect(r.query).toBe(`"widgets"`);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("escapes a manually-quoted phrase's embedded quote characters as content", () => {
|
|
66
|
+
// The user typed literal quote marks expecting phrase syntax; in
|
|
67
|
+
// literal mode those quote characters are just more content that gets
|
|
68
|
+
// escaped like anything else (vault#551 — this is CORRECT, not a bug:
|
|
69
|
+
// raw phrase/boolean/prefix syntax now requires search_mode:"advanced").
|
|
70
|
+
const r = buildLiteralSearchQuery(`"eleven-day capping delay"`);
|
|
71
|
+
expect(r.isEmpty).toBe(false);
|
|
72
|
+
expect(r.query).toBe(`"""eleven-day" "capping" "delay"""`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("treats a blank string as empty", () => {
|
|
76
|
+
expect(buildLiteralSearchQuery("").isEmpty).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("treats a whitespace-only string as empty", () => {
|
|
80
|
+
expect(buildLiteralSearchQuery(" ").isEmpty).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("treats a lone quote character as empty", () => {
|
|
84
|
+
expect(buildLiteralSearchQuery(`"`).isEmpty).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("treats a quotes-and-whitespace-only string as empty", () => {
|
|
88
|
+
expect(buildLiteralSearchQuery(`" " "`).isEmpty).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("an empty-flagged result never carries a query string callers should use", () => {
|
|
92
|
+
const r = buildLiteralSearchQuery(" ");
|
|
93
|
+
expect(r.isEmpty).toBe(true);
|
|
94
|
+
expect(r.query).toBe("");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Control-character sanitization (vault#551 review fold) — a NUL byte
|
|
98
|
+
// (or any C0/DEL control) inside a token would otherwise crash FTS5's
|
|
99
|
+
// C-string parser with a raw `unterminated string` error, breaking the
|
|
100
|
+
// "literal mode cannot throw" guarantee.
|
|
101
|
+
it("splits a token on an embedded NUL byte — both halves stay searchable", () => {
|
|
102
|
+
const r = buildLiteralSearchQuery("hello\x00world");
|
|
103
|
+
expect(r.isEmpty).toBe(false);
|
|
104
|
+
expect(r.query).toBe(`"hello" "world"`);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("treats a NUL-only string as empty (short-circuits, never reaches FTS5)", () => {
|
|
108
|
+
const r = buildLiteralSearchQuery("\x00");
|
|
109
|
+
expect(r.isEmpty).toBe(true);
|
|
110
|
+
expect(r.query).toBe("");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("collapses a run of NUL/control bytes between tokens like whitespace", () => {
|
|
114
|
+
const r = buildLiteralSearchQuery("hello\x00\x00world");
|
|
115
|
+
expect(r.query).toBe(`"hello" "world"`);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("sanitizes the other C0 controls and DEL (0x01-0x1F, 0x7F) as separators too", () => {
|
|
119
|
+
expect(buildLiteralSearchQuery("a\x01\x02b").query).toBe(`"a" "b"`);
|
|
120
|
+
expect(buildLiteralSearchQuery("a\x1fb").query).toBe(`"a" "b"`);
|
|
121
|
+
expect(buildLiteralSearchQuery("a\x7fb").query).toBe(`"a" "b"`);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("a control byte adjacent to a real token still yields the clean token", () => {
|
|
125
|
+
expect(buildLiteralSearchQuery("\x00hello\x00").query).toBe(`"hello"`);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("isValidSearchMode / SEARCH_MODES", () => {
|
|
130
|
+
it("SEARCH_MODES is exactly literal + advanced", () => {
|
|
131
|
+
expect(SEARCH_MODES).toEqual(["literal", "advanced"]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("accepts literal and advanced", () => {
|
|
135
|
+
expect(isValidSearchMode("literal")).toBe(true);
|
|
136
|
+
expect(isValidSearchMode("advanced")).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("rejects an unknown string", () => {
|
|
140
|
+
expect(isValidSearchMode("bogus")).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("rejects non-string values", () => {
|
|
144
|
+
expect(isValidSearchMode(123)).toBe(false);
|
|
145
|
+
expect(isValidSearchMode(null)).toBe(false);
|
|
146
|
+
expect(isValidSearchMode(undefined)).toBe(false);
|
|
147
|
+
expect(isValidSearchMode(["literal"])).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
Binary file
|
package/core/src/store.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
countConformanceViolations,
|
|
35
35
|
type ConformanceReport,
|
|
36
36
|
} from "./conformance.js";
|
|
37
|
+
import type { SearchMode } from "./search-query.js";
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* bun:sqlite-backed Store implementation. Internally everything is
|
|
@@ -375,7 +376,7 @@ export class BunSqliteStore implements Store {
|
|
|
375
376
|
return { ...opts, _tagsExpanded: expanded } as QueryOpts;
|
|
376
377
|
}
|
|
377
378
|
|
|
378
|
-
async searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode }): Promise<Note[]> {
|
|
379
|
+
async searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode; mode?: SearchMode; sort?: "asc" | "desc" }): Promise<Note[]> {
|
|
379
380
|
// Canonical-bare-tag guard (vault#XXX): strip leading `#` from search tag
|
|
380
381
|
// filters before expansion, so `#manual` and `manual` resolve identically.
|
|
381
382
|
if (opts?.tags && opts.tags.length > 0) {
|
package/core/src/types.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Database } from "bun:sqlite";
|
|
|
2
2
|
import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
|
|
3
3
|
import type { PrunedField } from "./indexed-fields.js";
|
|
4
4
|
import type { TagExpandMode, TagHierarchy } from "./tag-hierarchy.js";
|
|
5
|
+
import type { SearchMode } from "./search-query.js";
|
|
5
6
|
import type { ValidationStatus } from "./schema-defaults.js";
|
|
6
7
|
import type { ConformanceReport } from "./conformance.js";
|
|
7
8
|
import type { FindPathResult } from "./links.js";
|
|
@@ -314,7 +315,18 @@ export interface Store {
|
|
|
314
315
|
* agent loop can persist a single watermark and keep polling.
|
|
315
316
|
*/
|
|
316
317
|
queryNotesPaged(opts: QueryOpts): Promise<QueryNotesPage>;
|
|
317
|
-
|
|
318
|
+
/**
|
|
319
|
+
* `mode` (vault#551 — literal-by-default): "literal" (default) escapes +
|
|
320
|
+
* phrase-quotes the query so FTS5 punctuation syntax (hyphen = NOT, an
|
|
321
|
+
* apostrophe/period breaking the parse, ...) is treated as ordinary
|
|
322
|
+
* content. "advanced" passes `query` through to FTS5 raw (pre-#551
|
|
323
|
+
* behavior) for callers who want boolean/phrase/prefix operators — a
|
|
324
|
+
* syntax error in that mode throws (`error_type: "invalid_search_syntax"`)
|
|
325
|
+
* rather than silently returning `[]`. `sort` (vault#551): omitted stays
|
|
326
|
+
* FTS5 relevance ranking (default); an explicit "asc"/"desc" switches to
|
|
327
|
+
* `created_at` ordering. See `core/src/search-query.ts`.
|
|
328
|
+
*/
|
|
329
|
+
searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode; mode?: SearchMode; sort?: "asc" | "desc" }): Promise<Note[]>;
|
|
318
330
|
|
|
319
331
|
// Tags
|
|
320
332
|
tagNote(noteId: string, tags: string[]): Promise<void>;
|
package/package.json
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Contract suite — search (Wave 1
|
|
3
|
-
* umbrella #556). Encodes the 2026-07-09
|
|
4
|
-
*
|
|
5
|
-
* that is correct today; `test.todo`
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* Contract suite — search (Wave 1's findings, Wave 3's fix — Reliability &
|
|
3
|
+
* Usability Program, umbrella #556, WS2A #551). Encodes the 2026-07-09
|
|
4
|
+
* nine-persona deep test's search findings as executable tests: PASSING
|
|
5
|
+
* tests lock in behavior that is correct today; the #551 `test.todo`
|
|
6
|
+
* entries this file used to carry have all been flipped to real assertions
|
|
7
|
+
* against the literal-by-default fix (escaping, `search_mode: "advanced"`,
|
|
8
|
+
* honored `sort`, structured `invalid_search_syntax`) — see #551 for the
|
|
9
|
+
* full write-up. Covers `src/routes.ts` (the REST surface) plus a handful
|
|
10
|
+
* of MCP-parity checks (`core/src/mcp.ts`) where the two surfaces share one
|
|
11
|
+
* implementation (`core/src/notes.ts` `searchNotes` + `core/src/search-
|
|
12
|
+
* query.ts`) and a REST-only test wouldn't prove the MCP side actually got
|
|
13
|
+
* the fix — mirrors the pattern in `contract-honest-queries.test.ts`.
|
|
8
14
|
*
|
|
9
15
|
* Ground truth for every assertion here was re-verified live against this
|
|
10
16
|
* repo's FTS5 search path (`core/src/notes.ts` searchNotes → REST `GET
|
|
@@ -16,6 +22,7 @@ import { describe, it, test, expect, beforeEach, afterEach } from "bun:test";
|
|
|
16
22
|
import { Database } from "bun:sqlite";
|
|
17
23
|
import { BunStore } from "./vault-store.ts";
|
|
18
24
|
import { handleNotes } from "./routes.ts";
|
|
25
|
+
import { generateMcpTools } from "../core/src/mcp.ts";
|
|
19
26
|
|
|
20
27
|
let db: Database;
|
|
21
28
|
let store: BunStore;
|
|
@@ -26,6 +33,13 @@ function search(qs: string): Promise<Response> {
|
|
|
26
33
|
return handleNotes(new Request(`${BASE}/notes?${qs}`, { method: "GET" }), store, "");
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
/** Decode the `X-Parachute-Warnings` header (vault#550 — percent-encoded JSON; see `jsonWithWarnings` in routes.ts). */
|
|
37
|
+
function decodeWarningsHeader(res: Response): any[] | null {
|
|
38
|
+
const raw = res.headers.get("X-Parachute-Warnings");
|
|
39
|
+
if (!raw) return null;
|
|
40
|
+
return JSON.parse(decodeURIComponent(raw));
|
|
41
|
+
}
|
|
42
|
+
|
|
29
43
|
/** Planted corpus — each note exists to exercise exactly one FTS5 quirk. */
|
|
30
44
|
const NOTES = {
|
|
31
45
|
hyphenPhrase: "The rollout had an eleven-day capping delay before it shipped.",
|
|
@@ -76,22 +90,36 @@ describe("contract: search — passing (lock in current behavior)", () => {
|
|
|
76
90
|
expect(body.map((n: any) => n.content)).toEqual([NOTES.bothWords]);
|
|
77
91
|
});
|
|
78
92
|
|
|
79
|
-
|
|
80
|
-
|
|
93
|
+
// The next three tests manually quote the search string, expecting raw
|
|
94
|
+
// FTS5 phrase syntax to be honored. That's advanced-mode behavior as of
|
|
95
|
+
// #551 (literal-by-default escapes a caller's own `"` characters as
|
|
96
|
+
// ordinary content instead of honoring them as phrase syntax) — updated
|
|
97
|
+
// to pass `search_mode: "advanced"` explicitly. (Note for reviewers:
|
|
98
|
+
// empirically the bare literal-mode escaping of these SAME manually-
|
|
99
|
+
// quoted strings still resolves to the identical result set for this
|
|
100
|
+
// corpus — FTS5's tokenizer strips the embedded `"` characters the same
|
|
101
|
+
// way it strips other punctuation — but the semantic guarantee these
|
|
102
|
+
// tests were written to pin (phrase ADJACENCY honored as syntax) only
|
|
103
|
+
// holds under advanced mode; literal mode no longer enforces adjacency
|
|
104
|
+
// at all, it just happens to agree here because no other note contains
|
|
105
|
+
// a subset of the same words. See #551 test coverage for the literal-
|
|
106
|
+
// mode equivalents of these same strings, unquoted, further down.)
|
|
107
|
+
it('quoted phrase finds hyphenated phrase text: "eleven-day capping delay" (search_mode: "advanced")', async () => {
|
|
108
|
+
const res = await search(`search=${encodeURIComponent('"eleven-day capping delay"')}&search_mode=advanced&include_content=true`);
|
|
81
109
|
expect(res.status).toBe(200);
|
|
82
110
|
const body = await bodyOf(res);
|
|
83
111
|
expect(body.map((n: any) => n.content)).toEqual([NOTES.hyphenPhrase]);
|
|
84
112
|
});
|
|
85
113
|
|
|
86
|
-
it('quoted decimal literal finds it: "18.6"', async () => {
|
|
87
|
-
const res = await search(`search=${encodeURIComponent('"18.6"')}&include_content=true`);
|
|
114
|
+
it('quoted decimal literal finds it: "18.6" (search_mode: "advanced")', async () => {
|
|
115
|
+
const res = await search(`search=${encodeURIComponent('"18.6"')}&search_mode=advanced&include_content=true`);
|
|
88
116
|
expect(res.status).toBe(200);
|
|
89
117
|
const body = await bodyOf(res);
|
|
90
118
|
expect(body.map((n: any) => n.content)).toEqual([NOTES.decimal]);
|
|
91
119
|
});
|
|
92
120
|
|
|
93
|
-
it('quoted contraction finds it: "didn\'t"', async () => {
|
|
94
|
-
const res = await search(`search=${encodeURIComponent(`"didn't"`)}&include_content=true`);
|
|
121
|
+
it('quoted contraction finds it: "didn\'t" (search_mode: "advanced")', async () => {
|
|
122
|
+
const res = await search(`search=${encodeURIComponent(`"didn't"`)}&search_mode=advanced&include_content=true`);
|
|
95
123
|
expect(res.status).toBe(200);
|
|
96
124
|
const body = await bodyOf(res);
|
|
97
125
|
expect(body.map((n: any) => n.content)).toEqual([NOTES.contraction]);
|
|
@@ -105,23 +133,227 @@ describe("contract: search — passing (lock in current behavior)", () => {
|
|
|
105
133
|
});
|
|
106
134
|
});
|
|
107
135
|
|
|
108
|
-
describe(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
describe('contract: search — literal-by-default (#551, flipped from todo)', () => {
|
|
137
|
+
it(`unquoted search "didn't" finds the contraction content (literal-by-default — the bare apostrophe used to split into two AND'd tokens and return [])`, async () => {
|
|
138
|
+
const res = await search(`search=${encodeURIComponent("didn't")}&include_content=true`);
|
|
139
|
+
expect(res.status).toBe(200);
|
|
140
|
+
const body = await bodyOf(res);
|
|
141
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.contraction]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it(`unquoted search "eleven-day capping delay" finds the hyphenated-phrase note (literal-by-default — the bare hyphenated word used to tokenize into a NOT-operator and return [])`, async () => {
|
|
145
|
+
const res = await search(`search=${encodeURIComponent("eleven-day capping delay")}&include_content=true`);
|
|
146
|
+
expect(res.status).toBe(200);
|
|
147
|
+
const body = await bodyOf(res);
|
|
148
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.hyphenPhrase]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it(`unquoted search "18.6" finds the decimal note (literal-by-default — the bare decimal used to break the FTS5 parse and return [])`, async () => {
|
|
152
|
+
const res = await search(`search=${encodeURIComponent("18.6")}&include_content=true`);
|
|
153
|
+
expect(res.status).toBe(200);
|
|
154
|
+
const body = await bodyOf(res);
|
|
155
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.decimal]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('search_mode:"advanced" preserves raw FTS5 query syntax (boolean AND/NOT, prefix *)', async () => {
|
|
159
|
+
const and = await bodyOf(
|
|
160
|
+
await search(`search=${encodeURIComponent("widgets AND gadgets")}&search_mode=advanced&include_content=true`),
|
|
161
|
+
);
|
|
162
|
+
expect(and.map((n: any) => n.content)).toEqual([NOTES.bothWords]);
|
|
163
|
+
|
|
164
|
+
const not = await bodyOf(
|
|
165
|
+
await search(`search=${encodeURIComponent("widgets NOT gadgets")}&search_mode=advanced&include_content=true`),
|
|
166
|
+
);
|
|
167
|
+
expect(not.map((n: any) => n.content)).toEqual([NOTES.widgetsOnly]);
|
|
168
|
+
|
|
169
|
+
const prefix = await bodyOf(
|
|
170
|
+
await search(`search=${encodeURIComponent("wid*")}&search_mode=advanced&include_content=true`),
|
|
171
|
+
);
|
|
172
|
+
const prefixContents = new Set(prefix.map((n: any) => n.content));
|
|
173
|
+
expect(prefixContents.has(NOTES.widgetsOnly)).toBe(true);
|
|
174
|
+
expect(prefixContents.has(NOTES.bothWords)).toBe(true);
|
|
175
|
+
expect(prefixContents.has(NOTES.gadgetsOnly)).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('sort:"asc"/"desc" changes result order under search (previously silently ignored — FTS5 rank order won regardless of the sort param)', async () => {
|
|
179
|
+
await store.createNote("sortprobe alpha content", { created_at: "2020-01-01T00:00:00.000Z" });
|
|
180
|
+
await store.createNote("sortprobe beta content", { created_at: "2022-06-15T00:00:00.000Z" });
|
|
181
|
+
await store.createNote("sortprobe gamma content", { created_at: "2024-12-31T00:00:00.000Z" });
|
|
182
|
+
|
|
183
|
+
const asc = await bodyOf(await search("search=sortprobe&sort=asc&include_content=true"));
|
|
184
|
+
expect(asc.map((n: any) => n.content)).toEqual([
|
|
185
|
+
"sortprobe alpha content",
|
|
186
|
+
"sortprobe beta content",
|
|
187
|
+
"sortprobe gamma content",
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
const desc = await bodyOf(await search("search=sortprobe&sort=desc&include_content=true"));
|
|
191
|
+
expect(desc.map((n: any) => n.content)).toEqual([
|
|
192
|
+
"sortprobe gamma content",
|
|
193
|
+
"sortprobe beta content",
|
|
194
|
+
"sortprobe alpha content",
|
|
195
|
+
]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("malformed FTS syntax under search_mode:\"advanced\" yields a structured 400 invalid_search_syntax error, not a silently swallowed []", async () => {
|
|
199
|
+
const res = await search(`search=${encodeURIComponent('"unbalanced')}&search_mode=advanced`);
|
|
200
|
+
expect(res.status).toBe(400);
|
|
201
|
+
const body = await bodyOf(res);
|
|
202
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
203
|
+
expect(body.error_type).toBe("invalid_search_syntax");
|
|
204
|
+
expect(body.field).toBe("search");
|
|
205
|
+
expect(body.got).toBe('"unbalanced');
|
|
206
|
+
expect(typeof body.hint).toBe("string");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("the SAME malformed string in literal mode (default) returns an honest result, not an error — escaping makes it syntactically valid FTS5", async () => {
|
|
210
|
+
const res = await search(`search=${encodeURIComponent('"unbalanced')}`);
|
|
211
|
+
expect(res.status).toBe(200);
|
|
212
|
+
const body = await bodyOf(res);
|
|
213
|
+
expect(Array.isArray(body)).toBe(true);
|
|
214
|
+
expect(body).toEqual([]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Review fold: a NUL byte inside a literal-mode token used to crash FTS5's
|
|
218
|
+
// C-string parser (`SQLiteError: unterminated string`) → raw rethrow →
|
|
219
|
+
// unstructured 500. Sanitization (control chars → separators) makes it a
|
|
220
|
+
// normal search. `%00` is the URL-encoding of a NUL byte.
|
|
221
|
+
it("search=hello%00world (NUL byte in a token) does NOT 500 — literal mode cannot throw", async () => {
|
|
222
|
+
await store.createNote("hello world reachable across the NUL split", { tags: ["nul"] });
|
|
223
|
+
const res = await search("search=hello%00world&include_content=true");
|
|
224
|
+
expect(res.status).toBe(200);
|
|
225
|
+
const body = await bodyOf(res);
|
|
226
|
+
expect(Array.isArray(body)).toBe(true);
|
|
227
|
+
// The NUL split the token into "hello" AND "world" — both searchable —
|
|
228
|
+
// so the planted note (containing both words) is found.
|
|
229
|
+
expect(body.some((n: any) => n.content.includes("hello world reachable"))).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("a NUL-only search short-circuits to [] with an empty_search warning, no 500", async () => {
|
|
233
|
+
const res = await search("search=%00");
|
|
234
|
+
expect(res.status).toBe(200);
|
|
235
|
+
const body = await bodyOf(res);
|
|
236
|
+
expect(body).toEqual([]);
|
|
237
|
+
const warnings = decodeWarningsHeader(res);
|
|
238
|
+
expect(warnings).not.toBeNull();
|
|
239
|
+
expect(warnings!.some((w: any) => w.code === "empty_search")).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("sort under search is STABLE at identical created_at (deterministic n.id tiebreaker, both directions)", async () => {
|
|
243
|
+
// Two notes at the SAME millisecond — without the id tiebreaker their
|
|
244
|
+
// relative order would be arbitrary/unstable (review nit).
|
|
245
|
+
const ts = "2023-03-03T03:03:03.030Z";
|
|
246
|
+
await store.createNote("tiebreak content one", { id: "tie-aaa", created_at: ts });
|
|
247
|
+
await store.createNote("tiebreak content two", { id: "tie-bbb", created_at: ts });
|
|
248
|
+
|
|
249
|
+
const asc = await bodyOf(await search("search=tiebreak&sort=asc&include_content=true"));
|
|
250
|
+
const ascIds = asc.map((n: any) => n.id);
|
|
251
|
+
// ASC id tiebreaker → tie-aaa before tie-bbb.
|
|
252
|
+
expect(ascIds).toEqual(["tie-aaa", "tie-bbb"]);
|
|
253
|
+
|
|
254
|
+
const desc = await bodyOf(await search("search=tiebreak&sort=desc&include_content=true"));
|
|
255
|
+
const descIds = desc.map((n: any) => n.id);
|
|
256
|
+
// DESC id tiebreaker → tie-bbb before tie-aaa (the exact reverse).
|
|
257
|
+
expect(descIds).toEqual(["tie-bbb", "tie-aaa"]);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("contract: search — escaping edge cases, tag-scope, warnings (#551)", () => {
|
|
262
|
+
it("a double space between tokens still finds the note containing both (whitespace runs collapse)", async () => {
|
|
263
|
+
const res = await search(`search=${encodeURIComponent("widgets gadgets")}&include_content=true`);
|
|
264
|
+
expect(res.status).toBe(200);
|
|
265
|
+
const body = await bodyOf(res);
|
|
266
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.bothWords]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("whitespace-only search short-circuits to [] with an empty_search warning, no FTS5 call/error", async () => {
|
|
270
|
+
const res = await search(`search=${encodeURIComponent(" ")}`);
|
|
271
|
+
expect(res.status).toBe(200);
|
|
272
|
+
const body = await bodyOf(res);
|
|
273
|
+
expect(body).toEqual([]);
|
|
274
|
+
const warnings = decodeWarningsHeader(res);
|
|
275
|
+
expect(warnings).not.toBeNull();
|
|
276
|
+
expect(warnings!.some((w: any) => w.code === "empty_search")).toBe(true);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('a quotes-only search (e.g. a lone `"`) short-circuits to [] with an empty_search warning', async () => {
|
|
280
|
+
const res = await search(`search=${encodeURIComponent('"')}`);
|
|
281
|
+
expect(res.status).toBe(200);
|
|
282
|
+
const body = await bodyOf(res);
|
|
283
|
+
expect(body).toEqual([]);
|
|
284
|
+
const warnings = decodeWarningsHeader(res);
|
|
285
|
+
expect(warnings).not.toBeNull();
|
|
286
|
+
expect(warnings!.some((w: any) => w.code === "empty_search")).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("tag-filtered search also gets literal escaping (the tag-scoped FTS prepared statement)", async () => {
|
|
290
|
+
await store.createNote(NOTES.hyphenPhrase, { tags: ["probe"] });
|
|
291
|
+
const res = await search(
|
|
292
|
+
`search=${encodeURIComponent("eleven-day capping delay")}&tag=probe&include_content=true`,
|
|
293
|
+
);
|
|
294
|
+
expect(res.status).toBe(200);
|
|
295
|
+
const body = await bodyOf(res);
|
|
296
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.hyphenPhrase]);
|
|
297
|
+
expect(body[0].tags).toContain("probe");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("search_mode without search is a no-op that surfaces an ignored_param warning (structured-query path)", async () => {
|
|
301
|
+
const res = await search("search_mode=advanced");
|
|
302
|
+
expect(res.status).toBe(200);
|
|
303
|
+
const warnings = decodeWarningsHeader(res);
|
|
304
|
+
expect(warnings).not.toBeNull();
|
|
305
|
+
expect(warnings!.some((w: any) => w.code === "ignored_param" && w.param === "search_mode")).toBe(true);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("an unrecognized search_mode value is a structured 400 invalid_query", async () => {
|
|
309
|
+
const res = await search("search=widgets&search_mode=bogus");
|
|
310
|
+
expect(res.status).toBe(400);
|
|
311
|
+
const body = await bodyOf(res);
|
|
312
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
313
|
+
expect(body.error_type).toBe("invalid_query");
|
|
314
|
+
expect(body.field).toBe("search_mode");
|
|
315
|
+
expect(body.got).toBe("bogus");
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe("contract: search — MCP parity (#551)", () => {
|
|
320
|
+
it("query-notes: literal-by-default finds punctuation content the same as REST", async () => {
|
|
321
|
+
const tools = generateMcpTools(store);
|
|
322
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
323
|
+
const result = (await query.execute({ search: "didn't", include_content: true })) as any[];
|
|
324
|
+
expect(result.map((n: any) => n.content)).toEqual([NOTES.contraction]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("query-notes: search_mode validation rejects an unknown value", async () => {
|
|
328
|
+
const tools = generateMcpTools(store);
|
|
329
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
330
|
+
await expect(query.execute({ search: "widgets", search_mode: "bogus" })).rejects.toThrow(/search_mode/);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("query-notes: search_mode without search surfaces an ignored_param warning", async () => {
|
|
334
|
+
const tools = generateMcpTools(store);
|
|
335
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
336
|
+
const result = (await query.execute({ search_mode: "advanced" })) as any;
|
|
337
|
+
expect(result.warnings).toBeDefined();
|
|
338
|
+
expect(result.warnings.some((w: any) => w.code === "ignored_param" && w.param === "search_mode")).toBe(true);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("query-notes: search_mode:\"advanced\" + malformed syntax throws a structured invalid_search_syntax error", async () => {
|
|
342
|
+
const tools = generateMcpTools(store);
|
|
343
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
344
|
+
await expect(
|
|
345
|
+
query.execute({ search: '"unbalanced', search_mode: "advanced" }),
|
|
346
|
+
).rejects.toMatchObject({ error_type: "invalid_search_syntax" });
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("query-notes: sort:\"asc\"/\"desc\" changes order under search (parity with REST)", async () => {
|
|
350
|
+
await store.createNote("mcpsortprobe alpha", { created_at: "2020-01-01T00:00:00.000Z" });
|
|
351
|
+
await store.createNote("mcpsortprobe beta", { created_at: "2024-01-01T00:00:00.000Z" });
|
|
352
|
+
const tools = generateMcpTools(store);
|
|
353
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
354
|
+
const asc = (await query.execute({ search: "mcpsortprobe", sort: "asc", include_content: true })) as any[];
|
|
355
|
+
expect(asc.map((n: any) => n.content)).toEqual(["mcpsortprobe alpha", "mcpsortprobe beta"]);
|
|
356
|
+
const desc = (await query.execute({ search: "mcpsortprobe", sort: "desc", include_content: true })) as any[];
|
|
357
|
+
expect(desc.map((n: any) => n.content)).toEqual(["mcpsortprobe beta", "mcpsortprobe alpha"]);
|
|
358
|
+
});
|
|
127
359
|
});
|
package/src/mcp-http.ts
CHANGED
|
@@ -176,6 +176,19 @@ async function handleMcp(
|
|
|
176
176
|
hint: e.hint,
|
|
177
177
|
});
|
|
178
178
|
}
|
|
179
|
+
// Advanced-mode full-text search syntax error (vault#551) — a
|
|
180
|
+
// raw-passthrough FTS5 query that FTS5 itself rejected. Same shape as
|
|
181
|
+
// `invalid_query` (field/got/hint) but a DISTINCT `error_type` so a
|
|
182
|
+
// caller can tell "your search_mode:\"advanced\" syntax is malformed"
|
|
183
|
+
// apart from "your query PARAMS are malformed."
|
|
184
|
+
if (e?.error_type === "invalid_search_syntax") {
|
|
185
|
+
throw new McpError(ErrorCode.InvalidParams, message, {
|
|
186
|
+
error_type: "invalid_search_syntax",
|
|
187
|
+
field: e.field,
|
|
188
|
+
got: e.got,
|
|
189
|
+
hint: e.hint,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
179
192
|
if (e?.code === "CONFLICT") {
|
|
180
193
|
throw new McpError(ErrorCode.InvalidRequest, message, {
|
|
181
194
|
error_type: "conflict",
|
package/src/routes.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
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 { collectUnknownTagWarnings, type QueryWarning } from "../core/src/query-warnings.ts";
|
|
16
|
+
import { collectUnknownTagWarnings, emptySearchWarning, ignoredParamWarning, type QueryWarning } from "../core/src/query-warnings.ts";
|
|
17
|
+
import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "../core/src/search-query.ts";
|
|
17
18
|
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
18
19
|
import { transactionAsync } from "../core/src/txn.ts";
|
|
19
20
|
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
@@ -660,6 +661,39 @@ export function parseExpandParam(url: URL): { expand?: TagExpandMode; error?: Re
|
|
|
660
661
|
return { expand: expandParam as TagExpandMode };
|
|
661
662
|
}
|
|
662
663
|
|
|
664
|
+
/**
|
|
665
|
+
* Parse + validate `?search_mode=` (vault#551) — how `search` text is
|
|
666
|
+
* turned into an FTS5 query. "literal" (default, omit or pass explicitly):
|
|
667
|
+
* escape + phrase-quote so punctuation is literal content, not FTS5
|
|
668
|
+
* syntax. "advanced": raw FTS5 passthrough (pre-#551 behavior) for
|
|
669
|
+
* boolean/phrase/prefix operators. Same validation shape as
|
|
670
|
+
* `parseExpandParam` — loud 400 on an unknown value rather than a silent
|
|
671
|
+
* fallback to the default.
|
|
672
|
+
*
|
|
673
|
+
* Returns `{ mode }` (undefined when absent/empty → search branch defaults
|
|
674
|
+
* to "literal") or `{ error }` (a 400 Response) on an unknown value.
|
|
675
|
+
*/
|
|
676
|
+
function parseSearchModeParam(url: URL): { mode?: SearchMode; error?: Response } {
|
|
677
|
+
const raw = parseQuery(url, "search_mode");
|
|
678
|
+
if (raw === null || raw === "") return {};
|
|
679
|
+
if (!isValidSearchMode(raw)) {
|
|
680
|
+
return {
|
|
681
|
+
error: json(
|
|
682
|
+
{
|
|
683
|
+
error: `invalid \`search_mode\` value "${raw}" — must be one of ${SEARCH_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("literal").`,
|
|
684
|
+
code: "INVALID_QUERY",
|
|
685
|
+
error_type: "invalid_query",
|
|
686
|
+
field: "search_mode",
|
|
687
|
+
got: raw,
|
|
688
|
+
hint: `pass "literal" or "advanced", or omit for the default ("literal")`,
|
|
689
|
+
},
|
|
690
|
+
400,
|
|
691
|
+
),
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
return { mode: raw };
|
|
695
|
+
}
|
|
696
|
+
|
|
663
697
|
/**
|
|
664
698
|
* Parse the shared notes-query parameters (tags / excludeTags / path /
|
|
665
699
|
* pathPrefix / extension / metadata filters / date filters / sort / paging /
|
|
@@ -973,11 +1007,54 @@ async function handleNotesInner(
|
|
|
973
1007
|
// value. The validated mode is threaded into the search tag-narrowing.
|
|
974
1008
|
const tagExpand = parseExpandParam(url);
|
|
975
1009
|
if (tagExpand.error) return tagExpand.error;
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1010
|
+
// `search_mode` (vault#551) — same loud-validation policy as
|
|
1011
|
+
// `expand` above; also bypasses `parseNotesQueryOpts` so it needs
|
|
1012
|
+
// its own check here.
|
|
1013
|
+
const searchModeParsed = parseSearchModeParam(url);
|
|
1014
|
+
if (searchModeParsed.error) return searchModeParsed.error;
|
|
1015
|
+
const mode: SearchMode = searchModeParsed.mode ?? "literal";
|
|
1016
|
+
// `sort` under search (vault#551 item 3): omit for FTS5 relevance
|
|
1017
|
+
// (default, unchanged); explicit asc/desc switches to created_at.
|
|
1018
|
+
const sort = (parseQuery(url, "sort") as "asc" | "desc" | null) ?? undefined;
|
|
1019
|
+
|
|
1020
|
+
const searchWarnings: QueryWarning[] = [];
|
|
1021
|
+
let rawResults: Note[];
|
|
1022
|
+
// "Only whitespace/quotes" (vault#551 edge case) — short-circuit
|
|
1023
|
+
// BEFORE ever calling FTS5 rather than risk a syntax error (or a
|
|
1024
|
+
// meaningless always-empty query) on a degenerate escaped phrase.
|
|
1025
|
+
if (mode === "literal" && buildLiteralSearchQuery(search).isEmpty) {
|
|
1026
|
+
rawResults = [];
|
|
1027
|
+
searchWarnings.push(emptySearchWarning());
|
|
1028
|
+
} else {
|
|
1029
|
+
try {
|
|
1030
|
+
// A malformed `search_mode: "advanced"` query throws here
|
|
1031
|
+
// (structured `invalid_search_syntax`, vault#551) instead of
|
|
1032
|
+
// the pre-#551 silent `[]` — caught below and formatted the
|
|
1033
|
+
// same way the structured-query path formats a `QueryError`.
|
|
1034
|
+
rawResults = await store.searchNotes(search, {
|
|
1035
|
+
tags: searchTags,
|
|
1036
|
+
limit,
|
|
1037
|
+
expand: tagExpand.expand,
|
|
1038
|
+
mode,
|
|
1039
|
+
sort,
|
|
1040
|
+
});
|
|
1041
|
+
} catch (e: any) {
|
|
1042
|
+
if (e && e.name === "QueryError") {
|
|
1043
|
+
return json(
|
|
1044
|
+
{
|
|
1045
|
+
error: e.message,
|
|
1046
|
+
code: e.code ?? "INVALID_QUERY",
|
|
1047
|
+
...(e.error_type !== undefined ? { error_type: e.error_type } : {}),
|
|
1048
|
+
...(e.field !== undefined ? { field: e.field } : {}),
|
|
1049
|
+
...(e.got !== undefined ? { got: e.got } : {}),
|
|
1050
|
+
...(e.hint !== undefined ? { hint: e.hint } : {}),
|
|
1051
|
+
},
|
|
1052
|
+
400,
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
throw e;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
981
1058
|
// Tag-scope: drop any result the token isn't permitted to see. Filter
|
|
982
1059
|
// happens after the store query so an empty post-filter list still
|
|
983
1060
|
// returns 200 [] (consistent with "no matches"), not 403.
|
|
@@ -1015,7 +1092,7 @@ async function handleNotesInner(
|
|
|
1015
1092
|
);
|
|
1016
1093
|
for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
|
|
1017
1094
|
}
|
|
1018
|
-
return
|
|
1095
|
+
return jsonWithWarnings(output, searchWarnings);
|
|
1019
1096
|
}
|
|
1020
1097
|
|
|
1021
1098
|
// Structured query
|
|
@@ -1071,17 +1148,27 @@ async function handleNotesInner(
|
|
|
1071
1148
|
400,
|
|
1072
1149
|
);
|
|
1073
1150
|
}
|
|
1074
|
-
// Warnings channel (vault#550)
|
|
1075
|
-
// (
|
|
1076
|
-
//
|
|
1077
|
-
//
|
|
1078
|
-
//
|
|
1079
|
-
//
|
|
1080
|
-
// (docs/contracts/tag-scoped-tokens.md). `removed_param` warnings
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1151
|
+
// Warnings channel (vault#550). `unknown_tag` stays structured-query
|
|
1152
|
+
// only (skipped entirely for a tag-scoped session:
|
|
1153
|
+
// `collectUnknownTagWarnings` resolves `did_you_mean` against the
|
|
1154
|
+
// FULL vault-wide tag catalog, which would leak an out-of-scope tag's
|
|
1155
|
+
// existence/name across the scope boundary — the same "no leak"
|
|
1156
|
+
// stance this codebase takes everywhere else
|
|
1157
|
+
// (docs/contracts/tag-scoped-tokens.md)). `removed_param` warnings
|
|
1158
|
+
// carry no tag information and fire regardless of scope. `search_mode`
|
|
1159
|
+
// without `search` (vault#551) also carries no tag information — this
|
|
1160
|
+
// IS the structured-query path precisely because `search` was absent
|
|
1161
|
+
// or empty, so a `search_mode` param here is always a no-op.
|
|
1083
1162
|
const queryWarnings: QueryWarning[] = [
|
|
1084
1163
|
...collectRemovedParamWarnings(url),
|
|
1164
|
+
...(parseQuery(url, "search_mode")
|
|
1165
|
+
? [
|
|
1166
|
+
ignoredParamWarning(
|
|
1167
|
+
"search_mode",
|
|
1168
|
+
"no `search` was provided — search_mode only affects full-text search query parsing",
|
|
1169
|
+
),
|
|
1170
|
+
]
|
|
1171
|
+
: []),
|
|
1085
1172
|
...(tagScope.allowed === null
|
|
1086
1173
|
? collectUnknownTagWarnings(db, queryOpts.tags, queryOpts.expand, store.getTagHierarchy())
|
|
1087
1174
|
: []),
|
package/src/ws-subscribe.test.ts
CHANGED
|
@@ -440,6 +440,20 @@ describe("WS live-query — bad query + cap", () => {
|
|
|
440
440
|
expect((await res.json()).code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
|
|
441
441
|
});
|
|
442
442
|
|
|
443
|
+
// vault#551 — literal-by-default search + `search_mode` are a
|
|
444
|
+
// snapshot/REST-only concept (escaping happens in `core/src/notes.ts`
|
|
445
|
+
// `searchNotes`, which the live matcher never calls). `search` itself
|
|
446
|
+
// stays categorically unsupported for live subscriptions regardless of
|
|
447
|
+
// mode — confirms `search_mode` doesn't accidentally open a bypass.
|
|
448
|
+
it("rejects an unsupported query with search_mode present too — search_mode doesn't bypass the search rejection", async () => {
|
|
449
|
+
const { server } = makeServer();
|
|
450
|
+
const res = await fetch(`http://localhost:${server.port}/vault/${VAULT}/api/subscribe?search=hi&search_mode=advanced`, {
|
|
451
|
+
headers: { Upgrade: "websocket", Connection: "Upgrade", "Sec-WebSocket-Key": "x", "Sec-WebSocket-Version": "13" },
|
|
452
|
+
});
|
|
453
|
+
expect(res.status).toBe(400);
|
|
454
|
+
expect((await res.json()).code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
|
|
455
|
+
});
|
|
456
|
+
|
|
443
457
|
it("refuses over the per-vault cap (503) and self-heals when a socket closes", async () => {
|
|
444
458
|
const { binding } = makeServer({ maxSubscriptions: 2 });
|
|
445
459
|
const fakeWs = (): { data: SubscribeWsData; close(): void; send(): void } => ({
|