@openparachute/vault 0.7.0-rc.1 → 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/cursor.ts +27 -9
- package/core/src/links.ts +63 -3
- package/core/src/mcp.ts +155 -23
- package/core/src/notes.ts +226 -37
- package/core/src/query-operators.ts +18 -1
- package/core/src/query-warnings.ts +219 -0
- package/core/src/search-query.test.ts +149 -0
- package/core/src/search-query.ts +0 -0
- package/core/src/store.ts +11 -3
- package/core/src/tag-hierarchy.ts +113 -0
- package/core/src/types.ts +32 -5
- package/package.json +1 -1
- package/src/contract-honest-queries.test.ts +350 -38
- package/src/contract-search.test.ts +263 -31
- package/src/live-match.test.ts +17 -0
- package/src/live-match.ts +4 -1
- package/src/mcp-http.ts +32 -0
- package/src/mcp-list-tags-scope.test.ts +154 -0
- package/src/mcp-tools.ts +49 -8
- package/src/routes.ts +327 -23
- package/src/ws-subscribe.test.ts +14 -0
- package/web/ui/dist/assets/{index-TJ-XN4OZ.js → index-B8pGeQam.js} +1 -1
- package/web/ui/dist/index.html +1 -1
package/core/src/notes.ts
CHANGED
|
@@ -20,8 +20,9 @@ import {
|
|
|
20
20
|
type QueryHashInputs,
|
|
21
21
|
} from "./cursor.js";
|
|
22
22
|
import { getIndexedField, releaseField } from "./indexed-fields.js";
|
|
23
|
-
import { stripTagHash } from "./tag-hierarchy.js";
|
|
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
|
|
|
@@ -684,7 +685,78 @@ export function deleteNote(db: Database, id: string): void {
|
|
|
684
685
|
db.prepare("DELETE FROM notes WHERE id = ?").run(id);
|
|
685
686
|
}
|
|
686
687
|
|
|
688
|
+
/**
|
|
689
|
+
* Validate `limit`/`offset` before any query work (vault#550). A negative
|
|
690
|
+
* `limit` used to leak SQLite's "negative LIMIT means unlimited" semantics
|
|
691
|
+
* straight through to the caller — silently returning EVERYTHING when the
|
|
692
|
+
* caller almost certainly meant "no limit I typed by mistake." A
|
|
693
|
+
* non-numeric value (a bad MCP param type, or a REST caller that slipped
|
|
694
|
+
* past `parseNotesQueryOpts`'s own stricter check) used to silently fall
|
|
695
|
+
* back to the default via `typeof opts.limit === "number" ? opts.limit :
|
|
696
|
+
* 100` below — also silent-wrong. This is the single choke point BOTH
|
|
697
|
+
* transports funnel through (`store.queryNotes` / `store.queryNotesPaged`
|
|
698
|
+
* call this via `queryNotes`), so REST and MCP get identical validation
|
|
699
|
+
* for free.
|
|
700
|
+
*/
|
|
701
|
+
function validateLimitOffset(opts: QueryOpts): void {
|
|
702
|
+
if (opts.limit !== undefined) {
|
|
703
|
+
if (typeof opts.limit !== "number" || !Number.isFinite(opts.limit) || !Number.isInteger(opts.limit) || opts.limit < 0) {
|
|
704
|
+
throw new QueryError(
|
|
705
|
+
`invalid limit: ${JSON.stringify(opts.limit)} — must be a non-negative integer (a negative LIMIT silently means "unlimited" in SQLite semantics, which is almost never what was intended). Omit for the default of 50.`,
|
|
706
|
+
"INVALID_QUERY",
|
|
707
|
+
{
|
|
708
|
+
error_type: "invalid_query",
|
|
709
|
+
field: "limit",
|
|
710
|
+
got: opts.limit,
|
|
711
|
+
hint: "pass a non-negative integer, or omit for the default",
|
|
712
|
+
},
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
if (opts.offset !== undefined) {
|
|
717
|
+
if (typeof opts.offset !== "number" || !Number.isFinite(opts.offset) || !Number.isInteger(opts.offset) || opts.offset < 0) {
|
|
718
|
+
throw new QueryError(
|
|
719
|
+
`invalid offset: ${JSON.stringify(opts.offset)} — must be a non-negative integer.`,
|
|
720
|
+
"INVALID_QUERY",
|
|
721
|
+
{
|
|
722
|
+
error_type: "invalid_query",
|
|
723
|
+
field: "offset",
|
|
724
|
+
got: opts.offset,
|
|
725
|
+
hint: "pass a non-negative integer, or omit for the default of 0",
|
|
726
|
+
},
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Validate an ISO-8601 date-filter value before it's bound into a SQL
|
|
734
|
+
* comparison (vault#550). `n.created_at` / `n.updated_at` are TEXT columns
|
|
735
|
+
* compared lexicographically; an unparseable value used to bind straight
|
|
736
|
+
* through and silently match "nothing" or "everything" depending on how it
|
|
737
|
+
* happened to sort against real ISO strings, rather than erroring. Applies
|
|
738
|
+
* uniformly to BOTH `dateFilter` (bracket-style REST / MCP `date_filter`)
|
|
739
|
+
* and the legacy `dateFrom`/`dateTo` shorthand (MCP `date_from`/`date_to`,
|
|
740
|
+
* still supported) — both flow through this same function, so both get the
|
|
741
|
+
* same loud validation from one place.
|
|
742
|
+
*/
|
|
743
|
+
function validateIsoDateValue(field: string, value: string): void {
|
|
744
|
+
if (Number.isNaN(Date.parse(value))) {
|
|
745
|
+
throw new QueryError(
|
|
746
|
+
`invalid date value for "${field}": ${JSON.stringify(value)} — must be an ISO-8601 date/timestamp (e.g. "2026-07-09" or "2026-07-09T00:00:00.000Z").`,
|
|
747
|
+
"INVALID_QUERY",
|
|
748
|
+
{
|
|
749
|
+
error_type: "invalid_query",
|
|
750
|
+
field,
|
|
751
|
+
got: value,
|
|
752
|
+
hint: "pass an ISO-8601 date or timestamp",
|
|
753
|
+
},
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
687
758
|
export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
759
|
+
validateLimitOffset(opts);
|
|
688
760
|
const conditions: string[] = [];
|
|
689
761
|
const params: SQLQueryBindings[] = [];
|
|
690
762
|
|
|
@@ -920,19 +992,23 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
920
992
|
column = `"meta_${field}"`;
|
|
921
993
|
}
|
|
922
994
|
if (filter.from !== undefined) {
|
|
995
|
+
validateIsoDateValue(field === "created_at" ? "date_filter.from" : `date_filter.from (${field})`, filter.from);
|
|
923
996
|
conditions.push(`${column} >= ?`);
|
|
924
997
|
params.push(filter.from);
|
|
925
998
|
}
|
|
926
999
|
if (filter.to !== undefined) {
|
|
1000
|
+
validateIsoDateValue(field === "created_at" ? "date_filter.to" : `date_filter.to (${field})`, filter.to);
|
|
927
1001
|
conditions.push(`${column} < ?`);
|
|
928
1002
|
params.push(filter.to);
|
|
929
1003
|
}
|
|
930
1004
|
} else if (hasLegacyDate) {
|
|
931
1005
|
if (opts.dateFrom) {
|
|
1006
|
+
validateIsoDateValue("date_from", opts.dateFrom);
|
|
932
1007
|
conditions.push("n.created_at >= ?");
|
|
933
1008
|
params.push(opts.dateFrom);
|
|
934
1009
|
}
|
|
935
1010
|
if (opts.dateTo) {
|
|
1011
|
+
validateIsoDateValue("date_to", opts.dateTo);
|
|
936
1012
|
conditions.push("n.created_at < ?");
|
|
937
1013
|
params.push(opts.dateTo);
|
|
938
1014
|
}
|
|
@@ -940,8 +1016,22 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
940
1016
|
|
|
941
1017
|
// ---- Cursor predicate (vault#313) ----
|
|
942
1018
|
//
|
|
943
|
-
//
|
|
944
|
-
//
|
|
1019
|
+
// Cursor mode is keyed on PRESENCE of `opts.cursor`, not truthiness
|
|
1020
|
+
// (vault#550 bootstrap fix). `cursor: ""` is the bootstrap call — "I want
|
|
1021
|
+
// to paginate, I don't have a watermark yet" — and must still force the
|
|
1022
|
+
// keyset ORDER BY below so the FIRST page is taken in the same order
|
|
1023
|
+
// subsequent pages will be. `opts.cursor === undefined` is the only way
|
|
1024
|
+
// to opt OUT of cursor mode entirely (the legacy flat-array shape).
|
|
1025
|
+
// Before this fix, `if (opts.cursor)` treated an empty string exactly
|
|
1026
|
+
// like "no cursor" — the caller's bootstrap intent silently vanished and
|
|
1027
|
+
// the first page came back in `created_at` order instead of the
|
|
1028
|
+
// `updated_at` keyset order the SECOND page (a real cursor) would use,
|
|
1029
|
+
// so naive "did I see this note already" comparisons could skip or
|
|
1030
|
+
// duplicate rows across the boundary.
|
|
1031
|
+
//
|
|
1032
|
+
// When a REAL (non-empty) cursor is present, decode it, verify its
|
|
1033
|
+
// query_hash matches the current query, and add a keyset predicate of
|
|
1034
|
+
// the form:
|
|
945
1035
|
//
|
|
946
1036
|
// (updated_at > last_updated_at)
|
|
947
1037
|
// OR (updated_at = last_updated_at AND id > last_id)
|
|
@@ -953,8 +1043,9 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
953
1043
|
// exclusive with cursor mode (a "since last checked" loop wants
|
|
954
1044
|
// ascending updated_at, full stop); we reject with INVALID_QUERY so
|
|
955
1045
|
// callers don't silently get a broken iteration.
|
|
1046
|
+
const cursorMode = opts.cursor !== undefined;
|
|
956
1047
|
let cursorPayload: CursorPayload | null = null;
|
|
957
|
-
if (
|
|
1048
|
+
if (cursorMode) {
|
|
958
1049
|
if (opts.orderBy) {
|
|
959
1050
|
throw new QueryError(
|
|
960
1051
|
`cursor and order_by are mutually exclusive — cursor pagination forces order by updated_at`,
|
|
@@ -967,33 +1058,39 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
967
1058
|
"INVALID_QUERY",
|
|
968
1059
|
);
|
|
969
1060
|
}
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1061
|
+
if (opts.cursor !== "") {
|
|
1062
|
+
cursorPayload = decodeCursor(opts.cursor!);
|
|
1063
|
+
const expectedHash = computeQueryHash(toQueryHashInputs(opts));
|
|
1064
|
+
if (cursorPayload.query_hash !== expectedHash) {
|
|
1065
|
+
throw new CursorError(
|
|
1066
|
+
`cursor was minted for a different query — drop the cursor and restart iteration`,
|
|
1067
|
+
"cursor_query_mismatch",
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
// Translate the millis watermark back to an ISO string for the SQL
|
|
1071
|
+
// comparison. SQLite's `n.updated_at` is TEXT in canonical ISO form
|
|
1072
|
+
// (the store's `toISOString()` output), and ISO timestamps sort
|
|
1073
|
+
// lexicographically in the same order as their millisecond epochs
|
|
1074
|
+
// when they all use the same canonical form — which every timestamp
|
|
1075
|
+
// vault mints does. Cursors minted on heterogeneous timestamps
|
|
1076
|
+
// (e.g. an import that preserved unusual formatting) are still
|
|
1077
|
+
// safe: we round-trip the cursor's millis through `new Date()`'s
|
|
1078
|
+
// canonical ISO so the comparison is apples-to-apples.
|
|
1079
|
+
const cursorIso = millisToIso(cursorPayload.last_updated_at);
|
|
1080
|
+
conditions.push(
|
|
1081
|
+
"(n.updated_at > ? OR (n.updated_at = ? AND n.id > ?))",
|
|
976
1082
|
);
|
|
1083
|
+
params.push(cursorIso, cursorIso, cursorPayload.last_id);
|
|
977
1084
|
}
|
|
978
|
-
//
|
|
979
|
-
//
|
|
980
|
-
//
|
|
981
|
-
//
|
|
982
|
-
// when they all use the same canonical form — which every timestamp
|
|
983
|
-
// vault mints does. Cursors minted on heterogeneous timestamps
|
|
984
|
-
// (e.g. an import that preserved unusual formatting) are still
|
|
985
|
-
// safe: we round-trip the cursor's millis through `new Date()`'s
|
|
986
|
-
// canonical ISO so the comparison is apples-to-apples.
|
|
987
|
-
const cursorIso = millisToIso(cursorPayload.last_updated_at);
|
|
988
|
-
conditions.push(
|
|
989
|
-
"(n.updated_at > ? OR (n.updated_at = ? AND n.id > ?))",
|
|
990
|
-
);
|
|
991
|
-
params.push(cursorIso, cursorIso, cursorPayload.last_id);
|
|
1085
|
+
// else: bootstrap call (`cursor === ""`) — no watermark yet, no
|
|
1086
|
+
// predicate to add, but the ORDER BY below still switches to the
|
|
1087
|
+
// keyset order so this first page is consistent with every page after
|
|
1088
|
+
// it.
|
|
992
1089
|
}
|
|
993
1090
|
|
|
994
1091
|
const direction = opts.sort === "desc" ? "DESC" : "ASC";
|
|
995
1092
|
let orderBy: string;
|
|
996
|
-
if (
|
|
1093
|
+
if (cursorMode) {
|
|
997
1094
|
// Cursor mode forces a deterministic keyset order. `id` is the
|
|
998
1095
|
// tiebreaker — without it, two notes sharing an `updated_at` would
|
|
999
1096
|
// be at the mercy of SQLite's row order and the next page could
|
|
@@ -1177,7 +1274,11 @@ export function queryNotesPaged(db: Database, opts: QueryOpts): QueryNotesPage {
|
|
|
1177
1274
|
// cursor's watermark — see the JSDoc rationale above.
|
|
1178
1275
|
let lastUpdatedAt = 0;
|
|
1179
1276
|
let lastId = "";
|
|
1180
|
-
|
|
1277
|
+
// `opts.cursor === ""` is the bootstrap call (vault#550) — there's no
|
|
1278
|
+
// prior watermark to decode, so it takes the same 0/"" sentinel path as
|
|
1279
|
+
// `opts.cursor === undefined`. Only a REAL (non-empty) cursor gets
|
|
1280
|
+
// re-decoded here.
|
|
1281
|
+
if (opts.cursor !== undefined && opts.cursor !== "") {
|
|
1181
1282
|
// Re-decode (we already validated in queryNotes); this is cheap.
|
|
1182
1283
|
const prior = decodeCursor(opts.cursor);
|
|
1183
1284
|
lastUpdatedAt = prior.last_updated_at;
|
|
@@ -1209,12 +1310,78 @@ export function queryNotesPaged(db: Database, opts: QueryOpts): QueryNotesPage {
|
|
|
1209
1310
|
return { notes, next_cursor };
|
|
1210
1311
|
}
|
|
1211
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
|
+
|
|
1212
1344
|
export function searchNotes(
|
|
1213
1345
|
db: Database,
|
|
1214
1346
|
query: string,
|
|
1215
|
-
opts?: { tags?: string[]; limit?: number },
|
|
1347
|
+
opts?: { tags?: string[]; limit?: number; mode?: SearchMode; sort?: "asc" | "desc" },
|
|
1216
1348
|
): Note[] {
|
|
1217
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";
|
|
1218
1385
|
|
|
1219
1386
|
if (opts?.tags && opts.tags.length > 0) {
|
|
1220
1387
|
// Canonical-bare-tag guard backstop (vault#XXX) for direct-core callers.
|
|
@@ -1234,12 +1401,19 @@ export function searchNotes(
|
|
|
1234
1401
|
JOIN notes_fts fts ON fts.rowid = n.rowid
|
|
1235
1402
|
WHERE notes_fts MATCH ?
|
|
1236
1403
|
AND n.id IN (SELECT note_id FROM note_tags WHERE tag_name IN (${tagPlaceholders}))
|
|
1237
|
-
ORDER BY
|
|
1404
|
+
ORDER BY ${orderBy}
|
|
1238
1405
|
LIMIT ?
|
|
1239
|
-
`).all(
|
|
1406
|
+
`).all(ftsQuery, ...searchTags, limit) as NoteRow[];
|
|
1240
1407
|
return notesWithTags(db, rows);
|
|
1241
|
-
} catch {
|
|
1242
|
-
|
|
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);
|
|
1243
1417
|
}
|
|
1244
1418
|
}
|
|
1245
1419
|
}
|
|
@@ -1249,12 +1423,13 @@ export function searchNotes(
|
|
|
1249
1423
|
SELECT n.* FROM notes n
|
|
1250
1424
|
JOIN notes_fts fts ON fts.rowid = n.rowid
|
|
1251
1425
|
WHERE notes_fts MATCH ?
|
|
1252
|
-
ORDER BY
|
|
1426
|
+
ORDER BY ${orderBy}
|
|
1253
1427
|
LIMIT ?
|
|
1254
|
-
`).all(
|
|
1428
|
+
`).all(ftsQuery, limit) as NoteRow[];
|
|
1255
1429
|
return notesWithTags(db, rows);
|
|
1256
|
-
} catch {
|
|
1257
|
-
|
|
1430
|
+
} catch (err) {
|
|
1431
|
+
if (err instanceof QueryError) throw err;
|
|
1432
|
+
throw searchSyntaxError(query, err, mode);
|
|
1258
1433
|
}
|
|
1259
1434
|
}
|
|
1260
1435
|
|
|
@@ -1303,7 +1478,18 @@ export function getNoteTags(db: Database, noteId: string): string[] {
|
|
|
1303
1478
|
return rows.map((r) => r.tag_name);
|
|
1304
1479
|
}
|
|
1305
1480
|
|
|
1306
|
-
|
|
1481
|
+
/**
|
|
1482
|
+
* List every declared tag with its literal `count` (notes carrying that
|
|
1483
|
+
* EXACT tag name) and its `expanded_count` (vault#550) — distinct notes
|
|
1484
|
+
* matching the tag OR any transitive descendant under the DEFAULT
|
|
1485
|
+
* (subtypes) expansion axis. `expanded_count` is what surfaces a parent
|
|
1486
|
+
* tag as non-empty even when every one of its notes is actually tagged
|
|
1487
|
+
* with a more specific child tag — `count` alone reports 0 for that
|
|
1488
|
+
* parent, which reads as "this tag is dead" when it's really just a
|
|
1489
|
+
* rollup label. See `computeExpandedTagCounts` for the single-pass
|
|
1490
|
+
* (no N+1) computation.
|
|
1491
|
+
*/
|
|
1492
|
+
export function listTags(db: Database): { name: string; count: number; expanded_count: number }[] {
|
|
1307
1493
|
const rows = db.prepare(`
|
|
1308
1494
|
SELECT t.name, COUNT(nt.note_id) as count
|
|
1309
1495
|
FROM tags t
|
|
@@ -1311,7 +1497,10 @@ export function listTags(db: Database): { name: string; count: number }[] {
|
|
|
1311
1497
|
GROUP BY t.name
|
|
1312
1498
|
ORDER BY t.name
|
|
1313
1499
|
`).all() as { name: string; count: number }[];
|
|
1314
|
-
|
|
1500
|
+
|
|
1501
|
+
const h = loadTagHierarchy(db);
|
|
1502
|
+
const expandedCounts = computeExpandedTagCounts(db, h);
|
|
1503
|
+
return rows.map((r) => ({ ...r, expanded_count: expandedCounts.get(r.name) ?? 0 }));
|
|
1315
1504
|
}
|
|
1316
1505
|
|
|
1317
1506
|
export function deleteTag(db: Database, name: string): { deleted: boolean; notes_untagged: number } {
|
|
@@ -37,9 +37,26 @@ const OPS_SET: ReadonlySet<string> = new Set<string>(SUPPORTED_OPS);
|
|
|
37
37
|
export class QueryError extends Error {
|
|
38
38
|
override name = "QueryError";
|
|
39
39
|
code: string;
|
|
40
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Structured `error_type` for the honest-queries validation errors
|
|
42
|
+
* (vault#550 — `limit`/`offset`/date-value validation). Optional: the
|
|
43
|
+
* long-standing QueryError call sites (FIELD_NOT_INDEXED,
|
|
44
|
+
* UNKNOWN_OPERATOR, generic INVALID_QUERY combos) leave this unset and
|
|
45
|
+
* keep their existing `{error, code}` response shape — only the new #550
|
|
46
|
+
* call sites opt into the richer `{error_type, field, got, hint}` shape.
|
|
47
|
+
*/
|
|
48
|
+
error_type?: string;
|
|
49
|
+
field?: string;
|
|
50
|
+
got?: unknown;
|
|
51
|
+
hint?: string;
|
|
52
|
+
constructor(
|
|
53
|
+
message: string,
|
|
54
|
+
code = "INVALID_QUERY",
|
|
55
|
+
extra?: { error_type?: string; field?: string; got?: unknown; hint?: string },
|
|
56
|
+
) {
|
|
41
57
|
super(message);
|
|
42
58
|
this.code = code;
|
|
59
|
+
if (extra) Object.assign(this, extra);
|
|
43
60
|
}
|
|
44
61
|
}
|
|
45
62
|
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `warnings: []` channel (vault#550 — Reliability & Usability Program
|
|
3
|
+
* WS1, "honest queries"). Ratified principle: if the vault can still answer
|
|
4
|
+
* the question asked, answer it and attach a warning; if it would answer a
|
|
5
|
+
* DIFFERENT question, return a structured named error. Silence is never the
|
|
6
|
+
* third option.
|
|
7
|
+
*
|
|
8
|
+
* This module owns the ONE validated choke point for `unknown_tag`
|
|
9
|
+
* warnings — both the REST structured-query path (`src/routes.ts`) and the
|
|
10
|
+
* MCP `query-notes` tool call into `collectUnknownTagWarnings` so the two
|
|
11
|
+
* surfaces can't drift. REST-only warnings (`removed_param` — a REST query
|
|
12
|
+
* string concept with no MCP analog) stay in `src/routes.ts`.
|
|
13
|
+
*
|
|
14
|
+
* Tag-scope note (security review): this module is deliberately
|
|
15
|
+
* SCOPE-UNAWARE — `collectUnknownTagWarnings` always resolves against the
|
|
16
|
+
* full vault-wide tag catalog, exactly like `core/src/notes.ts:queryNotes`
|
|
17
|
+
* itself. Callers on a tag-scoped session MUST NOT surface these warnings
|
|
18
|
+
* (or must first narrow the candidate pool) — `did_you_mean` naming an
|
|
19
|
+
* out-of-scope tag would leak its existence across the scope boundary,
|
|
20
|
+
* which this codebase treats as a hard "no leak" invariant elsewhere (see
|
|
21
|
+
* `docs/contracts/tag-scoped-tokens.md`). `src/routes.ts` skips this call
|
|
22
|
+
* entirely for scoped sessions; `src/mcp-tools.ts`'s `query-notes` wrapper
|
|
23
|
+
* strips any `warnings` core computed before returning to a scoped caller.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { Database } from "bun:sqlite";
|
|
27
|
+
import {
|
|
28
|
+
DEFAULT_TAG_EXPAND_MODE,
|
|
29
|
+
getTagExpansion,
|
|
30
|
+
loadTagHierarchy,
|
|
31
|
+
stripTagHash,
|
|
32
|
+
suggestSimilarTag,
|
|
33
|
+
type TagExpandMode,
|
|
34
|
+
type TagHierarchy,
|
|
35
|
+
} from "./tag-hierarchy.js";
|
|
36
|
+
import { chunkForInClause } from "./sql-in.js";
|
|
37
|
+
|
|
38
|
+
export interface QueryWarning {
|
|
39
|
+
code: string;
|
|
40
|
+
message: string;
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Cap on `unknown_tag` warnings per query (vault#550 fold). A caller
|
|
46
|
+
* passing a garbage `tags` array (hundreds of junk names) would otherwise
|
|
47
|
+
* inflate the response — and the REST `X-Parachute-Warnings` header —
|
|
48
|
+
* unboundedly. Past the cap, a single `warnings_truncated` entry reports
|
|
49
|
+
* how many were suppressed.
|
|
50
|
+
*/
|
|
51
|
+
export const MAX_UNKNOWN_TAG_WARNINGS = 8;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Membership counts for a specific set of candidate tag names — NOT a
|
|
55
|
+
* full `listTags()` vault-wide scan. `listTags` additionally computes
|
|
56
|
+
* `expanded_count` (a full `note_tags` pass over EVERY tag, vault#550),
|
|
57
|
+
* which this function deliberately avoids: `collectUnknownTagWarnings` runs
|
|
58
|
+
* on every tag-filtered structured query (a hot path), so paying for a
|
|
59
|
+
* vault-wide rollup just to answer "does tag X have ANY notes" would be a
|
|
60
|
+
* real perf regression. This scopes the `note_tags` scan to only the
|
|
61
|
+
* (typically small) set of names actually reachable from the query's own
|
|
62
|
+
* tag filter and its expansion.
|
|
63
|
+
*/
|
|
64
|
+
function countMembership(db: Database, names: ReadonlySet<string>): Map<string, number> {
|
|
65
|
+
const counts = new Map<string, number>();
|
|
66
|
+
if (names.size === 0) return counts;
|
|
67
|
+
for (const chunk of chunkForInClause([...names])) {
|
|
68
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
69
|
+
const rows = db.prepare(
|
|
70
|
+
`SELECT tag_name, COUNT(*) as c FROM note_tags WHERE tag_name IN (${placeholders}) GROUP BY tag_name`,
|
|
71
|
+
).all(...chunk) as { tag_name: string; c: number }[];
|
|
72
|
+
for (const row of rows) counts.set(row.tag_name, row.c);
|
|
73
|
+
}
|
|
74
|
+
return counts;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Warn on each literal tag name in a `tags` filter that can contribute
|
|
79
|
+
* NOTHING to the result set no matter what — the caller almost certainly
|
|
80
|
+
* mistyped it, or is thinking of a different vault. A tag survives (no
|
|
81
|
+
* warning) if ANY of the following holds:
|
|
82
|
+
*
|
|
83
|
+
* - it has its own identity row (a `tags` table entry — created via
|
|
84
|
+
* `update-tag` or implicitly by tagging a note), OR
|
|
85
|
+
* - at least one note literally carries that tag, OR
|
|
86
|
+
* - its mode-aware expansion (subtypes/namespace/both/exact — whatever
|
|
87
|
+
* the query's `expand` axis resolves to) contains at least one tag
|
|
88
|
+
* name that itself has notes.
|
|
89
|
+
*
|
|
90
|
+
* `did_you_mean` names the closest existing tag (case variant, prefix
|
|
91
|
+
* relationship, or small edit distance) when one exists — see
|
|
92
|
+
* `suggestSimilarTag`.
|
|
93
|
+
*
|
|
94
|
+
* Scoped to the POSITIVE `tags` filter only — `excludeTags` is not
|
|
95
|
+
* checked (excluding a nonexistent tag is a harmless no-op, not a sign of
|
|
96
|
+
* a mistaken query) and this does not run under `search=` (out of scope
|
|
97
|
+
* for this wave — see #551).
|
|
98
|
+
*
|
|
99
|
+
* Perf shape (vault#550 fold): this runs on EVERY tag-filtered structured
|
|
100
|
+
* query, so the common all-tags-known case must cost ~nothing. Pass the
|
|
101
|
+
* store's cached `hierarchy` (`Store.getTagHierarchy()` — invalidated on
|
|
102
|
+
* tag writes) so no fresh `tags`-table scan happens per request; the
|
|
103
|
+
* `note_tags` membership query below only runs for input tags MISSING
|
|
104
|
+
* from the identity set (`h.allTags`), which for a well-formed query is
|
|
105
|
+
* none — a tag with an identity row can never warn (`hasIdentity`
|
|
106
|
+
* short-circuits), so there's nothing to look up. The `hierarchy` param
|
|
107
|
+
* stays optional for direct-core callers/tests (falls back to a fresh
|
|
108
|
+
* load).
|
|
109
|
+
*/
|
|
110
|
+
export function collectUnknownTagWarnings(
|
|
111
|
+
db: Database,
|
|
112
|
+
tags: string[] | undefined,
|
|
113
|
+
expandMode: TagExpandMode | undefined,
|
|
114
|
+
hierarchy?: TagHierarchy,
|
|
115
|
+
): QueryWarning[] {
|
|
116
|
+
if (!tags || tags.length === 0) return [];
|
|
117
|
+
|
|
118
|
+
const h = hierarchy ?? loadTagHierarchy(db);
|
|
119
|
+
const mode = expandMode ?? DEFAULT_TAG_EXPAND_MODE;
|
|
120
|
+
|
|
121
|
+
// Fast path: dedupe inputs and drop every tag with an identity row —
|
|
122
|
+
// those can never warn. For a well-formed query this empties the list
|
|
123
|
+
// and we return without touching the DB at all.
|
|
124
|
+
const suspects: string[] = [];
|
|
125
|
+
const seen = new Set<string>();
|
|
126
|
+
for (const raw of tags) {
|
|
127
|
+
const tag = stripTagHash(raw);
|
|
128
|
+
if (tag === "" || seen.has(tag)) continue;
|
|
129
|
+
seen.add(tag);
|
|
130
|
+
if (h.allTags.has(tag)) continue; // identity row → never unknown
|
|
131
|
+
suspects.push(tag);
|
|
132
|
+
}
|
|
133
|
+
if (suspects.length === 0) return [];
|
|
134
|
+
|
|
135
|
+
// Slow path (only for identity-less names): pre-compute each suspect's
|
|
136
|
+
// expansion set (memoized on `h.descendantsCache` — safe to share with
|
|
137
|
+
// the store's cache, entries are pure derived state) so the membership
|
|
138
|
+
// check is ONE batched IN-list query over the union, not one per tag.
|
|
139
|
+
// An identity-less tag can still be "known" two ways: a `note_tags` row
|
|
140
|
+
// exists without a `tags` row (not produced by current writers, but
|
|
141
|
+
// contract-tolerated), or children declared it a parent (`childrenOf`
|
|
142
|
+
// edges exist for undeclared parents) and a descendant has notes.
|
|
143
|
+
const expansions = new Map<string, Set<string>>();
|
|
144
|
+
for (const tag of suspects) expansions.set(tag, getTagExpansion(h, tag, mode));
|
|
145
|
+
const candidateNames = new Set<string>();
|
|
146
|
+
for (const set of expansions.values()) for (const name of set) candidateNames.add(name);
|
|
147
|
+
const counts = countMembership(db, candidateNames);
|
|
148
|
+
|
|
149
|
+
const warnings: QueryWarning[] = [];
|
|
150
|
+
let suppressed = 0;
|
|
151
|
+
for (const tag of suspects) {
|
|
152
|
+
const hasOwnMembership = (counts.get(tag) ?? 0) > 0;
|
|
153
|
+
let hasExpansionMembers = false;
|
|
154
|
+
for (const t of expansions.get(tag)!) {
|
|
155
|
+
if ((counts.get(t) ?? 0) > 0) {
|
|
156
|
+
hasExpansionMembers = true;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!hasOwnMembership && !hasExpansionMembers) {
|
|
162
|
+
if (warnings.length >= MAX_UNKNOWN_TAG_WARNINGS) {
|
|
163
|
+
suppressed++;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const suggestion = suggestSimilarTag(h.allTags, tag);
|
|
167
|
+
warnings.push({
|
|
168
|
+
code: "unknown_tag",
|
|
169
|
+
message: `tag "${tag}" has no identity row and no notes match it (directly or via expansion) — check spelling, or create it first with update-tag`,
|
|
170
|
+
tag,
|
|
171
|
+
...(suggestion ? { did_you_mean: suggestion } : {}),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (suppressed > 0) {
|
|
176
|
+
warnings.push({
|
|
177
|
+
code: "warnings_truncated",
|
|
178
|
+
message: `${suppressed} additional unknown_tag warning(s) suppressed — at most ${MAX_UNKNOWN_TAG_WARNINGS} are reported per query.`,
|
|
179
|
+
suppressed,
|
|
180
|
+
limit: MAX_UNKNOWN_TAG_WARNINGS,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return warnings;
|
|
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
|
+
}
|