@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.
package/src/mcp-tools.ts CHANGED
@@ -308,9 +308,11 @@ function applyTagScopeWrappers(
308
308
  const allowed = await getAllowed();
309
309
  const result = await orig(params);
310
310
  if (!allowed) return result;
311
- // Three possible response shapes:
312
- // - Array (legacy list, no cursor)
311
+ // Possible response shapes (vault#550 added the `warnings` variants):
312
+ // - Array (legacy list, no cursor, no warnings)
313
313
  // - `{notes, next_cursor}` (cursor mode, vault#313)
314
+ // - `{notes, warnings}` (warnings present, not in cursor mode)
315
+ // - `{notes, next_cursor, warnings}` (both)
314
316
  // - `{...note}` with `id`+`tags` (single-note by id)
315
317
  if (Array.isArray(result)) {
316
318
  return result
@@ -321,15 +323,22 @@ function applyTagScopeWrappers(
321
323
  result &&
322
324
  typeof result === "object" &&
323
325
  "notes" in result &&
324
- Array.isArray((result as any).notes) &&
325
- "next_cursor" in result
326
+ Array.isArray((result as any).notes)
326
327
  ) {
327
- const r = result as { notes: any[]; next_cursor: string | null };
328
+ const r = result as { notes: any[]; next_cursor?: string | null; warnings?: unknown };
328
329
  return {
329
330
  notes: r.notes
330
331
  .filter((n: any) => noteWithinTagScope(n, allowed, rawTags))
331
332
  .map(scrubNoteLinks),
332
- next_cursor: r.next_cursor,
333
+ ...("next_cursor" in r ? { next_cursor: r.next_cursor } : {}),
334
+ // `warnings` intentionally DROPPED for a tag-scoped session: core's
335
+ // `collectUnknownTagWarnings` (core/src/query-warnings.ts) resolves
336
+ // `did_you_mean` against the FULL vault-wide tag catalog, which
337
+ // would leak an out-of-scope tag's existence/name to a token that
338
+ // can't otherwise see it — the same "no leak across the scope
339
+ // boundary" stance this file takes everywhere else (see
340
+ // docs/contracts/tag-scoped-tokens.md). Unscoped sessions keep
341
+ // `warnings` untouched via the `!allowed` early return above.
333
342
  };
334
343
  }
335
344
  if (result && typeof result === "object" && "id" in result && "tags" in result) {
@@ -342,9 +351,41 @@ function applyTagScopeWrappers(
342
351
 
343
352
  wrapReadTool(tools, "list-tags", async (orig, params) => {
344
353
  const allowed = await getAllowed();
354
+ if (!allowed) return await orig(params);
355
+ // Single-tag detail (`{tag}` param) — the non-array shape the old
356
+ // wrapper silently passed through, which leaked BOTH the full record of
357
+ // an existing out-of-scope tag (vault#560) AND, post-#550, a vault-wide
358
+ // `did_you_mean` suggestion for a nonexistent one. This wrapper is the
359
+ // enforcement layer for tag scope on this tool; core's list-tags stays
360
+ // scope-unaware by architecture (same division as every other wrapper
361
+ // in this file).
362
+ const singleTag = typeof (params as any).tag === "string" ? ((params as any).tag as string) : null;
363
+ if (singleTag !== null && !allowed.has(singleTag)) {
364
+ // Out-of-scope name — whether the tag exists or not. Same
365
+ // "tag_not_found, no leak" shape as the REST handlers' early-return,
366
+ // and deliberately NO did_you_mean (any suggestion would be computed
367
+ // vault-wide). Short-circuits BEFORE core runs, so the full record /
368
+ // suggestion is never even computed.
369
+ return { error: "Tag not found", error_type: "tag_not_found", tag: singleTag };
370
+ }
345
371
  const result = await orig(params);
346
- if (!allowed || !Array.isArray(result)) return result;
347
- return result.filter((t: any) => allowed.has(t.name));
372
+ if (Array.isArray(result)) {
373
+ return result.filter((t: any) => allowed.has(t.name));
374
+ }
375
+ // In-scope single-tag miss (nonexistent but allowlisted name): core's
376
+ // tag_not_found may carry a vault-wide `did_you_mean` — keep it only
377
+ // when the suggestion itself is inside the allowlist.
378
+ if (
379
+ result &&
380
+ typeof result === "object" &&
381
+ (result as any).error_type === "tag_not_found" &&
382
+ typeof (result as any).did_you_mean === "string" &&
383
+ !allowed.has((result as any).did_you_mean)
384
+ ) {
385
+ const { did_you_mean: _dropped, ...scrubbed } = result as Record<string, unknown>;
386
+ return scrubbed;
387
+ }
388
+ return result;
348
389
  });
349
390
 
350
391
  wrapReadTool(tools, "find-path", async (orig, params) => {
package/src/routes.ts CHANGED
@@ -12,7 +12,8 @@
12
12
  */
13
13
 
14
14
  import type { Store, Note, QueryOpts } from "../core/src/types.ts";
15
- import { TAG_EXPAND_MODES, stripTagHash, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
15
+ import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
16
+ import { collectUnknownTagWarnings, type QueryWarning } from "../core/src/query-warnings.ts";
16
17
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
17
18
  import { transactionAsync } from "../core/src/txn.ts";
18
19
  import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
@@ -128,6 +129,56 @@ function json(data: unknown, status = 200): Response {
128
129
  return Response.json(data, { status });
129
130
  }
130
131
 
132
+ /**
133
+ * `json(...)` plus the `X-Parachute-Warnings` response header (vault#550) —
134
+ * `encodeURIComponent(JSON.stringify(warnings))`, set only when `warnings`
135
+ * is non-empty. Percent-encoded because HTTP header VALUES are
136
+ * Latin1/ASCII-only (the Fetch spec's ByteString requirement) while warning
137
+ * `message` text uses this codebase's normal house style (em dashes,
138
+ * curly-free but occasionally non-ASCII punctuation) — a raw
139
+ * `JSON.stringify` throws `TypeError: invalid header value` the first time
140
+ * a message isn't 7-bit-clean. Decode with `decodeURIComponent` then
141
+ * `JSON.parse`. Exists so bare-array responses (`GET /notes` without a
142
+ * cursor) can carry warnings WITHOUT changing their wire shape — a code
143
+ * consumer doing `for (const note of await res.json())` keeps working
144
+ * unmodified, while a consumer that cares can read the header. Envelope
145
+ * shapes (`{notes, next_cursor}`, `{nodes, edges}`) get the header too for
146
+ * uniform discoverability; callers that want warnings INLINE in an
147
+ * envelope should also spread them into the response body before calling
148
+ * this (see the cursor-envelope call sites in `handleNotesInner`).
149
+ */
150
+ function jsonWithWarnings(data: unknown, warnings: QueryWarning[], status = 200): Response {
151
+ const res = json(data, status);
152
+ if (warnings.length > 0) {
153
+ res.headers.set("X-Parachute-Warnings", encodeURIComponent(JSON.stringify(warnings)));
154
+ }
155
+ return res;
156
+ }
157
+
158
+ /**
159
+ * REST-only `removed_param` warning (vault#550) — the flat `date_field` /
160
+ * `date_from` / `date_to` query-string params were removed at 0.6.4
161
+ * (vault#288) and have been silently ignored ever since (routes.ts:633-ish
162
+ * comment). A request that passes any of them now gets a warning naming
163
+ * the ignored param and the bracket-style replacement, instead of quietly
164
+ * coming back unfiltered. One warning per param present (a caller passing
165
+ * all three gets three entries — precise, not lossy-summarized).
166
+ */
167
+ function collectRemovedParamWarnings(url: URL): QueryWarning[] {
168
+ const REMOVED_DATE_PARAMS = ["date_field", "date_from", "date_to"] as const;
169
+ const warnings: QueryWarning[] = [];
170
+ for (const param of REMOVED_DATE_PARAMS) {
171
+ if (url.searchParams.has(param)) {
172
+ warnings.push({
173
+ code: "removed_param",
174
+ message: `\`${param}\` was removed in 0.6.4 (vault#288) and is silently ignored — use bracket-style date filters instead: \`meta[created_at][gte]=…\` / \`meta[created_at][lt]=…\` (or \`meta[updated_at][...]\`).`,
175
+ param,
176
+ });
177
+ }
178
+ }
179
+ return warnings;
180
+ }
181
+
131
182
  function parseBool(val: string | null, defaultVal: boolean): boolean {
132
183
  if (val === null) return defaultVal;
133
184
  return val === "true" || val === "1";
@@ -221,6 +272,59 @@ function parseInt10(val: string | null): number | undefined {
221
272
  return isNaN(n) ? undefined : n;
222
273
  }
223
274
 
275
+ /**
276
+ * Parse + validate `?limit=` / `?offset=` for the structured-query path
277
+ * (vault#550). Before this, `parseInt10(...) ?? 50` silently swallowed a
278
+ * non-numeric value into the default (a caller's typo `?limit=fifty` just
279
+ * became `limit=50` with no signal), and any negative value that DID parse
280
+ * flowed straight through to SQLite, whose `LIMIT -1` means "no limit" —
281
+ * silently returning EVERYTHING instead of erroring. Returns `{value}`
282
+ * (undefined when the param is absent — the caller applies its own
283
+ * default) or `{error}` (400 `invalid_query`) when present-but-malformed.
284
+ * `got` echoes the ORIGINAL raw string so the error is legible even though
285
+ * the coerced value (e.g. `NaN`) wouldn't be.
286
+ */
287
+ function parseNonNegativeIntParam(
288
+ url: URL,
289
+ key: "limit" | "offset",
290
+ ): { value?: number; error?: Response } {
291
+ const raw = parseQuery(url, key);
292
+ if (raw === null || raw === "") return {};
293
+ const trimmed = raw.trim();
294
+ if (!/^-?\d+$/.test(trimmed)) {
295
+ return {
296
+ error: json(
297
+ {
298
+ error: `\`${key}\` must be an integer, got ${JSON.stringify(raw)}`,
299
+ error_type: "invalid_query",
300
+ code: "INVALID_QUERY",
301
+ field: key,
302
+ got: raw,
303
+ hint: `pass a non-negative integer (e.g. ?${key}=${key === "limit" ? "50" : "0"}), or omit for the default`,
304
+ },
305
+ 400,
306
+ ),
307
+ };
308
+ }
309
+ const n = parseInt(trimmed, 10);
310
+ if (n < 0) {
311
+ return {
312
+ error: json(
313
+ {
314
+ error: `\`${key}\` must be zero or greater, got ${n}${key === "limit" ? " — a negative limit used to silently mean \"unlimited\" (SQLite semantics leaking through)" : ""}`,
315
+ error_type: "invalid_query",
316
+ code: "INVALID_QUERY",
317
+ field: key,
318
+ got: raw,
319
+ hint: "pass a non-negative integer, or omit for the default",
320
+ },
321
+ 400,
322
+ ),
323
+ };
324
+ }
325
+ return { value: n };
326
+ }
327
+
224
328
  /**
225
329
  * Parse bracket-style metadata filters (vault#285 friction point 1.3).
226
330
  *
@@ -612,6 +716,12 @@ export function parseNotesQueryOpts(url: URL): {
612
716
  if (expandParsed.error) return { hasSearch, hasNear, hasCursor, error: expandParsed.error };
613
717
  const expand = expandParsed.expand;
614
718
 
719
+ // limit/offset validation (vault#550) — see parseNonNegativeIntParam.
720
+ const limitParsed = parseNonNegativeIntParam(url, "limit");
721
+ if (limitParsed.error) return { hasSearch, hasNear, hasCursor, error: limitParsed.error };
722
+ const offsetParsed = parseNonNegativeIntParam(url, "offset");
723
+ if (offsetParsed.error) return { hasSearch, hasNear, hasCursor, error: offsetParsed.error };
724
+
615
725
  const queryOpts: QueryOpts = {
616
726
  tags,
617
727
  tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
@@ -639,8 +749,8 @@ export function parseNotesQueryOpts(url: URL): {
639
749
  ...(bracket.dateFilter ? { dateFilter: bracket.dateFilter } : {}),
640
750
  sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
641
751
  orderBy: parseQuery(url, "order_by") ?? undefined,
642
- limit: parseInt10(parseQuery(url, "limit")) ?? 50,
643
- offset: parseInt10(parseQuery(url, "offset")),
752
+ limit: limitParsed.value ?? 50,
753
+ offset: offsetParsed.value,
644
754
  cursor: cursorParam ?? undefined,
645
755
  };
646
756
 
@@ -839,8 +949,11 @@ async function handleNotesInner(
839
949
  // FTS owns its own ordering (relevance, not updated_at), so a cursor
840
950
  // would skip rows. MCP rejects this combo at `core/src/mcp.ts`; REST
841
951
  // would otherwise route into the `if (search)` branch below and
842
- // silently drop the cursor. Reject here for surface parity.
843
- if (search && parseQuery(url, "cursor")) {
952
+ // silently drop the cursor. Reject here for surface parity. Presence
953
+ // check (`!== null`), not truthiness (vault#550) — a bootstrap
954
+ // `?cursor=` attempt against a search query is still cursor intent
955
+ // and must still be rejected, not silently treated as "no cursor."
956
+ if (search && parseQuery(url, "cursor") !== null) {
844
957
  return json(
845
958
  {
846
959
  error: "cursor is incompatible with full-text search — FTS has its own ordering. Use date_filter on updated_at for since-last-checked search.",
@@ -934,11 +1047,22 @@ async function handleNotesInner(
934
1047
  if (parsed.error) return parsed.error;
935
1048
  const queryOpts = parsed.queryOpts!;
936
1049
  const cursorParam = parseQuery(url, "cursor");
1050
+ // Cursor mode is keyed on PRESENCE (`!== null`), not truthiness
1051
+ // (vault#550 bootstrap fix). `?cursor=` (present, empty) is the
1052
+ // bootstrap call — "I want to paginate, no watermark yet" — and must
1053
+ // still engage the `{notes, next_cursor}` envelope. Before this fix
1054
+ // `if (cursorParam)` treated an empty string exactly like an absent
1055
+ // param, so the FIRST call could never obtain a cursor even though
1056
+ // `core/src/mcp.ts` documented that flow — the cursor bootstrap gap
1057
+ // this wave closes. Omitting `cursor` entirely (`cursorParam ===
1058
+ // null`) is the only way to opt OUT — that keeps today's bare-array
1059
+ // compat shape.
1060
+ const cursorMode = cursorParam !== null;
937
1061
  // Opaque cursor for "since last checked" agent loops (vault#313) is
938
1062
  // mutually exclusive with the `near` graph-neighborhood scope (rebuilding
939
1063
  // the neighborhood per page isn't stable).
940
1064
  const nearNoteIdEarly = parseQuery(url, "near[note_id]");
941
- if (cursorParam && nearNoteIdEarly) {
1065
+ if (cursorMode && nearNoteIdEarly) {
942
1066
  return json(
943
1067
  {
944
1068
  error: "cursor is incompatible with near (graph neighborhood). Resolve the neighborhood first, then iterate with cursor over the resulting note set.",
@@ -947,10 +1071,25 @@ async function handleNotesInner(
947
1071
  400,
948
1072
  );
949
1073
  }
1074
+ // Warnings channel (vault#550) — structured-query only for this wave
1075
+ // (search= is out of scope, see #551). Skipped entirely for a
1076
+ // tag-scoped session: `collectUnknownTagWarnings` resolves
1077
+ // `did_you_mean` against the FULL vault-wide tag catalog, which would
1078
+ // leak an out-of-scope tag's existence/name across the scope boundary
1079
+ // — the same "no leak" stance this codebase takes everywhere else
1080
+ // (docs/contracts/tag-scoped-tokens.md). `removed_param` warnings
1081
+ // (below, at the response-shaping stage) carry no tag information and
1082
+ // fire regardless of scope.
1083
+ const queryWarnings: QueryWarning[] = [
1084
+ ...collectRemovedParamWarnings(url),
1085
+ ...(tagScope.allowed === null
1086
+ ? collectUnknownTagWarnings(db, queryOpts.tags, queryOpts.expand, store.getTagHierarchy())
1087
+ : []),
1088
+ ];
950
1089
  let results: Note[];
951
1090
  let nextCursor: string | null = null;
952
1091
  try {
953
- if (cursorParam) {
1092
+ if (cursorMode) {
954
1093
  const page = await store.queryNotesPaged(queryOpts);
955
1094
  results = page.notes;
956
1095
  nextCursor = page.next_cursor;
@@ -960,9 +1099,23 @@ async function handleNotesInner(
960
1099
  } catch (e: any) {
961
1100
  // QueryError (non-indexed order_by, unknown operator, ...) surfaces
962
1101
  // here. Duck-type on `name` + `code` — core is a separate module, so
963
- // `instanceof` is fragile across bundling boundaries.
1102
+ // `instanceof` is fragile across bundling boundaries. The newer
1103
+ // vault#550 call sites (limit/offset/date validation in
1104
+ // core/src/notes.ts) additionally set `error_type`/`field`/`got`/
1105
+ // `hint` — merged in when present; long-standing QueryError throws
1106
+ // that don't set them keep their existing `{error, code}` shape.
964
1107
  if (e && e.name === "QueryError") {
965
- return json({ error: e.message, code: e.code ?? "INVALID_QUERY" }, 400);
1108
+ return json(
1109
+ {
1110
+ error: e.message,
1111
+ code: e.code ?? "INVALID_QUERY",
1112
+ ...(e.error_type !== undefined ? { error_type: e.error_type } : {}),
1113
+ ...(e.field !== undefined ? { field: e.field } : {}),
1114
+ ...(e.got !== undefined ? { got: e.got } : {}),
1115
+ ...(e.hint !== undefined ? { hint: e.hint } : {}),
1116
+ },
1117
+ 400,
1118
+ );
966
1119
  }
967
1120
  // CursorError carries a structured code (cursor_invalid /
968
1121
  // cursor_query_mismatch) so the agent loop can distinguish a
@@ -1072,7 +1225,12 @@ async function handleNotesInner(
1072
1225
  }
1073
1226
  }
1074
1227
  }
1075
- return json({ nodes, edges });
1228
+ // Envelope shape → warnings inline (same policy as the cursor
1229
+ // envelope below), plus the header via jsonWithWarnings.
1230
+ return jsonWithWarnings(
1231
+ { nodes, edges, ...(queryWarnings.length > 0 ? { warnings: queryWarnings } : {}) },
1232
+ queryWarnings,
1233
+ );
1076
1234
  }
1077
1235
 
1078
1236
  if (includeLinks || includeAttachments) {
@@ -1099,12 +1257,24 @@ async function handleNotesInner(
1099
1257
  // Cursor mode wraps the list in {notes, next_cursor} so an agent
1100
1258
  // loop can chain calls without tracking a watermark client-side.
1101
1259
  // Legacy callers (no `cursor` param) still get the flat array.
1102
- if (cursorParam) return json({ notes: enrichedOut, next_cursor: nextCursor });
1103
- return json(enrichedOut);
1260
+ // Warnings (vault#550): inline on the envelope, header either way
1261
+ // (see jsonWithWarnings).
1262
+ if (cursorMode) {
1263
+ return jsonWithWarnings(
1264
+ { notes: enrichedOut, next_cursor: nextCursor, ...(queryWarnings.length > 0 ? { warnings: queryWarnings } : {}) },
1265
+ queryWarnings,
1266
+ );
1267
+ }
1268
+ return jsonWithWarnings(enrichedOut, queryWarnings);
1104
1269
  }
1105
1270
 
1106
- if (cursorParam) return json({ notes: output, next_cursor: nextCursor });
1107
- return json(output);
1271
+ if (cursorMode) {
1272
+ return jsonWithWarnings(
1273
+ { notes: output, next_cursor: nextCursor, ...(queryWarnings.length > 0 ? { warnings: queryWarnings } : {}) },
1274
+ queryWarnings,
1275
+ );
1276
+ }
1277
+ return jsonWithWarnings(output, queryWarnings);
1108
1278
  }
1109
1279
 
1110
1280
  // POST /notes — create (single or batch)
@@ -1874,16 +2044,42 @@ export async function handleTags(
1874
2044
  if (singleTag) {
1875
2045
  // Tag-scope: a tag-scoped token can only see tags reachable from its
1876
2046
  // allowlist (root + descendants per the parent_names hierarchy).
1877
- // Anything else 404s — same "no leak" stance as note reads.
2047
+ // Anything else 404s — same "no leak" stance as note reads. This
2048
+ // early-return means the vault#550 unknown-tag 404 below never even
2049
+ // runs for a scope-excluded name — `did_you_mean` (which searches
2050
+ // vault-wide) can't fire and leak an out-of-scope tag's existence.
1878
2051
  if (tagScope.allowed && !tagScope.allowed.has(singleTag)) {
1879
- return json({ error: "Tag not found", tag: singleTag }, 404);
2052
+ return json({ error: "Tag not found", error_type: "tag_not_found", tag: singleTag }, 404);
1880
2053
  }
1881
2054
  const allTags = await store.listTags();
1882
2055
  const found = allTags.find((t) => t.name === singleTag);
1883
2056
  const record = await store.getTagRecord(singleTag);
2057
+ // vault#550 — a tag with no identity row AND no notes carrying it
2058
+ // isn't a legitimate (if empty) tag; it's a typo or a tag from a
2059
+ // different vault. Return a structured 404 instead of synthesizing
2060
+ // an all-null 200. `did_you_mean` candidates are restricted to the
2061
+ // caller's allowlist when scoped (defense in depth — this branch is
2062
+ // normally unreachable for a scoped session per the early-return
2063
+ // above, but stays scope-safe if that ever changes).
2064
+ if (!found && !record) {
2065
+ const candidates = tagScope.allowed
2066
+ ? allTags.filter((t) => tagScope.allowed!.has(t.name)).map((t) => t.name)
2067
+ : allTags.map((t) => t.name);
2068
+ const suggestion = suggestSimilarTag(candidates, singleTag);
2069
+ return json(
2070
+ {
2071
+ error: "Tag not found",
2072
+ error_type: "tag_not_found",
2073
+ tag: singleTag,
2074
+ ...(suggestion ? { did_you_mean: suggestion } : {}),
2075
+ },
2076
+ 404,
2077
+ );
2078
+ }
1884
2079
  return json({
1885
2080
  name: singleTag,
1886
2081
  count: found?.count ?? 0,
2082
+ expanded_count: found?.expanded_count ?? 0,
1887
2083
  description: record?.description ?? null,
1888
2084
  fields: record?.fields ?? null,
1889
2085
  relationships: record?.relationships ?? null,
@@ -2069,15 +2265,36 @@ export async function handleTags(
2069
2265
 
2070
2266
  // GET /tags/:name — single tag detail (full record)
2071
2267
  if (req.method === "GET") {
2268
+ // Same "no leak" early-return as the `?tag=` form above — a
2269
+ // scope-excluded name 404s before `did_you_mean` (vault-wide) can run.
2072
2270
  if (tagScope.allowed && !tagScope.allowed.has(tagName)) {
2073
- return json({ error: "Tag not found", tag: tagName }, 404);
2271
+ return json({ error: "Tag not found", error_type: "tag_not_found", tag: tagName }, 404);
2074
2272
  }
2075
2273
  const allTags = await store.listTags();
2076
2274
  const found = allTags.find((t) => t.name === tagName);
2077
2275
  const record = await store.getTagRecord(tagName);
2276
+ // vault#550 — see the `?tag=` form above for the rationale (no
2277
+ // identity row + no memberships = not a real tag, not a legitimate
2278
+ // empty one).
2279
+ if (!found && !record) {
2280
+ const candidates = tagScope.allowed
2281
+ ? allTags.filter((t) => tagScope.allowed!.has(t.name)).map((t) => t.name)
2282
+ : allTags.map((t) => t.name);
2283
+ const suggestion = suggestSimilarTag(candidates, tagName);
2284
+ return json(
2285
+ {
2286
+ error: "Tag not found",
2287
+ error_type: "tag_not_found",
2288
+ tag: tagName,
2289
+ ...(suggestion ? { did_you_mean: suggestion } : {}),
2290
+ },
2291
+ 404,
2292
+ );
2293
+ }
2078
2294
  return json({
2079
2295
  name: tagName,
2080
2296
  count: found?.count ?? 0,
2297
+ expanded_count: found?.expanded_count ?? 0,
2081
2298
  description: record?.description ?? null,
2082
2299
  fields: record?.fields ?? null,
2083
2300
  relationships: record?.relationships ?? null,