@frockbot/plugin-audit 0.0.0 → 0.1.1

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,168 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { AuditStoreV1 } from "./store.ts";
3
+ import { FakeAuditSql } from "./testing.ts";
4
+ import type { AuditEntryV1 } from "./shared.ts";
5
+
6
+ const DIGEST = "a".repeat(64);
7
+
8
+ function entry(overrides: Partial<AuditEntryV1> = {}): AuditEntryV1 {
9
+ const occurrenceId = overrides.occurrenceId ?? "tool:1:1:0";
10
+ const [, turn, step, ordinal] = /^tool:(\d+):(\d+):(\d+)$/.exec(
11
+ occurrenceId,
12
+ )!;
13
+ return {
14
+ schemaVersion: 1,
15
+ botId: "foreman",
16
+ runId: "run-1",
17
+ occurrenceId,
18
+ turn: Number(turn),
19
+ step: Number(step),
20
+ ordinal: Number(ordinal),
21
+ effectId: occurrenceId,
22
+ at: "2026-08-31T00:00:00.000Z",
23
+ kind: "shell",
24
+ target: "computer",
25
+ toolName: "computer_exec",
26
+ argumentDigest: DIGEST,
27
+ preview: "ls -la",
28
+ outcome: "ok",
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ function store(
34
+ options: { maxRows?: number; maxAgeMs?: number; now?: () => number } = {},
35
+ ) {
36
+ return new AuditStoreV1({ sql: new FakeAuditSql(), ...options });
37
+ }
38
+
39
+ describe("the audit table", () => {
40
+ test("inserts idempotently on (botId, runId, occurrenceId)", () => {
41
+ const table = store();
42
+ expect(table.insert([entry(), entry({ occurrenceId: "tool:1:1:1" })])).toBe(
43
+ 2,
44
+ );
45
+ // The outbox delivers at least once; the table is what makes that safe.
46
+ expect(table.insert([entry()])).toBe(0);
47
+ expect(table.count()).toBe(2);
48
+ // A different Bot with the same coordinates is a different effect.
49
+ expect(table.insert([entry({ botId: "scheduler" })])).toBe(1);
50
+ expect(table.count()).toBe(3);
51
+ });
52
+
53
+ test("evicts the oldest rows past the row bound and says so", () => {
54
+ const table = store({ maxRows: 3 });
55
+ expect(table.state()).toBe("ready");
56
+ for (let index = 0; index < 5; index += 1) {
57
+ table.insert([
58
+ entry({
59
+ occurrenceId: `tool:1:1:${index}`,
60
+ at: `2026-08-3${index}T00:00:00.000Z`,
61
+ }),
62
+ ]);
63
+ }
64
+ expect(table.count()).toBe(3);
65
+ expect(table.state()).toBe("truncated");
66
+ // The newest survived; the oldest are what left.
67
+ expect(table.all().map((row) => row.at)).toEqual([
68
+ "2026-08-34T00:00:00.000Z",
69
+ "2026-08-33T00:00:00.000Z",
70
+ "2026-08-32T00:00:00.000Z",
71
+ ]);
72
+ });
73
+
74
+ test("evicts past the age bound whatever the row count", () => {
75
+ const now = Date.parse("2026-08-31T00:00:00.000Z");
76
+ const table = store({ maxAgeMs: 1_000, now: () => now });
77
+ table.insert([
78
+ entry({ occurrenceId: "tool:1:1:0", at: "2026-08-30T00:00:00.000Z" }),
79
+ entry({ occurrenceId: "tool:1:1:1", at: "2026-08-30T23:59:59.900Z" }),
80
+ ]);
81
+ expect(table.all().map((row) => row.occurrenceId)).toEqual(["tool:1:1:1"]);
82
+ expect(table.state()).toBe("truncated");
83
+ });
84
+
85
+ test("purges one Bot and leaves the others", () => {
86
+ const table = store();
87
+ table.insert([entry(), entry({ botId: "scheduler" })]);
88
+ expect(table.purge("foreman")).toEqual(1);
89
+ expect(table.all().map((row) => row.botId)).toEqual(["scheduler"]);
90
+ expect(table.purge("foreman")).toEqual(0);
91
+ });
92
+
93
+ test("filters by kind, target and Bot, and pages with a cursor", () => {
94
+ const table = store();
95
+ table.insert([
96
+ entry({ occurrenceId: "tool:1:1:0", at: "2026-08-31T00:00:01.000Z" }),
97
+ entry({
98
+ occurrenceId: "tool:1:1:1",
99
+ at: "2026-08-31T00:00:02.000Z",
100
+ kind: "mcp",
101
+ target: "remote:mcp.example.test",
102
+ toolName: "mcp__example__echo",
103
+ }),
104
+ entry({
105
+ occurrenceId: "tool:1:1:2",
106
+ at: "2026-08-31T00:00:03.000Z",
107
+ kind: "browser",
108
+ }),
109
+ ]);
110
+ expect(
111
+ table.query({ kind: "shell" }).entries.map((row) => row.kind),
112
+ ).toEqual(["shell"]);
113
+ expect(
114
+ table
115
+ .query({ target: "remote:mcp.example.test" })
116
+ .entries.map((row) => row.toolName),
117
+ ).toEqual(["mcp__example__echo"]);
118
+ expect(table.query({ botId: "nobody" }).entries).toEqual([]);
119
+
120
+ // Newest first, one at a time, and the cursor walks the rest.
121
+ const first = table.query({ limit: 1 });
122
+ expect(first.total).toBe(3);
123
+ expect(first.entries[0]!.occurrenceId).toBe("tool:1:1:2");
124
+ const second = table.query({ limit: 1, before: first.nextCursor! });
125
+ expect(second.entries[0]!.occurrenceId).toBe("tool:1:1:1");
126
+ const third = table.query({ limit: 1, before: second.nextCursor! });
127
+ expect(third.entries[0]!.occurrenceId).toBe("tool:1:1:0");
128
+ expect(third.nextCursor).toBeUndefined();
129
+ });
130
+
131
+ test("a rebuild empties the table and reproduces the identical set", async () => {
132
+ const table = store({ maxRows: 2 });
133
+ const entries = [
134
+ entry({ occurrenceId: "tool:1:1:0", at: "2026-08-31T00:00:01.000Z" }),
135
+ entry({ occurrenceId: "tool:1:1:1", at: "2026-08-31T00:00:02.000Z" }),
136
+ ];
137
+ table.insert([
138
+ ...entries,
139
+ entry({ occurrenceId: "tool:1:1:2", at: "2026-08-01T00:00:00.000Z" }),
140
+ ]);
141
+ expect(table.state()).toBe("truncated");
142
+ const before = table.all();
143
+
144
+ const outcome = await table.rebuild([
145
+ {
146
+ botId: "foreman",
147
+ page: async (cursor) =>
148
+ cursor ? { entries: [] } : { entries, nextCursor: undefined },
149
+ },
150
+ ]);
151
+ expect(outcome).toMatchObject({ entries: 2, bots: 1, indexState: "ready" });
152
+ // A completed rebuild clears the truncation marker, because the table is
153
+ // once again everything the durable events say it should be.
154
+ expect(table.state()).toBe("ready");
155
+ expect(table.all()).toEqual(before);
156
+ });
157
+
158
+ test("a rebuild refuses a page that names another Bot's rows", async () => {
159
+ const table = store();
160
+ await table.rebuild([
161
+ {
162
+ botId: "foreman",
163
+ page: async () => ({ entries: [entry({ botId: "scheduler" })] }),
164
+ },
165
+ ]);
166
+ expect(table.count()).toBe(0);
167
+ });
168
+ });
package/src/store.ts ADDED
@@ -0,0 +1,432 @@
1
+ // The audit table: one deep module over one narrow SQL seam.
2
+ //
3
+ // WHERE IT LIVES. One table per User, in the User Durable Object —
4
+ // `AGENTS.md` § Authorities, "The User's Durable Object is the authority for
5
+ // everything User-scoped". It sits beside the transcript index and shares
6
+ // nothing with it: audit is filtered and paged by kind, target and time rather
7
+ // than searched, its retention policy is different, and it must not be
8
+ // reachable from a stray free-text query. A separate plain table, no FTS.
9
+ //
10
+ // WHAT IT IS NOT. It is not authority. Every row is a projection of the
11
+ // `tool/call` and `tool/result` events a Bot Durable Object already holds, and
12
+ // `rebuild()` reconstructs the whole table from them. A lost, corrupt, or
13
+ // evicted table costs a rebuild and nothing else.
14
+ //
15
+ // RETENTION. Two bounds, both durable and both visible: at most
16
+ // {@link AUDIT_MAX_ROWS_V1} rows per User, and nothing older than
17
+ // {@link AUDIT_MAX_AGE_MS_V1}. Enforcing a bound by discarding rather than
18
+ // refusing means the loss has to be observable, so it sets `audit-truncated`,
19
+ // which the UI shows and a rebuild clears.
20
+ import {
21
+ AUDIT_MAX_AGE_MS_V1,
22
+ AUDIT_MAX_ROWS_V1,
23
+ type AuditEntryV1,
24
+ type AuditIndexStateV1,
25
+ } from "./shared.js";
26
+
27
+ /** Exactly the column types SQLite storage returns. */
28
+ export type AuditSqlValueV1 = ArrayBuffer | string | number | null;
29
+
30
+ export interface AuditSqlCursorV1<Row extends Record<string, AuditSqlValueV1>> {
31
+ toArray(): Row[];
32
+ }
33
+
34
+ export interface AuditSqlV1 {
35
+ exec<Row extends Record<string, AuditSqlValueV1>>(
36
+ query: string,
37
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `SqlStorage.exec`
38
+ // declares `any[]`; a narrower parameter type here would stop
39
+ // `ctx.storage.sql` satisfying this interface at all.
40
+ ...bindings: any[]
41
+ ): AuditSqlCursorV1<Row>;
42
+ }
43
+
44
+ const TABLE = "audit_entries";
45
+ const META_TABLE = "audit_meta";
46
+ const TRUNCATED_KEY = "audit-truncated";
47
+ const REBUILDING_KEY = "audit-rebuilding";
48
+
49
+ /** The page size a rebuild pulls from one Bot at a time. */
50
+ export const AUDIT_REBUILD_PAGE_V1 = 32;
51
+
52
+ export interface AuditStoreOptionsV1 {
53
+ sql: AuditSqlV1;
54
+ /** Overridable so a test can drive eviction without twenty thousand rows. */
55
+ maxRows?: number;
56
+ /** Overridable so a test can drive age eviction without waiting 180 days. */
57
+ maxAgeMs?: number;
58
+ now?: () => number;
59
+ }
60
+
61
+ /** One Bot's entries, as the table pulls them during a rebuild. */
62
+ export interface AuditEntrySourceV1 {
63
+ botId: string;
64
+ page(
65
+ cursor?: string,
66
+ ): Promise<{ entries: AuditEntryV1[]; nextCursor?: string }>;
67
+ }
68
+
69
+ export interface AuditRebuildOutcomeV1 {
70
+ entries: number;
71
+ bots: number;
72
+ indexState: AuditIndexStateV1;
73
+ }
74
+
75
+ interface AuditRow extends Record<string, AuditSqlValueV1> {
76
+ bot_id: string;
77
+ run_id: string;
78
+ occurrence_id: string;
79
+ turn: number;
80
+ step: number;
81
+ ordinal: number;
82
+ effect_id: string;
83
+ at: string;
84
+ kind: string;
85
+ target: string;
86
+ tool_name: string;
87
+ argument_digest: string;
88
+ preview: string;
89
+ outcome: string;
90
+ exit_code: number | null;
91
+ duration_ms: number | null;
92
+ bytes_out: number | null;
93
+ }
94
+
95
+ function fromRow(row: AuditRow): AuditEntryV1 {
96
+ return {
97
+ schemaVersion: 1,
98
+ botId: String(row.bot_id),
99
+ runId: String(row.run_id),
100
+ occurrenceId: String(row.occurrence_id),
101
+ turn: Number(row.turn),
102
+ step: Number(row.step),
103
+ ordinal: Number(row.ordinal),
104
+ effectId: String(row.effect_id),
105
+ at: String(row.at),
106
+ kind: String(row.kind) as AuditEntryV1["kind"],
107
+ target: String(row.target),
108
+ toolName: String(row.tool_name),
109
+ argumentDigest: String(row.argument_digest),
110
+ preview: String(row.preview),
111
+ outcome: String(row.outcome) as AuditEntryV1["outcome"],
112
+ ...(row.exit_code === null ? {} : { exitCode: Number(row.exit_code) }),
113
+ ...(row.duration_ms === null
114
+ ? {}
115
+ : { durationMs: Number(row.duration_ms) }),
116
+ ...(row.bytes_out === null ? {} : { bytesOut: Number(row.bytes_out) }),
117
+ };
118
+ }
119
+
120
+ export class AuditStoreV1 {
121
+ private readonly sql: AuditSqlV1;
122
+ private readonly maxRows: number;
123
+ private readonly maxAgeMs: number;
124
+ private readonly now: () => number;
125
+ private opened = false;
126
+
127
+ constructor(options: AuditStoreOptionsV1) {
128
+ this.sql = options.sql;
129
+ this.maxRows = options.maxRows ?? AUDIT_MAX_ROWS_V1;
130
+ this.maxAgeMs = options.maxAgeMs ?? AUDIT_MAX_AGE_MS_V1;
131
+ this.now = options.now ?? (() => Date.now());
132
+ }
133
+
134
+ /** Creates the table if it is absent. Safe to call on every request. */
135
+ open(): void {
136
+ if (this.opened) return;
137
+ this.sql.exec(
138
+ `CREATE TABLE IF NOT EXISTS ${TABLE} (` +
139
+ "bot_id TEXT NOT NULL, run_id TEXT NOT NULL, occurrence_id TEXT NOT NULL, " +
140
+ "turn INTEGER NOT NULL, step INTEGER NOT NULL, ordinal INTEGER NOT NULL, " +
141
+ "effect_id TEXT NOT NULL, at TEXT NOT NULL, kind TEXT NOT NULL, " +
142
+ "target TEXT NOT NULL, tool_name TEXT NOT NULL, argument_digest TEXT NOT NULL, " +
143
+ "preview TEXT NOT NULL, outcome TEXT NOT NULL, exit_code INTEGER, " +
144
+ "duration_ms INTEGER, bytes_out INTEGER, " +
145
+ "PRIMARY KEY (bot_id, run_id, occurrence_id))",
146
+ );
147
+ this.sql.exec(`CREATE INDEX IF NOT EXISTS ${TABLE}_at ON ${TABLE} (at)`);
148
+ this.sql.exec(
149
+ `CREATE INDEX IF NOT EXISTS ${TABLE}_bot_at ON ${TABLE} (bot_id, at)`,
150
+ );
151
+ this.sql.exec(
152
+ `CREATE INDEX IF NOT EXISTS ${TABLE}_kind_at ON ${TABLE} (kind, at)`,
153
+ );
154
+ this.sql.exec(
155
+ `CREATE TABLE IF NOT EXISTS ${META_TABLE} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
156
+ );
157
+ this.opened = true;
158
+ }
159
+
160
+ private meta(key: string): string | undefined {
161
+ return this.sql
162
+ .exec<{ value: string }>(
163
+ `SELECT value FROM ${META_TABLE} WHERE key = ?`,
164
+ key,
165
+ )
166
+ .toArray()[0]?.value;
167
+ }
168
+
169
+ private setMeta(key: string, value: string | undefined): void {
170
+ if (value === undefined) {
171
+ this.sql.exec(`DELETE FROM ${META_TABLE} WHERE key = ?`, key);
172
+ return;
173
+ }
174
+ this.sql.exec(
175
+ `INSERT INTO ${META_TABLE} (key, value) VALUES (?, ?) ` +
176
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
177
+ key,
178
+ value,
179
+ );
180
+ }
181
+
182
+ /** `ready`, or the durable marker that says otherwise. */
183
+ state(): AuditIndexStateV1 {
184
+ this.open();
185
+ if (this.meta(REBUILDING_KEY)) return "rebuilding";
186
+ return this.meta(TRUNCATED_KEY) ? "truncated" : "ready";
187
+ }
188
+
189
+ count(): number {
190
+ this.open();
191
+ return Number(
192
+ this.sql
193
+ .exec<{ n: number }>(`SELECT count(*) AS n FROM ${TABLE}`)
194
+ .toArray()[0]?.n ?? 0,
195
+ );
196
+ }
197
+
198
+ /**
199
+ * Inserts entries, idempotently on `(botId, runId, occurrenceId)`.
200
+ *
201
+ * Returns how many were new. A Turn that settles twice — a redelivered
202
+ * outbox, a rebuild over a live table — inserts nothing the second time,
203
+ * which is what makes the outbox's at-least-once delivery safe.
204
+ */
205
+ insert(entries: readonly AuditEntryV1[]): number {
206
+ this.open();
207
+ let inserted = 0;
208
+ for (const entry of entries) {
209
+ const existing = this.sql
210
+ .exec<{ n: number }>(
211
+ `SELECT count(*) AS n FROM ${TABLE} WHERE bot_id = ? AND run_id = ? AND occurrence_id = ?`,
212
+ entry.botId,
213
+ entry.runId,
214
+ entry.occurrenceId,
215
+ )
216
+ .toArray();
217
+ if (Number(existing[0]?.n ?? 0) > 0) continue;
218
+ this.sql.exec(
219
+ `INSERT INTO ${TABLE} (bot_id, run_id, occurrence_id, turn, step, ordinal, ` +
220
+ "effect_id, at, kind, target, tool_name, argument_digest, preview, outcome, " +
221
+ "exit_code, duration_ms, bytes_out) " +
222
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
223
+ entry.botId,
224
+ entry.runId,
225
+ entry.occurrenceId,
226
+ entry.turn,
227
+ entry.step,
228
+ entry.ordinal,
229
+ entry.effectId,
230
+ entry.at,
231
+ entry.kind,
232
+ entry.target,
233
+ entry.toolName,
234
+ entry.argumentDigest,
235
+ entry.preview,
236
+ entry.outcome,
237
+ entry.exitCode ?? null,
238
+ entry.durationMs ?? null,
239
+ entry.bytesOut ?? null,
240
+ );
241
+ inserted += 1;
242
+ }
243
+ this.evict();
244
+ return inserted;
245
+ }
246
+
247
+ /**
248
+ * Enforces both durable bounds by discarding the oldest rows.
249
+ *
250
+ * Age first, then count: an entry past the age bound leaves whatever the row
251
+ * count is, so retention is a promise about time and not only about volume.
252
+ */
253
+ private evict(): void {
254
+ let evicted = false;
255
+ const horizon = new Date(this.now() - this.maxAgeMs).toISOString();
256
+ const aged = this.sql
257
+ .exec<{ n: number }>(
258
+ `SELECT count(*) AS n FROM ${TABLE} WHERE at < ?`,
259
+ horizon,
260
+ )
261
+ .toArray();
262
+ if (Number(aged[0]?.n ?? 0) > 0) {
263
+ this.sql.exec(`DELETE FROM ${TABLE} WHERE at < ?`, horizon);
264
+ evicted = true;
265
+ }
266
+ let excess = this.count() - this.maxRows;
267
+ while (excess > 0) {
268
+ const oldest = this.sql
269
+ .exec<{ bot_id: string; run_id: string; occurrence_id: string }>(
270
+ `SELECT bot_id, run_id, occurrence_id FROM ${TABLE} ` +
271
+ "ORDER BY at ASC, bot_id ASC, run_id ASC, occurrence_id ASC LIMIT ?",
272
+ Math.min(excess, 256),
273
+ )
274
+ .toArray();
275
+ if (oldest.length === 0) break;
276
+ for (const key of oldest) {
277
+ this.sql.exec(
278
+ `DELETE FROM ${TABLE} WHERE bot_id = ? AND run_id = ? AND occurrence_id = ?`,
279
+ key.bot_id,
280
+ key.run_id,
281
+ key.occurrence_id,
282
+ );
283
+ }
284
+ excess -= oldest.length;
285
+ evicted = true;
286
+ }
287
+ if (evicted) this.setMeta(TRUNCATED_KEY, "1");
288
+ }
289
+
290
+ /** Every entry of one Bot leaves the table; the archive saga calls this. */
291
+ purge(botId: string): number {
292
+ this.open();
293
+ const removed = Number(
294
+ this.sql
295
+ .exec<{ n: number }>(
296
+ `SELECT count(*) AS n FROM ${TABLE} WHERE bot_id = ?`,
297
+ botId,
298
+ )
299
+ .toArray()[0]?.n ?? 0,
300
+ );
301
+ this.sql.exec(`DELETE FROM ${TABLE} WHERE bot_id = ?`, botId);
302
+ return removed;
303
+ }
304
+
305
+ /**
306
+ * Every entry, newest first. The route's filters and paging sit on top of
307
+ * this in `query.ts`; this is the whole-table read a rebuild compares
308
+ * against and a test asserts on.
309
+ */
310
+ all(): AuditEntryV1[] {
311
+ this.open();
312
+ return this.sql
313
+ .exec<AuditRow>(
314
+ `SELECT * FROM ${TABLE} ORDER BY at DESC, bot_id ASC, run_id ASC, occurrence_id ASC`,
315
+ )
316
+ .toArray()
317
+ .map(fromRow);
318
+ }
319
+
320
+ /**
321
+ * One filtered, paged answer.
322
+ *
323
+ * The cursor is an offset rather than a key range, matching the transcript
324
+ * index's, because the table is bounded at twenty thousand rows: the deepest
325
+ * possible page is cheap, and an opaque offset cannot be used to address a
326
+ * row the filters would have excluded.
327
+ */
328
+ query(request: {
329
+ botId?: string;
330
+ kind?: string;
331
+ target?: string;
332
+ before?: string;
333
+ limit?: number;
334
+ }): { entries: AuditEntryV1[]; nextCursor?: string; total: number } {
335
+ this.open();
336
+ const limit = Math.min(Math.max(request.limit ?? 50, 1), 500);
337
+ const offset = decodeAuditOffsetV1(request.before);
338
+ const clauses: string[] = [];
339
+ const bindings: unknown[] = [];
340
+ if (request.botId) {
341
+ clauses.push("bot_id = ?");
342
+ bindings.push(request.botId);
343
+ }
344
+ if (request.kind) {
345
+ clauses.push("kind = ?");
346
+ bindings.push(request.kind);
347
+ }
348
+ if (request.target) {
349
+ clauses.push("target = ?");
350
+ bindings.push(request.target);
351
+ }
352
+ const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
353
+ const total = Number(
354
+ this.sql
355
+ .exec<{ n: number }>(
356
+ `SELECT count(*) AS n FROM ${TABLE}${where}`,
357
+ ...bindings,
358
+ )
359
+ .toArray()[0]?.n ?? 0,
360
+ );
361
+ const rows = this.sql
362
+ .exec<AuditRow>(
363
+ `SELECT * FROM ${TABLE}${where} ` +
364
+ "ORDER BY at DESC, bot_id ASC, run_id ASC, occurrence_id ASC LIMIT ? OFFSET ?",
365
+ ...bindings,
366
+ limit + 1,
367
+ offset,
368
+ )
369
+ .toArray();
370
+ const page = rows.slice(0, limit).map(fromRow);
371
+ return {
372
+ entries: page,
373
+ ...(rows.length > limit
374
+ ? { nextCursor: `p${offset + page.length}` }
375
+ : {}),
376
+ total,
377
+ };
378
+ }
379
+
380
+ /**
381
+ * Discards the table and re-projects it from the Bots' own stored runs.
382
+ *
383
+ * This is the correctness story for the whole Package: the table is
384
+ * disposable because this exists, and it is the same projection function
385
+ * settlement uses, so a rebuild and a lifetime of incremental projections
386
+ * produce the identical set. The `rebuilding` marker is durable, so a
387
+ * rebuild interrupted by eviction is visible as an unfinished table rather
388
+ * than silently reported as ready.
389
+ */
390
+ async rebuild(
391
+ sources: readonly AuditEntrySourceV1[],
392
+ ): Promise<AuditRebuildOutcomeV1> {
393
+ this.open();
394
+ this.setMeta(REBUILDING_KEY, "1");
395
+ this.sql.exec(`DELETE FROM ${TABLE}`);
396
+ this.setMeta(TRUNCATED_KEY, undefined);
397
+ let entries = 0;
398
+ try {
399
+ for (const source of sources) {
400
+ let cursor: string | undefined;
401
+ let pages = 0;
402
+ do {
403
+ const page = await source.page(cursor);
404
+ entries += this.insert(
405
+ page.entries.filter((entry) => entry.botId === source.botId),
406
+ );
407
+ cursor = page.nextCursor;
408
+ pages += 1;
409
+ // A Bot cannot page for ever: the run index is bounded, and a source
410
+ // that never stops offering pages is a fault, not a large Bot.
411
+ } while (cursor && pages < 10_000);
412
+ }
413
+ } finally {
414
+ this.setMeta(REBUILDING_KEY, undefined);
415
+ }
416
+ return { entries, bots: sources.length, indexState: this.state() };
417
+ }
418
+ }
419
+
420
+ const CURSOR_PATTERN = /^p([0-9]{1,9})$/;
421
+
422
+ /** The offset an opaque page cursor names. */
423
+ export function decodeAuditOffsetV1(cursor: string | undefined): number {
424
+ if (cursor === undefined) return 0;
425
+ const match = CURSOR_PATTERN.exec(cursor);
426
+ if (!match) throw new Error("audit cursor is invalid");
427
+ const offset = Number(match[1]);
428
+ if (!Number.isSafeInteger(offset) || offset > 100_000) {
429
+ throw new Error("audit cursor is invalid");
430
+ }
431
+ return offset;
432
+ }