@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.
- package/frockbot.json +24 -0
- package/package.json +42 -6
- package/src/backend.test.ts +210 -0
- package/src/backend.ts +216 -0
- package/src/bot.test.ts +105 -0
- package/src/bot.ts +125 -0
- package/src/client/SearchBox.vue +21 -0
- package/src/client/SearchOverlay.vue +165 -0
- package/src/client/index.ts +192 -0
- package/src/client/state.ts +44 -0
- package/src/client/styles.css +225 -0
- package/src/env.d.ts +5 -0
- package/src/groups.test.ts +130 -0
- package/src/groups.ts +78 -0
- package/src/index-store.test.ts +206 -0
- package/src/index-store.ts +420 -0
- package/src/index.ts +17 -0
- package/src/manifest.ts +3 -0
- package/src/shared.test.ts +215 -0
- package/src/shared.ts +569 -0
- package/src/testing.ts +213 -0
- package/src/user.ts +116 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
|
@@ -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";
|
package/src/manifest.ts
ADDED
|
@@ -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
|
+
});
|