@frockbot/plugin-search 0.0.0 → 0.1.0

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.
@@ -0,0 +1,420 @@
1
+ // The transcript index: one deep module over one narrow SQL seam.
2
+ //
3
+ // WHERE IT LIVES. One index per User, in the User Durable Object —
4
+ // `AGENTS.md` § Authorities, "The User's Durable Object is the authority for
5
+ // everything User-scoped". GrokBot keeps one `search-index.db` holding the
6
+ // transcripts of all fourteen agents (`docs/research/grokbot-computer.md:78`),
7
+ // and this is the same shape: one table, one query, no fan-out at read time.
8
+ //
9
+ // WHAT IT IS NOT. It is not authority. Every row is a projection of a settled
10
+ // `StoredRunV1` that the owning Bot Durable Object holds, and `rebuild()`
11
+ // reconstructs the whole table from those runs. A lost, corrupt, or evicted
12
+ // index costs a rebuild and nothing else, which is the only reason it is
13
+ // allowed to exist outside the Durable Object that owns the conversation.
14
+ //
15
+ // FTS5. `CREATE VIRTUAL TABLE … USING fts5` is accepted by the Durable Object
16
+ // SQL authorizer — proven by `apps/cloudflare/test/search.workerd.ts`, which
17
+ // creates the real table on `ctx.storage.sql` before anything else runs. The
18
+ // metadata columns are `UNINDEXED` so they cost no tokens, and a companion
19
+ // ordinary table carries the `(bot_id, run_id, seq)` primary key FTS5 cannot:
20
+ // it is what makes a re-projected turn idempotent rather than duplicated.
21
+ import {
22
+ boundSearchBodyV1,
23
+ SEARCH_DEFAULT_ROW_KINDS_V1,
24
+ SEARCH_MAX_CURSOR_LENGTH_V1,
25
+ SEARCH_MAX_QUERY_LENGTH_V1,
26
+ SEARCH_MAX_RESULTS_V1,
27
+ SEARCH_MAX_ROWS_V1,
28
+ SEARCH_MAX_SNIPPET_LENGTH_V1,
29
+ SEARCH_ROW_KINDS_V1,
30
+ type SearchIndexResultsV1,
31
+ type SearchIndexStateV1,
32
+ type SearchQueryV1,
33
+ type SearchRowKindV1,
34
+ type SearchRowV1,
35
+ } from "./shared.js";
36
+
37
+ // The SQL surface this module consumes: exactly what `ctx.storage.sql` offers
38
+ // and nothing more, so the module's tests can supply a fake cursor and the
39
+ // Durable Object type never reaches this Package.
40
+
41
+ /** Exactly the column types SQLite storage returns. */
42
+ export type SearchSqlValueV1 = ArrayBuffer | string | number | null;
43
+
44
+ export interface SearchSqlCursorV1<
45
+ Row extends Record<string, SearchSqlValueV1>,
46
+ > {
47
+ toArray(): Row[];
48
+ }
49
+
50
+ export interface SearchSqlV1 {
51
+ exec<Row extends Record<string, SearchSqlValueV1>>(
52
+ query: string,
53
+ // eslint-disable-next-line -- `SqlStorage.exec` declares `any[]`; a
54
+ // narrower parameter type here would stop `ctx.storage.sql` satisfying
55
+ // this interface at all.
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
+ ...bindings: any[]
58
+ ): SearchSqlCursorV1<Row>;
59
+ }
60
+
61
+ const ROWS_TABLE = "search_rows";
62
+ const KEYS_TABLE = "search_keys";
63
+ const META_TABLE = "search_meta";
64
+
65
+ const TRUNCATED_KEY = "index-truncated";
66
+ const REBUILDING_KEY = "index-rebuilding";
67
+
68
+ /** The page size a rebuild pulls from one Bot at a time. */
69
+ export const SEARCH_REBUILD_PAGE_V1 = 32;
70
+
71
+ export interface SearchIndexOptionsV1 {
72
+ sql: SearchSqlV1;
73
+ /** Overridable so a test can drive eviction without two million rows. */
74
+ maxRows?: number;
75
+ }
76
+
77
+ /** One Bot's rows, as the index pulls them during a rebuild. */
78
+ export interface SearchRowSourceV1 {
79
+ botId: string;
80
+ /** Returns one page and the cursor for the next, or `undefined` when done. */
81
+ page(cursor?: string): Promise<{ rows: SearchRowV1[]; nextCursor?: string }>;
82
+ }
83
+
84
+ export interface SearchRebuildOutcomeV1 {
85
+ indexedRows: number;
86
+ bots: number;
87
+ indexState: SearchIndexStateV1;
88
+ }
89
+
90
+ const CURSOR_PATTERN = /^p([0-9]{1,9})$/;
91
+
92
+ function decodeOffset(cursor: string | undefined): number {
93
+ if (cursor === undefined) return 0;
94
+ if (cursor.length > SEARCH_MAX_CURSOR_LENGTH_V1) {
95
+ throw new Error("search cursor is invalid");
96
+ }
97
+ const match = CURSOR_PATTERN.exec(cursor);
98
+ if (!match) throw new Error("search cursor is invalid");
99
+ const offset = Number(match[1]);
100
+ if (!Number.isSafeInteger(offset) || offset > 100_000) {
101
+ throw new Error("search cursor is invalid");
102
+ }
103
+ return offset;
104
+ }
105
+
106
+ /**
107
+ * Turns a person's words into an FTS5 MATCH expression.
108
+ *
109
+ * Every token is quoted, which is what makes this safe: FTS5's own operators
110
+ * (`NEAR`, `OR`, `-`, `^`, `*`, column filters) are syntax outside quotes and
111
+ * literal text inside them, so a query containing them searches for them
112
+ * rather than executing them. The final token also matches by prefix, because
113
+ * a search box is read while it is still being typed.
114
+ */
115
+ export function searchMatchExpressionV1(query: string): string | undefined {
116
+ const tokens = query
117
+ .slice(0, SEARCH_MAX_QUERY_LENGTH_V1)
118
+ .split(/[^\p{L}\p{N}_]+/u)
119
+ .filter((token) => token.length > 0)
120
+ .slice(0, 16);
121
+ if (tokens.length === 0) return undefined;
122
+ return tokens
123
+ .map((token, index) => {
124
+ const quoted = `"${token.replaceAll('"', '""')}"`;
125
+ return index === tokens.length - 1 ? `${quoted}*` : quoted;
126
+ })
127
+ .join(" ");
128
+ }
129
+
130
+ export class SearchIndexV1 {
131
+ private readonly sql: SearchSqlV1;
132
+ private readonly maxRows: number;
133
+ private opened = false;
134
+
135
+ constructor(options: SearchIndexOptionsV1) {
136
+ this.sql = options.sql;
137
+ this.maxRows = options.maxRows ?? SEARCH_MAX_ROWS_V1;
138
+ }
139
+
140
+ /** Creates the tables if they are absent. Safe to call on every request. */
141
+ open(): void {
142
+ if (this.opened) return;
143
+ this.sql.exec(
144
+ `CREATE VIRTUAL TABLE IF NOT EXISTS ${ROWS_TABLE} USING fts5(` +
145
+ "body, bot_id UNINDEXED, run_id UNINDEXED, seq UNINDEXED, " +
146
+ "kind UNINDEXED, at UNINDEXED, tokenize='unicode61')",
147
+ );
148
+ this.sql.exec(
149
+ `CREATE TABLE IF NOT EXISTS ${KEYS_TABLE} (` +
150
+ "bot_id TEXT NOT NULL, run_id TEXT NOT NULL, seq INTEGER NOT NULL, " +
151
+ "at TEXT NOT NULL, PRIMARY KEY (bot_id, run_id, seq))",
152
+ );
153
+ this.sql.exec(
154
+ `CREATE INDEX IF NOT EXISTS ${KEYS_TABLE}_at ON ${KEYS_TABLE} (at)`,
155
+ );
156
+ this.sql.exec(
157
+ `CREATE TABLE IF NOT EXISTS ${META_TABLE} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
158
+ );
159
+ this.opened = true;
160
+ }
161
+
162
+ private meta(key: string): string | undefined {
163
+ const rows = this.sql
164
+ .exec<{ value: string }>(
165
+ `SELECT value FROM ${META_TABLE} WHERE key = ?`,
166
+ key,
167
+ )
168
+ .toArray();
169
+ return rows[0]?.value;
170
+ }
171
+
172
+ private setMeta(key: string, value: string | undefined): void {
173
+ if (value === undefined) {
174
+ this.sql.exec(`DELETE FROM ${META_TABLE} WHERE key = ?`, key);
175
+ return;
176
+ }
177
+ this.sql.exec(
178
+ `INSERT INTO ${META_TABLE} (key, value) VALUES (?, ?) ` +
179
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
180
+ key,
181
+ value,
182
+ );
183
+ }
184
+
185
+ /**
186
+ * `ready`, or the durable marker that says otherwise.
187
+ *
188
+ * Truncation is durable and visible: a User whose oldest turns were evicted
189
+ * to stay inside the row quota sees so in the UI rather than silently
190
+ * getting fewer answers, which is the quota rule's "records a visible
191
+ * failure" for a bound that is enforced by discarding rather than refusing.
192
+ */
193
+ state(): SearchIndexStateV1 {
194
+ this.open();
195
+ if (this.meta(REBUILDING_KEY)) return "rebuilding";
196
+ return this.meta(TRUNCATED_KEY) ? "truncated" : "ready";
197
+ }
198
+
199
+ count(): number {
200
+ this.open();
201
+ const rows = this.sql
202
+ .exec<{ n: number }>(`SELECT count(*) AS n FROM ${KEYS_TABLE}`)
203
+ .toArray();
204
+ return Number(rows[0]?.n ?? 0);
205
+ }
206
+
207
+ /**
208
+ * Projects rows into the index, idempotently on `(botId, runId, seq)`.
209
+ *
210
+ * Returns how many rows were new. A Turn that settles twice — a retried RPC,
211
+ * a rebuild over a live index — inserts nothing the second time, which is
212
+ * what lets the write path be a fire-and-forget projection rather than a
213
+ * transaction the Turn has to wait on.
214
+ */
215
+ insert(rows: readonly SearchRowV1[]): number {
216
+ this.open();
217
+ let inserted = 0;
218
+ for (const row of rows) {
219
+ const existing = this.sql
220
+ .exec<{ n: number }>(
221
+ `SELECT count(*) AS n FROM ${KEYS_TABLE} WHERE bot_id = ? AND run_id = ? AND seq = ?`,
222
+ row.botId,
223
+ row.runId,
224
+ row.seq,
225
+ )
226
+ .toArray();
227
+ if (Number(existing[0]?.n ?? 0) > 0) continue;
228
+ const body = boundSearchBodyV1(row.body);
229
+ this.sql.exec(
230
+ `INSERT INTO ${ROWS_TABLE} (body, bot_id, run_id, seq, kind, at) VALUES (?, ?, ?, ?, ?, ?)`,
231
+ body,
232
+ row.botId,
233
+ row.runId,
234
+ row.seq,
235
+ row.kind,
236
+ row.at,
237
+ );
238
+ this.sql.exec(
239
+ `INSERT INTO ${KEYS_TABLE} (bot_id, run_id, seq, at) VALUES (?, ?, ?, ?)`,
240
+ row.botId,
241
+ row.runId,
242
+ row.seq,
243
+ row.at,
244
+ );
245
+ inserted += 1;
246
+ }
247
+ this.evict();
248
+ return inserted;
249
+ }
250
+
251
+ /** Enforces the durable per-User row quota by discarding the oldest rows. */
252
+ private evict(): void {
253
+ let excess = this.count() - this.maxRows;
254
+ if (excess <= 0) return;
255
+ while (excess > 0) {
256
+ const oldest = this.sql
257
+ .exec<{ bot_id: string; run_id: string; seq: number }>(
258
+ `SELECT bot_id, run_id, seq FROM ${KEYS_TABLE} ORDER BY at ASC, bot_id ASC, run_id ASC, seq ASC LIMIT ?`,
259
+ Math.min(excess, 256),
260
+ )
261
+ .toArray();
262
+ if (oldest.length === 0) break;
263
+ for (const key of oldest) {
264
+ this.deleteRow(key.bot_id, key.run_id, Number(key.seq));
265
+ }
266
+ excess -= oldest.length;
267
+ }
268
+ this.setMeta(TRUNCATED_KEY, "1");
269
+ }
270
+
271
+ private deleteRow(botId: string, runId: string, seq: number): void {
272
+ this.sql.exec(
273
+ `DELETE FROM ${ROWS_TABLE} WHERE bot_id = ? AND run_id = ? AND seq = ?`,
274
+ botId,
275
+ runId,
276
+ seq,
277
+ );
278
+ this.sql.exec(
279
+ `DELETE FROM ${KEYS_TABLE} WHERE bot_id = ? AND run_id = ? AND seq = ?`,
280
+ botId,
281
+ runId,
282
+ seq,
283
+ );
284
+ }
285
+
286
+ /** Every row of one Bot leaves the index; the lifecycle saga calls this. */
287
+ purge(botId: string): number {
288
+ this.open();
289
+ const removed = this.sql
290
+ .exec<{ n: number }>(
291
+ `SELECT count(*) AS n FROM ${KEYS_TABLE} WHERE bot_id = ?`,
292
+ botId,
293
+ )
294
+ .toArray();
295
+ this.sql.exec(`DELETE FROM ${ROWS_TABLE} WHERE bot_id = ?`, botId);
296
+ this.sql.exec(`DELETE FROM ${KEYS_TABLE} WHERE bot_id = ?`, botId);
297
+ return Number(removed[0]?.n ?? 0);
298
+ }
299
+
300
+ /**
301
+ * One page of hits, most relevant first.
302
+ *
303
+ * `archivedBotIds` is re-checked here rather than trusted from write time: a
304
+ * Bot archived after its turns were indexed must disappear from default
305
+ * results without a rebuild, so lifecycle is a query-time filter over live
306
+ * directory state, never a column.
307
+ */
308
+ query(
309
+ request: SearchQueryV1,
310
+ directory: { archivedBotIds: readonly string[] },
311
+ ): SearchIndexResultsV1 {
312
+ this.open();
313
+ const indexState = this.state();
314
+ const match = searchMatchExpressionV1(request.query);
315
+ const empty: SearchIndexResultsV1 = {
316
+ schemaVersion: 1,
317
+ query: request.query,
318
+ hits: [],
319
+ truncated: false,
320
+ indexState,
321
+ };
322
+ if (!match) return empty;
323
+ const kinds = (request.kinds ?? SEARCH_DEFAULT_ROW_KINDS_V1).filter(
324
+ (kind): kind is SearchRowKindV1 => SEARCH_ROW_KINDS_V1.includes(kind),
325
+ );
326
+ if (kinds.length === 0) return empty;
327
+ const excluded = request.includeArchived
328
+ ? []
329
+ : [...new Set(directory.archivedBotIds)];
330
+ const offset = decodeOffset(request.before);
331
+
332
+ const bindings: unknown[] = [match, ...kinds];
333
+ let where = `${ROWS_TABLE} MATCH ? AND kind IN (${kinds.map(() => "?").join(", ")})`;
334
+ if (request.botId) {
335
+ where += " AND bot_id = ?";
336
+ bindings.push(request.botId);
337
+ }
338
+ if (excluded.length > 0) {
339
+ where += ` AND bot_id NOT IN (${excluded.map(() => "?").join(", ")})`;
340
+ bindings.push(...excluded);
341
+ }
342
+ const limit = SEARCH_MAX_RESULTS_V1;
343
+ const rows = this.sql
344
+ .exec<{
345
+ bot_id: string;
346
+ run_id: string;
347
+ seq: number;
348
+ kind: string;
349
+ at: string;
350
+ snippet: string;
351
+ }>(
352
+ `SELECT bot_id, run_id, seq, kind, at, ` +
353
+ `snippet(${ROWS_TABLE}, 0, '', '', '…', 24) AS snippet ` +
354
+ `FROM ${ROWS_TABLE} WHERE ${where} ORDER BY rank LIMIT ? OFFSET ?`,
355
+ ...bindings,
356
+ limit + 1,
357
+ offset,
358
+ )
359
+ .toArray();
360
+ const truncated = rows.length > limit;
361
+ const page = rows.slice(0, limit);
362
+ return {
363
+ schemaVersion: 1,
364
+ query: request.query,
365
+ hits: page.map((row) => ({
366
+ botId: String(row.bot_id),
367
+ runId: String(row.run_id),
368
+ kind: String(row.kind) as SearchRowKindV1,
369
+ at: String(row.at),
370
+ snippet: String(row.snippet ?? "").slice(
371
+ 0,
372
+ SEARCH_MAX_SNIPPET_LENGTH_V1,
373
+ ),
374
+ })),
375
+ truncated,
376
+ ...(truncated ? { nextCursor: `p${offset + page.length}` } : {}),
377
+ indexState,
378
+ };
379
+ }
380
+
381
+ /**
382
+ * Discards the table and re-projects it from the Bots' own stored runs.
383
+ *
384
+ * This is the correctness story for the whole Package. The index is
385
+ * disposable because this exists: it is the same code path a Bot's backfill
386
+ * uses, so a rebuild and a lifetime of incremental projections produce the
387
+ * identical result set. The `rebuilding` marker is durable, so a rebuild
388
+ * interrupted by eviction is visible as an unfinished index rather than
389
+ * silently reported as ready.
390
+ */
391
+ async rebuild(
392
+ sources: readonly SearchRowSourceV1[],
393
+ ): Promise<SearchRebuildOutcomeV1> {
394
+ this.open();
395
+ this.setMeta(REBUILDING_KEY, "1");
396
+ this.sql.exec(`DELETE FROM ${ROWS_TABLE}`);
397
+ this.sql.exec(`DELETE FROM ${KEYS_TABLE}`);
398
+ this.setMeta(TRUNCATED_KEY, undefined);
399
+ let indexedRows = 0;
400
+ try {
401
+ for (const source of sources) {
402
+ let cursor: string | undefined;
403
+ let pages = 0;
404
+ do {
405
+ const page = await source.page(cursor);
406
+ indexedRows += this.insert(
407
+ page.rows.filter((row) => row.botId === source.botId),
408
+ );
409
+ cursor = page.nextCursor;
410
+ pages += 1;
411
+ // A Bot cannot page for ever: the run index is bounded, and a source
412
+ // that never stops offering pages is a fault, not a large Bot.
413
+ } while (cursor && pages < 10_000);
414
+ }
415
+ } finally {
416
+ this.setMeta(REBUILDING_KEY, undefined);
417
+ }
418
+ return { indexedRows, bots: sources.length, indexState: this.state() };
419
+ }
420
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ export * from "./shared.js";
2
+ export {
3
+ SearchIndexV1,
4
+ SEARCH_REBUILD_PAGE_V1,
5
+ searchMatchExpressionV1,
6
+ type SearchRebuildOutcomeV1,
7
+ type SearchRowSourceV1,
8
+ type SearchSqlCursorV1,
9
+ type SearchSqlV1,
10
+ } from "./index-store.js";
11
+ export {
12
+ isSettledSearchRunV1,
13
+ searchRowsFromClientRunV1,
14
+ type SearchProjectableRunV1,
15
+ type SearchSinkV1,
16
+ } from "./bot.js";
17
+ export { default as manifest } from "./manifest.js";
@@ -0,0 +1,3 @@
1
+ import manifest from "../frockbot.json" with { type: "json" };
2
+
3
+ export default manifest;
@@ -0,0 +1,215 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ boundSearchBodyV1,
4
+ decodeClientSearchRebuildReceiptV1,
5
+ decodeClientSearchResultsV1,
6
+ decodeSearchQueryV1,
7
+ decodeSearchRowPageV1,
8
+ decodeSearchRowV1,
9
+ searchDeepLinkV1,
10
+ searchTurnAnchorV1,
11
+ SEARCH_MAX_BODY_BYTES_V1,
12
+ } from "./shared.ts";
13
+
14
+ const ROW = {
15
+ botId: "bot-a",
16
+ runId: "run-1",
17
+ seq: 0,
18
+ kind: "user" as const,
19
+ at: "2026-08-31T00:00:00.000Z",
20
+ body: "the gym build",
21
+ };
22
+
23
+ describe("search row decoding", () => {
24
+ test("round-trips an exact row", () => {
25
+ expect(decodeSearchRowV1({ ...ROW })).toEqual(ROW);
26
+ });
27
+
28
+ test("refuses an unexpected key", () => {
29
+ expect(() => decodeSearchRowV1({ ...ROW, userId: "u" })).toThrow(
30
+ "not allowed",
31
+ );
32
+ });
33
+
34
+ test("refuses an invalid kind, seq, or timestamp", () => {
35
+ expect(() => decodeSearchRowV1({ ...ROW, kind: "secret" })).toThrow();
36
+ expect(() => decodeSearchRowV1({ ...ROW, seq: -1 })).toThrow();
37
+ expect(() => decodeSearchRowV1({ ...ROW, at: "yesterday" })).toThrow();
38
+ });
39
+
40
+ test("truncates an over-long body to the durable per-row bound", () => {
41
+ const long = "x".repeat(SEARCH_MAX_BODY_BYTES_V1 + 500);
42
+ expect(decodeSearchRowV1({ ...ROW, body: long }).body.length).toBe(
43
+ SEARCH_MAX_BODY_BYTES_V1,
44
+ );
45
+ });
46
+
47
+ test("truncates on a code-point edge", () => {
48
+ const emoji = "😀".repeat(SEARCH_MAX_BODY_BYTES_V1);
49
+ const bounded = boundSearchBodyV1(emoji);
50
+ expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(
51
+ SEARCH_MAX_BODY_BYTES_V1,
52
+ );
53
+ expect([...bounded].every((point) => point === "😀")).toBe(true);
54
+ });
55
+ });
56
+
57
+ describe("search row page decoding", () => {
58
+ test("refuses rows a Bot offers on another Bot's behalf", () => {
59
+ expect(() =>
60
+ decodeSearchRowPageV1({
61
+ schemaVersion: 1,
62
+ botId: "bot-a",
63
+ rows: [{ ...ROW, botId: "bot-b" }],
64
+ }),
65
+ ).toThrow("another Bot");
66
+ });
67
+
68
+ test("round-trips a page with a cursor", () => {
69
+ const page = {
70
+ schemaVersion: 1 as const,
71
+ botId: "bot-a",
72
+ rows: [ROW],
73
+ nextCursor: "run-index:2026-08-31T00:00:00.000Z:run-1",
74
+ };
75
+ expect(decodeSearchRowPageV1(page)).toEqual(page);
76
+ });
77
+ });
78
+
79
+ describe("search query decoding", () => {
80
+ test("round-trips a full query", () => {
81
+ const query = {
82
+ schemaVersion: 1 as const,
83
+ query: "gym",
84
+ before: "p50",
85
+ kinds: ["user" as const, "tool" as const],
86
+ botId: "bot-a",
87
+ includeArchived: true,
88
+ };
89
+ expect(decodeSearchQueryV1(query)).toEqual(query);
90
+ });
91
+
92
+ test("refuses an empty or over-long kinds list", () => {
93
+ expect(() =>
94
+ decodeSearchQueryV1({ schemaVersion: 1, query: "gym", kinds: [] }),
95
+ ).toThrow();
96
+ expect(() =>
97
+ decodeSearchQueryV1({ schemaVersion: 1, query: "gym", kinds: ["nope"] }),
98
+ ).toThrow();
99
+ });
100
+
101
+ test("refuses an over-long query string", () => {
102
+ expect(() =>
103
+ decodeSearchQueryV1({ schemaVersion: 1, query: "x".repeat(257) }),
104
+ ).toThrow("bounded string");
105
+ });
106
+
107
+ test("refuses the wrong schema version", () => {
108
+ expect(() =>
109
+ decodeSearchQueryV1({ schemaVersion: 2, query: "gym" }),
110
+ ).toThrow("schemaVersion");
111
+ });
112
+ });
113
+
114
+ describe("deep links", () => {
115
+ test("name the Bot and the turn anchor", () => {
116
+ expect(searchDeepLinkV1("bot a", "run-1")).toBe("/?bot=bot%20a#turn-run-1");
117
+ expect(searchTurnAnchorV1("run-1")).toBe("turn-run-1");
118
+ });
119
+ });
120
+
121
+ describe("client search results decoding", () => {
122
+ const RESULTS = {
123
+ schemaVersion: 1 as const,
124
+ query: "gym",
125
+ groups: [
126
+ {
127
+ botId: "bot-a",
128
+ botName: "Site foreman",
129
+ archived: false,
130
+ hidden: true,
131
+ hits: [
132
+ {
133
+ runId: "run-1",
134
+ kind: "user" as const,
135
+ at: "2026-08-31T00:00:00.000Z",
136
+ snippet: "the gym build",
137
+ deepLink: "/?bot=bot-a#turn-run-1",
138
+ },
139
+ ],
140
+ totalHits: 1,
141
+ },
142
+ ],
143
+ page: { truncated: true, nextCursor: "p50" },
144
+ indexState: "ready" as const,
145
+ };
146
+
147
+ test("round-trips an exact page", () => {
148
+ expect(decodeClientSearchResultsV1(structuredClone(RESULTS))).toEqual(
149
+ RESULTS,
150
+ );
151
+ });
152
+
153
+ test("refuses an unexpected key anywhere in the page", () => {
154
+ expect(() =>
155
+ decodeClientSearchResultsV1({ ...RESULTS, userId: "user-1" }),
156
+ ).toThrow("not allowed");
157
+ expect(() =>
158
+ decodeClientSearchResultsV1({
159
+ ...RESULTS,
160
+ groups: [{ ...RESULTS.groups[0]!, botKey: "x" }],
161
+ }),
162
+ ).toThrow("not allowed");
163
+ expect(() =>
164
+ decodeClientSearchResultsV1({
165
+ ...RESULTS,
166
+ groups: [
167
+ {
168
+ ...RESULTS.groups[0]!,
169
+ hits: [{ ...RESULTS.groups[0]!.hits[0]!, body: "raw" }],
170
+ },
171
+ ],
172
+ }),
173
+ ).toThrow("not allowed");
174
+ });
175
+
176
+ test("refuses a totalHits that under-counts the hits it carries", () => {
177
+ expect(() =>
178
+ decodeClientSearchResultsV1({
179
+ ...RESULTS,
180
+ groups: [{ ...RESULTS.groups[0]!, totalHits: 0 }],
181
+ }),
182
+ ).toThrow("totalHits is invalid");
183
+ });
184
+
185
+ test("refuses an invalid index state", () => {
186
+ expect(() =>
187
+ decodeClientSearchResultsV1({ ...RESULTS, indexState: "stale" }),
188
+ ).toThrow("indexState is invalid");
189
+ });
190
+ });
191
+
192
+ describe("client rebuild receipt decoding", () => {
193
+ const RECEIPT = {
194
+ schemaVersion: 1 as const,
195
+ status: "rebuilt" as const,
196
+ indexedRows: 12,
197
+ bots: 3,
198
+ indexState: "ready" as const,
199
+ };
200
+
201
+ test("round-trips an exact receipt", () => {
202
+ expect(
203
+ decodeClientSearchRebuildReceiptV1(structuredClone(RECEIPT)),
204
+ ).toEqual(RECEIPT);
205
+ });
206
+
207
+ test("refuses a negative count and an unknown status", () => {
208
+ expect(() =>
209
+ decodeClientSearchRebuildReceiptV1({ ...RECEIPT, indexedRows: -1 }),
210
+ ).toThrow("non-negative integer");
211
+ expect(() =>
212
+ decodeClientSearchRebuildReceiptV1({ ...RECEIPT, status: "queued" }),
213
+ ).toThrow("status is invalid");
214
+ });
215
+ });