@openparachute/vault 0.7.0-rc.1 → 0.7.0-rc.2

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.
@@ -94,41 +94,59 @@ export function encodeCursor(payload: CursorPayload): string {
94
94
  return Buffer.from(json, "utf8").toString("base64url");
95
95
  }
96
96
 
97
- /** Decode a cursor string. Throws `CursorError` on any structural problem. */
97
+ /**
98
+ * Trailing hint appended to every `cursor_invalid` message (vault#550) so a
99
+ * caller who passed a garbage cursor — or never understood the bootstrap
100
+ * shape in the first place — gets the actual recovery flow, not just "this
101
+ * string is broken." The bootstrap flow: an EMPTY string (`cursor: ""` /
102
+ * `?cursor=`) opts into cursor mode without a watermark yet; the response's
103
+ * `next_cursor` is what you pass on every call after that.
104
+ */
105
+ const CURSOR_BOOTSTRAP_HINT =
106
+ 'first call: pass cursor:"" (or omit `cursor` entirely for a plain, non-paginated list); the response carries `next_cursor` — pass that back on each subsequent call to resume from the watermark.';
107
+
108
+ /**
109
+ * Decode a cursor string. Throws `CursorError` on any structural problem.
110
+ * An EMPTY string is deliberately NOT accepted here — callers (queryNotes /
111
+ * queryNotesPaged) must special-case `opts.cursor === ""` as "cursor mode,
112
+ * no watermark yet" and skip calling this at all (vault#550 bootstrap fix).
113
+ * Any caller that reaches this function with an empty string gets the same
114
+ * loud `cursor_invalid` as any other malformed cursor.
115
+ */
98
116
  export function decodeCursor(cursor: string): CursorPayload {
99
117
  if (typeof cursor !== "string" || cursor.length === 0) {
100
- throw new CursorError("cursor must be a non-empty string", "cursor_invalid");
118
+ throw new CursorError(`cursor must be a non-empty string — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
101
119
  }
102
120
  let json: string;
103
121
  try {
104
122
  json = Buffer.from(cursor, "base64url").toString("utf8");
105
123
  } catch {
106
- throw new CursorError("cursor is not valid base64url", "cursor_invalid");
124
+ throw new CursorError(`cursor is not valid base64url — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
107
125
  }
108
126
  let parsed: unknown;
109
127
  try {
110
128
  parsed = JSON.parse(json);
111
129
  } catch {
112
- throw new CursorError("cursor payload is not valid JSON", "cursor_invalid");
130
+ throw new CursorError(`cursor payload is not valid JSON — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
113
131
  }
114
132
  if (!parsed || typeof parsed !== "object") {
115
- throw new CursorError("cursor payload must be an object", "cursor_invalid");
133
+ throw new CursorError(`cursor payload must be an object — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
116
134
  }
117
135
  const p = parsed as Record<string, unknown>;
118
136
  if (typeof p.v !== "number" || p.v !== CURSOR_VERSION) {
119
137
  throw new CursorError(
120
- `cursor schema version mismatch (expected ${CURSOR_VERSION}, got ${String(p.v)})`,
138
+ `cursor schema version mismatch (expected ${CURSOR_VERSION}, got ${String(p.v)}) — ${CURSOR_BOOTSTRAP_HINT}`,
121
139
  "cursor_invalid",
122
140
  );
123
141
  }
124
142
  if (typeof p.last_updated_at !== "number" || !Number.isFinite(p.last_updated_at)) {
125
- throw new CursorError("cursor.last_updated_at must be a finite number", "cursor_invalid");
143
+ throw new CursorError(`cursor.last_updated_at must be a finite number — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
126
144
  }
127
145
  if (typeof p.last_id !== "string") {
128
- throw new CursorError("cursor.last_id must be a string", "cursor_invalid");
146
+ throw new CursorError(`cursor.last_id must be a string — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
129
147
  }
130
148
  if (typeof p.query_hash !== "string" || p.query_hash.length === 0) {
131
- throw new CursorError("cursor.query_hash must be a non-empty string", "cursor_invalid");
149
+ throw new CursorError(`cursor.query_hash must be a non-empty string — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
132
150
  }
133
151
  return {
134
152
  v: p.v,
package/core/src/links.ts CHANGED
@@ -404,6 +404,66 @@ export function traverseLinks(
404
404
  return results;
405
405
  }
406
406
 
407
+ /** Hydrated find-path result — see `hydratePathResult` for field semantics. */
408
+ export interface FindPathResult {
409
+ /** Note IDs, source → target (unchanged shape — back-compat). */
410
+ path: string[];
411
+ /** relationships[i] is the edge connecting path[i] to path[i+1] (unchanged shape). */
412
+ relationships: string[];
413
+ /**
414
+ * Hydrated companion to `path[]` (vault#550, additive): one entry per
415
+ * node, in the same order, carrying the note's `path` field alongside
416
+ * its `id` — so a caller doesn't have to round-trip each id through a
417
+ * separate note fetch just to render a human-legible chain.
418
+ */
419
+ nodes: { id: string; path: string | null }[];
420
+ /**
421
+ * Hydrated companion to `relationships[]` (vault#550, additive): each
422
+ * hop as a self-contained `{source, target, relationship}` edge plus
423
+ * both endpoints' note `path`, so a graph-rendering client can draw the
424
+ * chain without cross-referencing `nodes`.
425
+ */
426
+ edges: {
427
+ source: string;
428
+ target: string;
429
+ relationship: string;
430
+ sourcePath: string | null;
431
+ targetPath: string | null;
432
+ }[];
433
+ }
434
+
435
+ /**
436
+ * Hydrate a raw BFS result (`path` ids + `relationships`) with each node's
437
+ * note `path` field, in ONE batched query (not one fetch per hop). `path`
438
+ * and `relationships` pass through byte-identical to before this existed —
439
+ * `nodes`/`edges` are pure additions.
440
+ */
441
+ function hydratePathResult(db: Database, path: string[], relationships: string[]): FindPathResult {
442
+ const pathById = new Map<string, string | null>();
443
+ for (const chunk of chunkForInClause(path)) {
444
+ const placeholders = chunk.map(() => "?").join(", ");
445
+ const rows = db.prepare(`SELECT id, path FROM notes WHERE id IN (${placeholders})`).all(...chunk) as
446
+ { id: string; path: string | null }[];
447
+ for (const row of rows) pathById.set(row.id, row.path);
448
+ }
449
+
450
+ const nodes = path.map((id) => ({ id, path: pathById.get(id) ?? null }));
451
+ const edges: FindPathResult["edges"] = [];
452
+ for (let i = 0; i < path.length - 1; i++) {
453
+ const source = path[i]!;
454
+ const target = path[i + 1]!;
455
+ edges.push({
456
+ source,
457
+ target,
458
+ relationship: relationships[i] ?? "",
459
+ sourcePath: pathById.get(source) ?? null,
460
+ targetPath: pathById.get(target) ?? null,
461
+ });
462
+ }
463
+
464
+ return { path, relationships, nodes, edges };
465
+ }
466
+
407
467
  /**
408
468
  * Find a path between two notes in the link graph.
409
469
  * Returns the sequence of note IDs from source to target, or null if no path exists.
@@ -413,11 +473,11 @@ export function findPath(
413
473
  sourceId: string,
414
474
  targetId: string,
415
475
  opts?: { max_depth?: number },
416
- ): { path: string[]; relationships: string[] } | null {
476
+ ): FindPathResult | null {
417
477
  const maxDepth = opts?.max_depth ?? 5;
418
478
 
419
479
  if (sourceId === targetId) {
420
- return { path: [sourceId], relationships: [] };
480
+ return hydratePathResult(db, [sourceId], []);
421
481
  }
422
482
 
423
483
  // BFS from source
@@ -460,7 +520,7 @@ export function findPath(
460
520
  current = entry.parent;
461
521
  }
462
522
  path.unshift(sourceId);
463
- return { path, relationships };
523
+ return hydratePathResult(db, path, relationships);
464
524
  }
465
525
  }
466
526
  }
package/core/src/mcp.ts CHANGED
@@ -4,7 +4,8 @@ import { transactionAsync } from "./txn.js";
4
4
  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
- import { TAG_EXPAND_MODES, stripTagHash, type TagExpandMode } from "./tag-hierarchy.js";
7
+ import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "./tag-hierarchy.js";
8
+ import { collectUnknownTagWarnings, type QueryWarning } from "./query-warnings.js";
8
9
  import * as linkOps from "./links.js";
9
10
  import * as tagSchemaOps from "./tag-schemas.js";
10
11
  import type { TagFieldSchema } from "./tag-schemas.js";
@@ -237,7 +238,12 @@ Defaults: include_content=true for single note, false for lists. include_links=f
237
238
 
238
239
  Large notes: pass \`content_offset\` / \`content_length\` (UTF-8 bytes) for a bounded read of note content — the response carries the slice plus \`content_total_length\` and \`content_next_offset\` (null when complete). Loop, feeding \`content_next_offset\` back as \`content_offset\`, to read a note too large for one response.
239
240
 
240
- Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returned content. Tune with \`expand_depth\` (1–3, default 1) and \`expand_mode\` ("full" inlines full content, "summary" inlines only metadata.summary). Expansions are deduplicated across the query and cycle-guarded.`,
241
+ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returned content. Tune with \`expand_depth\` (1–3, default 1) and \`expand_mode\` ("full" inlines full content, "summary" inlines only metadata.summary). Expansions are deduplicated across the query and cycle-guarded.
242
+
243
+ Response shape (vault#550 — three variants, pick by what you passed):
244
+ - Default (no \`cursor\`, no warnings): a bare array of notes.
245
+ - 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.`,
241
247
  inputSchema: {
242
248
  type: "object",
243
249
  properties: {
@@ -328,7 +334,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
328
334
  cursor: {
329
335
  type: "string",
330
336
  description:
331
- "Opaque cursor for 'since last checked' agent loops (vault#313). First call: omit. The response will include `next_cursor` pass it on the subsequent call to receive only notes created or updated since the prior page. The cursor binds to the query's filters (tag, path, metadata, etc.); changing them between calls returns a structured `cursor_query_mismatch` error. Pagination via cursor orders results by `updated_at ASC` and is mutually exclusive with `order_by` and `sort: \"desc\"`. The response shape switches to `{notes, next_cursor}` when this parameter is present.",
337
+ "Opaque cursor for 'since last checked' agent loops (vault#313). Bootstrap flow (vault#550): FIRST call passes `cursor: \"\"` (empty string) — this opts into cursor mode with no watermark yet and the response comes back as `{notes, next_cursor}`. Persist `next_cursor` and pass it back verbatim as `cursor` on every SUBSEQUENT call to receive only notes created or updated since the prior page. Omitting `cursor` entirely (not passing the key at all) is a DIFFERENT thing — a plain one-shot list with no cursor envelope and no way to resume; use that when you don't want pagination at all. The cursor binds to the query's filters (tag, path, metadata, etc.); changing them between calls returns a structured `cursor_query_mismatch` error, and a malformed/expired cursor returns `cursor_invalid` naming the bootstrap flow again. Pagination via cursor orders results by `updated_at ASC` and is mutually exclusive with `order_by` and `sort: \"desc\"`.",
332
338
  },
333
339
  include_content: { type: "boolean", description: "Include note content (default: true for single, false for list)" },
334
340
  content_offset: {
@@ -466,7 +472,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
466
472
  // neighborhood every call to be cursor-stable; we punt for now).
467
473
  // Both surface as INVALID_QUERY rather than silently returning
468
474
  // wrong rows.
469
- const cursorMode = typeof params.cursor === "string" && params.cursor.length > 0;
475
+ // Presence, not truthiness (vault#550 bootstrap fix) `cursor: ""`
476
+ // is the bootstrap call ("I want to paginate, no watermark yet") and
477
+ // must still engage cursor mode. Before this fix `"".length > 0` was
478
+ // false, so the very first call could never obtain a `next_cursor`.
479
+ const cursorMode = typeof params.cursor === "string";
470
480
  if (cursorMode && params.search) {
471
481
  throw new QueryError(
472
482
  `cursor is incompatible with full-text search — FTS has its own ordering. Use date_filter on updated_at for since-last-checked search.`,
@@ -495,6 +505,14 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
495
505
  // --- Full-text search ---
496
506
  let results: Note[];
497
507
  let nextCursor: string | null = null;
508
+ // Warnings channel (vault#550) — structured-query only for this
509
+ // wave; `search=` is out of scope (see #551). Scope-unaware by
510
+ // design (see `core/src/query-warnings.ts` doc comment) — a
511
+ // tag-scoped MCP session gets these stripped by the
512
+ // `applyTagScopeWrappers` query-notes wrapper in `src/mcp-tools.ts`
513
+ // before the result reaches the caller, so an out-of-scope tag name
514
+ // never leaks via `did_you_mean`.
515
+ let queryWarnings: QueryWarning[] = [];
498
516
  if (params.search) {
499
517
  // Normalize tag param
500
518
  const tags = normalizeTags(params.tag);
@@ -556,6 +574,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
556
574
  offset: params.offset as number | undefined,
557
575
  cursor: cursorMode ? (params.cursor as string) : undefined,
558
576
  };
577
+ queryWarnings = collectUnknownTagWarnings(db, queryOpts.tags, queryOpts.expand, store.getTagHierarchy());
559
578
  if (cursorMode) {
560
579
  const page = await store.queryNotesPaged(queryOpts);
561
580
  results = page.notes;
@@ -637,13 +656,29 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
637
656
  }
638
657
  // Cursor mode wraps the list in `{notes, next_cursor}` so callers can
639
658
  // chain calls without tracking a watermark client-side. Legacy
640
- // callers (no `cursor` param) still get the flat array.
641
- if (cursorMode) return { notes: enrichedOut, next_cursor: nextCursor };
642
- return enrichedOut;
659
+ // callers (no `cursor` param, no warnings) still get the flat
660
+ // array (vault#550 warnings channel is additive, never forces
661
+ // the envelope on its own outside cursor mode... except when
662
+ // there ARE warnings, in which case the envelope is the only way
663
+ // to attach them).
664
+ if (cursorMode) {
665
+ return {
666
+ notes: enrichedOut,
667
+ next_cursor: nextCursor,
668
+ ...(queryWarnings.length > 0 ? { warnings: queryWarnings } : {}),
669
+ };
670
+ }
671
+ return queryWarnings.length > 0 ? { notes: enrichedOut, warnings: queryWarnings } : enrichedOut;
643
672
  }
644
673
 
645
- if (cursorMode) return { notes: output, next_cursor: nextCursor };
646
- return output;
674
+ if (cursorMode) {
675
+ return {
676
+ notes: output,
677
+ next_cursor: nextCursor,
678
+ ...(queryWarnings.length > 0 ? { warnings: queryWarnings } : {}),
679
+ };
680
+ }
681
+ return queryWarnings.length > 0 ? { notes: output, warnings: queryWarnings } : output;
647
682
  },
648
683
  },
649
684
 
@@ -1353,7 +1388,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1353
1388
  {
1354
1389
  name: "list-tags",
1355
1390
  requiredVerb: "read",
1356
- description: `List tags with usage counts. Pass \`tag\` to get a single tag's full record (description, fields, relationships, parent_names, timestamps). Pass \`include_schema: true\` to include the full record for every tag.`,
1391
+ description: `List tags with usage counts. Each row carries \`count\` (notes carrying the EXACT tag) and \`expanded_count\` (vault#550 — distinct notes matching the tag OR any transitive descendant under the default subtypes expansion; use this to see a parent tag's true rollup when its notes are actually tagged with a more specific child). Pass \`tag\` to get a single tag's full record (description, fields, relationships, parent_names, timestamps) — errors with \`error_type: "tag_not_found"\` (plus a \`did_you_mean\` hint when a close match exists) if the tag has no identity row and no notes. Pass \`include_schema: true\` to include the full record for every tag.`,
1357
1392
  inputSchema: {
1358
1393
  type: "object",
1359
1394
  properties: {
@@ -1368,9 +1403,30 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1368
1403
  const allTags = noteOps.listTags(db);
1369
1404
  const found = allTags.find((t) => t.name === singleTag);
1370
1405
  const record = tagSchemaOps.getTagRecord(db, singleTag);
1406
+ // vault#550 — a tag with no identity row AND no note carrying it
1407
+ // isn't a legitimate (if empty) tag, it's a typo or a tag from a
1408
+ // different vault. Return a structured miss instead of a
1409
+ // synthesized all-null 200. `did_you_mean` searches the full
1410
+ // vault-wide tag catalog — core is scope-unaware by architecture.
1411
+ // Tag-scope enforcement lives in the server layer's list-tags
1412
+ // wrapper (src/mcp-tools.ts:applyTagScopeWrappers): a scoped
1413
+ // session's out-of-scope `tag` param short-circuits to
1414
+ // tag_not_found BEFORE this executes, and an in-scope miss gets
1415
+ // its `did_you_mean` dropped unless the suggestion is also
1416
+ // in-scope.
1417
+ if (!found && !record) {
1418
+ const suggestion = suggestSimilarTag(allTags.map((t) => t.name), singleTag);
1419
+ return {
1420
+ error: "Tag not found",
1421
+ error_type: "tag_not_found",
1422
+ tag: singleTag,
1423
+ ...(suggestion ? { did_you_mean: suggestion } : {}),
1424
+ };
1425
+ }
1371
1426
  return {
1372
1427
  name: singleTag,
1373
1428
  count: found?.count ?? 0,
1429
+ expanded_count: found?.expanded_count ?? 0,
1374
1430
  description: record?.description ?? null,
1375
1431
  fields: record?.fields ?? null,
1376
1432
  relationships: record?.relationships ?? null,
@@ -1589,7 +1645,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1589
1645
  {
1590
1646
  name: "find-path",
1591
1647
  requiredVerb: "read",
1592
- description: "Find the shortest path between two notes in the link graph. Accepts IDs or paths. Returns the chain of note IDs and relationships, or null if no path exists.",
1648
+ description: "Find the shortest path between two notes in the link graph. Accepts IDs or paths. Returns null if no path exists, else `{path, relationships, nodes, edges}`: `path` (note IDs, source→target) and `relationships` (relationships[i] connects path[i] to path[i+1]) are the original id-only shape; `nodes` (vault#550, additive) hydrates each id in `path` with the note's own `path` field — `[{id, path}]` in the same order; `edges` (additive) is the self-contained hop list — `[{source, target, relationship, sourcePath, targetPath}]` — for rendering the chain without cross-referencing `nodes`.",
1593
1649
  inputSchema: {
1594
1650
  type: "object",
1595
1651
  properties: {
package/core/src/notes.ts CHANGED
@@ -20,7 +20,7 @@ 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
25
 
26
26
  let idCounter = 0;
@@ -684,7 +684,78 @@ export function deleteNote(db: Database, id: string): void {
684
684
  db.prepare("DELETE FROM notes WHERE id = ?").run(id);
685
685
  }
686
686
 
687
+ /**
688
+ * Validate `limit`/`offset` before any query work (vault#550). A negative
689
+ * `limit` used to leak SQLite's "negative LIMIT means unlimited" semantics
690
+ * straight through to the caller — silently returning EVERYTHING when the
691
+ * caller almost certainly meant "no limit I typed by mistake." A
692
+ * non-numeric value (a bad MCP param type, or a REST caller that slipped
693
+ * past `parseNotesQueryOpts`'s own stricter check) used to silently fall
694
+ * back to the default via `typeof opts.limit === "number" ? opts.limit :
695
+ * 100` below — also silent-wrong. This is the single choke point BOTH
696
+ * transports funnel through (`store.queryNotes` / `store.queryNotesPaged`
697
+ * call this via `queryNotes`), so REST and MCP get identical validation
698
+ * for free.
699
+ */
700
+ function validateLimitOffset(opts: QueryOpts): void {
701
+ if (opts.limit !== undefined) {
702
+ if (typeof opts.limit !== "number" || !Number.isFinite(opts.limit) || !Number.isInteger(opts.limit) || opts.limit < 0) {
703
+ throw new QueryError(
704
+ `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.`,
705
+ "INVALID_QUERY",
706
+ {
707
+ error_type: "invalid_query",
708
+ field: "limit",
709
+ got: opts.limit,
710
+ hint: "pass a non-negative integer, or omit for the default",
711
+ },
712
+ );
713
+ }
714
+ }
715
+ if (opts.offset !== undefined) {
716
+ if (typeof opts.offset !== "number" || !Number.isFinite(opts.offset) || !Number.isInteger(opts.offset) || opts.offset < 0) {
717
+ throw new QueryError(
718
+ `invalid offset: ${JSON.stringify(opts.offset)} — must be a non-negative integer.`,
719
+ "INVALID_QUERY",
720
+ {
721
+ error_type: "invalid_query",
722
+ field: "offset",
723
+ got: opts.offset,
724
+ hint: "pass a non-negative integer, or omit for the default of 0",
725
+ },
726
+ );
727
+ }
728
+ }
729
+ }
730
+
731
+ /**
732
+ * Validate an ISO-8601 date-filter value before it's bound into a SQL
733
+ * comparison (vault#550). `n.created_at` / `n.updated_at` are TEXT columns
734
+ * compared lexicographically; an unparseable value used to bind straight
735
+ * through and silently match "nothing" or "everything" depending on how it
736
+ * happened to sort against real ISO strings, rather than erroring. Applies
737
+ * uniformly to BOTH `dateFilter` (bracket-style REST / MCP `date_filter`)
738
+ * and the legacy `dateFrom`/`dateTo` shorthand (MCP `date_from`/`date_to`,
739
+ * still supported) — both flow through this same function, so both get the
740
+ * same loud validation from one place.
741
+ */
742
+ function validateIsoDateValue(field: string, value: string): void {
743
+ if (Number.isNaN(Date.parse(value))) {
744
+ throw new QueryError(
745
+ `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").`,
746
+ "INVALID_QUERY",
747
+ {
748
+ error_type: "invalid_query",
749
+ field,
750
+ got: value,
751
+ hint: "pass an ISO-8601 date or timestamp",
752
+ },
753
+ );
754
+ }
755
+ }
756
+
687
757
  export function queryNotes(db: Database, opts: QueryOpts): Note[] {
758
+ validateLimitOffset(opts);
688
759
  const conditions: string[] = [];
689
760
  const params: SQLQueryBindings[] = [];
690
761
 
@@ -920,19 +991,23 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
920
991
  column = `"meta_${field}"`;
921
992
  }
922
993
  if (filter.from !== undefined) {
994
+ validateIsoDateValue(field === "created_at" ? "date_filter.from" : `date_filter.from (${field})`, filter.from);
923
995
  conditions.push(`${column} >= ?`);
924
996
  params.push(filter.from);
925
997
  }
926
998
  if (filter.to !== undefined) {
999
+ validateIsoDateValue(field === "created_at" ? "date_filter.to" : `date_filter.to (${field})`, filter.to);
927
1000
  conditions.push(`${column} < ?`);
928
1001
  params.push(filter.to);
929
1002
  }
930
1003
  } else if (hasLegacyDate) {
931
1004
  if (opts.dateFrom) {
1005
+ validateIsoDateValue("date_from", opts.dateFrom);
932
1006
  conditions.push("n.created_at >= ?");
933
1007
  params.push(opts.dateFrom);
934
1008
  }
935
1009
  if (opts.dateTo) {
1010
+ validateIsoDateValue("date_to", opts.dateTo);
936
1011
  conditions.push("n.created_at < ?");
937
1012
  params.push(opts.dateTo);
938
1013
  }
@@ -940,8 +1015,22 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
940
1015
 
941
1016
  // ---- Cursor predicate (vault#313) ----
942
1017
  //
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:
1018
+ // Cursor mode is keyed on PRESENCE of `opts.cursor`, not truthiness
1019
+ // (vault#550 bootstrap fix). `cursor: ""` is the bootstrap call — "I want
1020
+ // to paginate, I don't have a watermark yet" — and must still force the
1021
+ // keyset ORDER BY below so the FIRST page is taken in the same order
1022
+ // subsequent pages will be. `opts.cursor === undefined` is the only way
1023
+ // to opt OUT of cursor mode entirely (the legacy flat-array shape).
1024
+ // Before this fix, `if (opts.cursor)` treated an empty string exactly
1025
+ // like "no cursor" — the caller's bootstrap intent silently vanished and
1026
+ // the first page came back in `created_at` order instead of the
1027
+ // `updated_at` keyset order the SECOND page (a real cursor) would use,
1028
+ // so naive "did I see this note already" comparisons could skip or
1029
+ // duplicate rows across the boundary.
1030
+ //
1031
+ // When a REAL (non-empty) cursor is present, decode it, verify its
1032
+ // query_hash matches the current query, and add a keyset predicate of
1033
+ // the form:
945
1034
  //
946
1035
  // (updated_at > last_updated_at)
947
1036
  // OR (updated_at = last_updated_at AND id > last_id)
@@ -953,8 +1042,9 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
953
1042
  // exclusive with cursor mode (a "since last checked" loop wants
954
1043
  // ascending updated_at, full stop); we reject with INVALID_QUERY so
955
1044
  // callers don't silently get a broken iteration.
1045
+ const cursorMode = opts.cursor !== undefined;
956
1046
  let cursorPayload: CursorPayload | null = null;
957
- if (opts.cursor) {
1047
+ if (cursorMode) {
958
1048
  if (opts.orderBy) {
959
1049
  throw new QueryError(
960
1050
  `cursor and order_by are mutually exclusive — cursor pagination forces order by updated_at`,
@@ -967,33 +1057,39 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
967
1057
  "INVALID_QUERY",
968
1058
  );
969
1059
  }
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",
1060
+ if (opts.cursor !== "") {
1061
+ cursorPayload = decodeCursor(opts.cursor!);
1062
+ const expectedHash = computeQueryHash(toQueryHashInputs(opts));
1063
+ if (cursorPayload.query_hash !== expectedHash) {
1064
+ throw new CursorError(
1065
+ `cursor was minted for a different query — drop the cursor and restart iteration`,
1066
+ "cursor_query_mismatch",
1067
+ );
1068
+ }
1069
+ // Translate the millis watermark back to an ISO string for the SQL
1070
+ // comparison. SQLite's `n.updated_at` is TEXT in canonical ISO form
1071
+ // (the store's `toISOString()` output), and ISO timestamps sort
1072
+ // lexicographically in the same order as their millisecond epochs
1073
+ // when they all use the same canonical form — which every timestamp
1074
+ // vault mints does. Cursors minted on heterogeneous timestamps
1075
+ // (e.g. an import that preserved unusual formatting) are still
1076
+ // safe: we round-trip the cursor's millis through `new Date()`'s
1077
+ // canonical ISO so the comparison is apples-to-apples.
1078
+ const cursorIso = millisToIso(cursorPayload.last_updated_at);
1079
+ conditions.push(
1080
+ "(n.updated_at > ? OR (n.updated_at = ? AND n.id > ?))",
976
1081
  );
1082
+ params.push(cursorIso, cursorIso, cursorPayload.last_id);
977
1083
  }
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);
1084
+ // else: bootstrap call (`cursor === ""`) no watermark yet, no
1085
+ // predicate to add, but the ORDER BY below still switches to the
1086
+ // keyset order so this first page is consistent with every page after
1087
+ // it.
992
1088
  }
993
1089
 
994
1090
  const direction = opts.sort === "desc" ? "DESC" : "ASC";
995
1091
  let orderBy: string;
996
- if (opts.cursor) {
1092
+ if (cursorMode) {
997
1093
  // Cursor mode forces a deterministic keyset order. `id` is the
998
1094
  // tiebreaker — without it, two notes sharing an `updated_at` would
999
1095
  // be at the mercy of SQLite's row order and the next page could
@@ -1177,7 +1273,11 @@ export function queryNotesPaged(db: Database, opts: QueryOpts): QueryNotesPage {
1177
1273
  // cursor's watermark — see the JSDoc rationale above.
1178
1274
  let lastUpdatedAt = 0;
1179
1275
  let lastId = "";
1180
- if (opts.cursor) {
1276
+ // `opts.cursor === ""` is the bootstrap call (vault#550) — there's no
1277
+ // prior watermark to decode, so it takes the same 0/"" sentinel path as
1278
+ // `opts.cursor === undefined`. Only a REAL (non-empty) cursor gets
1279
+ // re-decoded here.
1280
+ if (opts.cursor !== undefined && opts.cursor !== "") {
1181
1281
  // Re-decode (we already validated in queryNotes); this is cheap.
1182
1282
  const prior = decodeCursor(opts.cursor);
1183
1283
  lastUpdatedAt = prior.last_updated_at;
@@ -1303,7 +1403,18 @@ export function getNoteTags(db: Database, noteId: string): string[] {
1303
1403
  return rows.map((r) => r.tag_name);
1304
1404
  }
1305
1405
 
1306
- export function listTags(db: Database): { name: string; count: number }[] {
1406
+ /**
1407
+ * List every declared tag with its literal `count` (notes carrying that
1408
+ * EXACT tag name) and its `expanded_count` (vault#550) — distinct notes
1409
+ * matching the tag OR any transitive descendant under the DEFAULT
1410
+ * (subtypes) expansion axis. `expanded_count` is what surfaces a parent
1411
+ * tag as non-empty even when every one of its notes is actually tagged
1412
+ * with a more specific child tag — `count` alone reports 0 for that
1413
+ * parent, which reads as "this tag is dead" when it's really just a
1414
+ * rollup label. See `computeExpandedTagCounts` for the single-pass
1415
+ * (no N+1) computation.
1416
+ */
1417
+ export function listTags(db: Database): { name: string; count: number; expanded_count: number }[] {
1307
1418
  const rows = db.prepare(`
1308
1419
  SELECT t.name, COUNT(nt.note_id) as count
1309
1420
  FROM tags t
@@ -1311,7 +1422,10 @@ export function listTags(db: Database): { name: string; count: number }[] {
1311
1422
  GROUP BY t.name
1312
1423
  ORDER BY t.name
1313
1424
  `).all() as { name: string; count: number }[];
1314
- return rows;
1425
+
1426
+ const h = loadTagHierarchy(db);
1427
+ const expandedCounts = computeExpandedTagCounts(db, h);
1428
+ return rows.map((r) => ({ ...r, expanded_count: expandedCounts.get(r.name) ?? 0 }));
1315
1429
  }
1316
1430
 
1317
1431
  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