@openparachute/vault 0.6.5 → 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.
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
 
@@ -0,0 +1,184 @@
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
+ }
package/core/src/store.ts CHANGED
@@ -70,8 +70,15 @@ export class BunSqliteStore implements Store {
70
70
  * boot or after an invalidation does the scan; subsequent calls hit the
71
71
  * cache. Returns the same object until invalidated, so callers can rely
72
72
  * on identity for memoizing per-tag descendant sets.
73
+ *
74
+ * Public (vault#550 fold): the query-warnings collector
75
+ * (`core/src/query-warnings.ts:collectUnknownTagWarnings`) runs on every
76
+ * tag-filtered structured query — threading this cached hierarchy in
77
+ * (instead of a fresh `loadTagHierarchy` per request) keeps the
78
+ * common all-tags-known case at ~zero extra cost. Treat the returned
79
+ * object as READ-ONLY — it's the shared cache, invalidated by writers.
73
80
  */
74
- private getTagHierarchy(): TagHierarchy {
81
+ getTagHierarchy(): TagHierarchy {
75
82
  if (!this._tagHierarchy) this._tagHierarchy = loadTagHierarchy(this.db);
76
83
  return this._tagHierarchy;
77
84
  }
@@ -445,7 +452,7 @@ export class BunSqliteStore implements Store {
445
452
  return expanded;
446
453
  }
447
454
 
448
- async listTags(): Promise<{ name: string; count: number }[]> {
455
+ async listTags(): Promise<{ name: string; count: number; expanded_count: number }[]> {
449
456
  return noteOps.listTags(this.db);
450
457
  }
451
458
 
@@ -249,6 +249,119 @@ export function getTagExpansion(
249
249
  }
250
250
  }
251
251
 
252
+ /**
253
+ * Suggest the closest EXISTING tag name to an unmatched input — the
254
+ * `did_you_mean` hint on `unknown_tag` warnings (vault#550) and
255
+ * `tag_not_found` errors. Candidates are scored, lower is better:
256
+ *
257
+ * 0. case-only difference (`Voice` vs `voice`)
258
+ * 1. a prefix relationship either direction (`voice` / `voice-memo` —
259
+ * catches plural/singular drift and namespace-child typos)
260
+ * 2+dist. Levenshtein edit distance, when within a length-scaled budget
261
+ *
262
+ * Returns the single best match, or `undefined` when nothing is close
263
+ * enough to be worth suggesting — a genuinely novel tag name shouldn't get
264
+ * a noisy "did you mean" pointing at an unrelated tag.
265
+ */
266
+ export function suggestSimilarTag(
267
+ candidates: Iterable<string>,
268
+ input: string,
269
+ ): string | undefined {
270
+ const lower = input.toLowerCase();
271
+ let best: string | undefined;
272
+ let bestScore = Infinity;
273
+ for (const candidate of candidates) {
274
+ if (candidate === input) continue;
275
+ const candLower = candidate.toLowerCase();
276
+ let score: number | null = null;
277
+ if (candLower === lower) {
278
+ score = 0;
279
+ } else if (lower.length >= 2 && candLower.length >= 2 && (candLower.startsWith(lower) || lower.startsWith(candLower))) {
280
+ score = 1;
281
+ } else {
282
+ const dist = levenshtein(lower, candLower);
283
+ const budget = Math.max(2, Math.ceil(Math.min(lower.length, candLower.length) * 0.34));
284
+ if (dist <= budget) score = 2 + dist;
285
+ }
286
+ if (score !== null && score < bestScore) {
287
+ bestScore = score;
288
+ best = candidate;
289
+ }
290
+ }
291
+ return best;
292
+ }
293
+
294
+ /** Classic Levenshtein edit distance, O(m·n) time / O(n) space. */
295
+ function levenshtein(a: string, b: string): number {
296
+ const m = a.length;
297
+ const n = b.length;
298
+ if (m === 0) return n;
299
+ if (n === 0) return m;
300
+ let prev = new Array<number>(n + 1);
301
+ let curr = new Array<number>(n + 1);
302
+ for (let j = 0; j <= n; j++) prev[j] = j;
303
+ for (let i = 1; i <= m; i++) {
304
+ curr[0] = i;
305
+ for (let j = 1; j <= n; j++) {
306
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
307
+ curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1, prev[j - 1]! + cost);
308
+ }
309
+ [prev, curr] = [curr, prev];
310
+ }
311
+ return prev[n]!;
312
+ }
313
+
314
+ /**
315
+ * Compute, for every declared tag, the number of DISTINCT notes matching
316
+ * that tag OR any transitive descendant under the subtypes axis (vault#550
317
+ * `expanded_count` — the rollup a parent tag whose notes are all tagged
318
+ * with a CHILD tag needs to report a non-zero count).
319
+ *
320
+ * One query fetches every `(tag_name, note_id)` pairing from `note_tags`;
321
+ * the fan-out to ancestors is pure in-memory set work over the (memoized)
322
+ * hierarchy — not one query per tag, so this stays cheap regardless of how
323
+ * many tags the vault declares. `getTagDescendants` is memoized per tag on
324
+ * `h`, so the ancestor-closure build below reuses that cache instead of
325
+ * re-walking the graph per candidate.
326
+ */
327
+ export function computeExpandedTagCounts(
328
+ db: Database,
329
+ h: TagHierarchy,
330
+ ): Map<string, number> {
331
+ const rows = db.prepare(`SELECT tag_name, note_id FROM note_tags`).all() as
332
+ { tag_name: string; note_id: string }[];
333
+
334
+ const expandedNotes = new Map<string, Set<string>>();
335
+ const ancestorsCache = new Map<string, string[]>();
336
+
337
+ function ancestorsOrSelf(x: string): string[] {
338
+ const cached = ancestorsCache.get(x);
339
+ if (cached) return cached;
340
+ const result: string[] = [];
341
+ for (const t of h.allTags) {
342
+ if (getTagDescendants(h, t).has(x)) result.push(t);
343
+ }
344
+ if (!result.includes(x)) result.push(x);
345
+ ancestorsCache.set(x, result);
346
+ return result;
347
+ }
348
+
349
+ for (const row of rows) {
350
+ for (const ancestor of ancestorsOrSelf(row.tag_name)) {
351
+ let set = expandedNotes.get(ancestor);
352
+ if (!set) {
353
+ set = new Set();
354
+ expandedNotes.set(ancestor, set);
355
+ }
356
+ set.add(row.note_id);
357
+ }
358
+ }
359
+
360
+ const counts = new Map<string, number>();
361
+ for (const [tag, set] of expandedNotes) counts.set(tag, set.size);
362
+ return counts;
363
+ }
364
+
252
365
  /**
253
366
  * Detect cycles in the declared hierarchy. Returns the list of tags
254
367
  * reachable from themselves via parent declarations. Used by
package/core/src/types.ts CHANGED
@@ -1,16 +1,18 @@
1
1
  import type { Database } from "bun:sqlite";
2
2
  import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
3
3
  import type { PrunedField } from "./indexed-fields.js";
4
- import type { TagExpandMode } from "./tag-hierarchy.js";
4
+ import type { TagExpandMode, TagHierarchy } from "./tag-hierarchy.js";
5
5
  import type { ValidationStatus } from "./schema-defaults.js";
6
6
  import type { ConformanceReport } from "./conformance.js";
7
+ import type { FindPathResult } from "./links.js";
7
8
 
8
9
  // ---- Re-exports ----
9
10
 
10
11
  export type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
11
12
  export type { PrunedField } from "./indexed-fields.js";
12
- export type { TagExpandMode } from "./tag-hierarchy.js";
13
+ export type { TagExpandMode, TagHierarchy } from "./tag-hierarchy.js";
13
14
  export type { ConformanceReport } from "./conformance.js";
15
+ export type { FindPathResult } from "./links.js";
14
16
 
15
17
  // ---- Note ----
16
18
 
@@ -336,7 +338,20 @@ export interface Store {
336
338
  * IDENTICAL expansion the snapshot query engine uses for the same `expand`.
337
339
  */
338
340
  expandTags(tags: string[], mode?: TagExpandMode): Promise<Set<string>>;
339
- listTags(): Promise<{ name: string; count: number }[]>;
341
+ /**
342
+ * The store's cached tag hierarchy (invalidated on tag/parent_names
343
+ * writes). Sync, like `db` and `transaction`. Exposed (vault#550 fold)
344
+ * so per-query consumers — the `unknown_tag` warning collector — reuse
345
+ * the cache instead of re-scanning the `tags` table per request. Treat
346
+ * the returned object as READ-ONLY shared state.
347
+ */
348
+ getTagHierarchy(): TagHierarchy;
349
+ /**
350
+ * `expanded_count` (vault#550): distinct notes matching the tag OR any
351
+ * transitive descendant under the DEFAULT (subtypes) expansion axis,
352
+ * alongside the literal `count`. See `core/src/tag-hierarchy.ts:computeExpandedTagCounts`.
353
+ */
354
+ listTags(): Promise<{ name: string; count: number; expanded_count: number }[]>;
340
355
  deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }>;
341
356
  renameTag(
342
357
  oldName: string,
@@ -375,7 +390,7 @@ export interface Store {
375
390
 
376
391
  // Deeper link queries
377
392
  traverseLinks(noteId: string, opts?: { max_depth?: number; relationship?: string }): Promise<{ noteId: string; depth: number; relationship: string; direction: "outbound" | "inbound" }[]>;
378
- findPath(sourceId: string, targetId: string, opts?: { max_depth?: number }): Promise<{ path: string[]; relationships: string[] } | null>;
393
+ findPath(sourceId: string, targetId: string, opts?: { max_depth?: number }): Promise<FindPathResult | null>;
379
394
 
380
395
  // Tag schemas — schema-only facade (description + fields). Back-compat
381
396
  // surface for v13-and-earlier callers; reads/writes route through the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.6.5",
3
+ "version": "0.7.0-rc.2",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",