@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/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
- // When a cursor is present, decode it, verify its query_hash matches the
944
- // current query, and add a keyset predicate of the form:
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 (opts.cursor) {
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
- cursorPayload = decodeCursor(opts.cursor);
971
- const expectedHash = computeQueryHash(toQueryHashInputs(opts));
972
- if (cursorPayload.query_hash !== expectedHash) {
973
- throw new CursorError(
974
- `cursor was minted for a different query — drop the cursor and restart iteration`,
975
- "cursor_query_mismatch",
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
- // Translate the millis watermark back to an ISO string for the SQL
979
- // comparison. SQLite's `n.updated_at` is TEXT in canonical ISO form
980
- // (the store's `toISOString()` output), and ISO timestamps sort
981
- // lexicographically in the same order as their millisecond epochs
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 (opts.cursor) {
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
- if (opts.cursor) {
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 rank
1404
+ ORDER BY ${orderBy}
1238
1405
  LIMIT ?
1239
- `).all(query, ...searchTags, limit) as NoteRow[];
1406
+ `).all(ftsQuery, ...searchTags, limit) as NoteRow[];
1240
1407
  return notesWithTags(db, rows);
1241
- } catch {
1242
- return [];
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 rank
1426
+ ORDER BY ${orderBy}
1253
1427
  LIMIT ?
1254
- `).all(query, limit) as NoteRow[];
1428
+ `).all(ftsQuery, limit) as NoteRow[];
1255
1429
  return notesWithTags(db, rows);
1256
- } catch {
1257
- return [];
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
- export function listTags(db: Database): { name: string; count: number }[] {
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
- return rows;
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
- constructor(message: string, code = "INVALID_QUERY") {
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
+ }