@frockbot/plugin-audit 0.3.8 → 0.3.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-audit",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -25,11 +25,11 @@
25
25
  "typecheck": "vue-tsc --noEmit -p tsconfig.json"
26
26
  },
27
27
  "dependencies": {
28
- "@frockbot/client-core": "0.3.8",
29
- "@frockbot/client-ui": "0.3.8",
30
- "@frockbot/kernel-contracts": "0.3.8",
31
- "@frockbot/plugin-shell": "0.3.8",
32
- "@frockbot/secret-shapes": "0.3.8",
28
+ "@frockbot/client-core": "0.3.10",
29
+ "@frockbot/client-ui": "0.3.10",
30
+ "@frockbot/kernel-contracts": "0.3.10",
31
+ "@frockbot/plugin-shell": "0.3.10",
32
+ "@frockbot/secret-shapes": "0.3.10",
33
33
  "cordis": "4.0.0-rc.8",
34
34
  "vue": "3.5.41"
35
35
  },
@@ -8,7 +8,12 @@
8
8
  // reconciliation rule forbids. The same goes for truncation — a table trimmed
9
9
  // to its retention bound says so above the rows rather than quietly answering
10
10
  // with fewer.
11
- import { UiAnchor, UiButton, UiIcon } from "@frockbot/client-ui";
11
+ import {
12
+ formatMomentV1,
13
+ UiAnchor,
14
+ UiButton,
15
+ UiIcon,
16
+ } from "@frockbot/client-ui";
12
17
  import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
13
18
  import { settingsLinkV1 } from "@frockbot/plugin-shell/settings-links";
14
19
  import { computed, inject, watch } from "vue";
@@ -64,9 +69,14 @@ function target(entry: AuditEntryV1): string {
64
69
  return entry.target.slice("remote:".length);
65
70
  }
66
71
 
72
+ /**
73
+ * The moment, in the reader's own zone and in the house order. It used to be a
74
+ * bare `toLocaleString()`, which for an Australian reader rendered a US
75
+ * month-first date — the one column beside it that a person reads to orient
76
+ * themselves.
77
+ */
67
78
  function when(entry: AuditEntryV1): string {
68
- const parsed = Date.parse(entry.at);
69
- return Number.isFinite(parsed) ? new Date(parsed).toLocaleString() : entry.at;
79
+ return formatMomentV1(entry.at);
70
80
  }
71
81
  </script>
72
82
 
@@ -166,17 +176,19 @@ function when(entry: AuditEntryV1): string {
166
176
  :key="`${entry.runId}:${entry.occurrenceId}`"
167
177
  class="audit-row"
168
178
  >
169
- <span class="audit-row__time">{{ when(entry) }}</span>
179
+ <time class="audit-row__time" :datetime="entry.at">{{
180
+ when(entry)
181
+ }}</time>
170
182
  <span class="audit-row__kind" :data-kind="entry.kind">{{
171
183
  entry.kind
172
184
  }}</span>
173
185
  <span class="audit-row__target">{{ target(entry) }}</span>
174
- <span class="audit-row__preview" :title="entry.toolName">{{
175
- entry.preview
176
- }}</span>
177
186
  <span class="audit-row__outcome" :data-outcome="entry.outcome">{{
178
187
  entry.outcome
179
188
  }}</span>
189
+ <span class="audit-row__preview" :title="entry.toolName">{{
190
+ entry.preview
191
+ }}</span>
180
192
  </li>
181
193
  </ol>
182
194
 
@@ -308,8 +320,11 @@ function when(entry: AuditEntryV1): string {
308
320
  .audit-row {
309
321
  display: grid;
310
322
  align-items: baseline;
311
- gap: 8px;
312
- grid-template-columns: auto auto auto 1fr auto;
323
+ gap: 4px 8px;
324
+ /* Time, kind, target, outcome across the top; what happened on its own line
325
+ underneath, where it has the whole row to say it. */
326
+ grid-template-areas: "time kind target outcome" "preview preview preview preview";
327
+ grid-template-columns: auto auto 1fr auto;
313
328
  border: 1px solid var(--frock-border);
314
329
  border-radius: var(--frock-radius-card);
315
330
  padding: 6px 10px;
@@ -317,9 +332,45 @@ function when(entry: AuditEntryV1): string {
317
332
  font-size: var(--frock-text-sm);
318
333
  }
319
334
 
335
+ .audit-row__time {
336
+ grid-area: time;
337
+ }
338
+
339
+ .audit-row__kind {
340
+ grid-area: kind;
341
+ }
342
+
343
+ .audit-row__target {
344
+ grid-area: target;
345
+ }
346
+
347
+ .audit-row__outcome {
348
+ grid-area: outcome;
349
+ }
350
+
351
+ .audit-row__preview {
352
+ grid-area: preview;
353
+ }
354
+
355
+ /* On a phone the target is the first thing to go: it is the same for every row
356
+ of a session, while the tool name is the only part that says what happened
357
+ and used to be clipped to a single character. */
358
+ @media (max-width: 520px) {
359
+ .audit-row {
360
+ grid-template-areas: "time kind outcome" "preview preview preview";
361
+ grid-template-columns: 1fr auto auto;
362
+ }
363
+
364
+ .audit-row__target {
365
+ display: none;
366
+ }
367
+ }
368
+
320
369
  .audit-row__time,
321
370
  .audit-row__target {
371
+ overflow: hidden;
322
372
  color: var(--frock-text-muted);
373
+ text-overflow: ellipsis;
323
374
  white-space: nowrap;
324
375
  }
325
376
 
package/src/index.ts CHANGED
@@ -14,7 +14,9 @@ export {
14
14
  export {
15
15
  AuditStoreV1,
16
16
  AUDIT_REBUILD_PAGE_V1,
17
- decodeAuditOffsetV1,
17
+ decodeAuditCursorV1,
18
+ encodeAuditCursorV1,
19
+ type AuditCursorV1,
18
20
  type AuditEntrySourceV1,
19
21
  type AuditRebuildOutcomeV1,
20
22
  type AuditSqlCursorV1,
package/src/store.test.ts CHANGED
@@ -128,6 +128,40 @@ describe("the audit table", () => {
128
128
  expect(third.nextCursor).toBeUndefined();
129
129
  });
130
130
 
131
+ test("rows written between two pages do not repeat or hide a row", () => {
132
+ const table = store();
133
+ table.insert([
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
+ entry({ occurrenceId: "tool:1:1:2", at: "2026-08-31T00:00:03.000Z" }),
137
+ ]);
138
+
139
+ const first = table.query({ limit: 2 });
140
+ expect(first.entries.map((row) => row.occurrenceId)).toEqual([
141
+ "tool:1:1:2",
142
+ "tool:1:1:1",
143
+ ]);
144
+
145
+ // The Bot keeps working while the reader reads. Under an offset cursor
146
+ // these two new rows shifted the window down by two, so "Load more"
147
+ // returned `tool:1:1:2` and `tool:1:1:1` a second time — duplicate rows on
148
+ // screen and duplicate Vue keys — and `tool:1:1:0` was never reachable.
149
+ table.insert([
150
+ entry({ occurrenceId: "tool:1:1:3", at: "2026-08-31T00:00:04.000Z" }),
151
+ entry({ occurrenceId: "tool:1:1:4", at: "2026-08-31T00:00:05.000Z" }),
152
+ ]);
153
+
154
+ const second = table.query({ limit: 2, before: first.nextCursor! });
155
+ expect(second.entries.map((row) => row.occurrenceId)).toEqual([
156
+ "tool:1:1:0",
157
+ ]);
158
+ expect(second.nextCursor).toBeUndefined();
159
+ const shown = [...first.entries, ...second.entries].map(
160
+ (row) => row.occurrenceId,
161
+ );
162
+ expect(new Set(shown).size).toBe(shown.length);
163
+ });
164
+
131
165
  test("a rebuild empties the table and reproduces the identical set", async () => {
132
166
  const table = store({ maxRows: 2 });
133
167
  const entries = [
package/src/store.ts CHANGED
@@ -346,10 +346,13 @@ export class AuditStoreV1 {
346
346
  /**
347
347
  * One filtered, paged answer.
348
348
  *
349
- * The cursor is an offset rather than a key range, matching the transcript
350
- * index's, because the table is bounded at twenty thousand rows: the deepest
351
- * possible page is cheap, and an opaque offset cannot be used to address a
352
- * row the filters would have excluded.
349
+ * The cursor names the last row of the page it came from, not how many rows
350
+ * were skipped. An offset over a table written at the head is a window that
351
+ * moves under the reader: every row inserted between "Load more" and the
352
+ * page before it shifted the window down, so the next page repeated rows
353
+ * already on screen — visibly, as duplicate keys — and skipped exactly as
354
+ * many that nobody ever saw. A key range is stable under inserts because it
355
+ * addresses a row rather than a position.
353
356
  */
354
357
  query(request: {
355
358
  botId?: string;
@@ -364,7 +367,7 @@ export class AuditStoreV1 {
364
367
  // row past the 180-day bound simply because no insert came to evict them.
365
368
  this.evict();
366
369
  const limit = Math.min(Math.max(request.limit ?? 50, 1), 500);
367
- const offset = decodeAuditOffsetV1(request.before);
370
+ const after = decodeAuditCursorV1(request.before);
368
371
  const clauses: string[] = [];
369
372
  const bindings: unknown[] = [];
370
373
  if (request.botId) {
@@ -388,20 +391,53 @@ export class AuditStoreV1 {
388
391
  )
389
392
  .toArray()[0]?.n ?? 0,
390
393
  );
394
+ // The predicate is the sort order spelled out: strictly after the cursor
395
+ // row under `at DESC, bot_id ASC, run_id ASC, occurrence_id ASC`.
396
+ const pageClauses = [...clauses];
397
+ const pageBindings = [...bindings];
398
+ if (after) {
399
+ pageClauses.push(
400
+ "(at < ?" +
401
+ " OR (at = ? AND bot_id > ?)" +
402
+ " OR (at = ? AND bot_id = ? AND run_id > ?)" +
403
+ " OR (at = ? AND bot_id = ? AND run_id = ? AND occurrence_id > ?))",
404
+ );
405
+ pageBindings.push(
406
+ after.at,
407
+ after.at,
408
+ after.botId,
409
+ after.at,
410
+ after.botId,
411
+ after.runId,
412
+ after.at,
413
+ after.botId,
414
+ after.runId,
415
+ after.occurrenceId,
416
+ );
417
+ }
418
+ const pageWhere =
419
+ pageClauses.length > 0 ? ` WHERE ${pageClauses.join(" AND ")}` : "";
391
420
  const rows = this.sql
392
421
  .exec<AuditRow>(
393
- `SELECT * FROM ${TABLE}${where} ` +
394
- "ORDER BY at DESC, bot_id ASC, run_id ASC, occurrence_id ASC LIMIT ? OFFSET ?",
395
- ...bindings,
422
+ `SELECT * FROM ${TABLE}${pageWhere} ` +
423
+ "ORDER BY at DESC, bot_id ASC, run_id ASC, occurrence_id ASC LIMIT ?",
424
+ ...pageBindings,
396
425
  limit + 1,
397
- offset,
398
426
  )
399
427
  .toArray();
400
- const page = rows.slice(0, limit).map(fromRow);
428
+ const kept = rows.slice(0, limit);
429
+ const last = kept.at(-1);
401
430
  return {
402
- entries: page,
403
- ...(rows.length > limit
404
- ? { nextCursor: `p${offset + page.length}` }
431
+ entries: kept.map(fromRow),
432
+ ...(rows.length > limit && last
433
+ ? {
434
+ nextCursor: encodeAuditCursorV1({
435
+ at: last.at,
436
+ botId: last.bot_id,
437
+ runId: last.run_id,
438
+ occurrenceId: last.occurrence_id,
439
+ }),
440
+ }
405
441
  : {}),
406
442
  total,
407
443
  };
@@ -473,16 +509,53 @@ export class AuditStoreV1 {
473
509
  }
474
510
  }
475
511
 
476
- const CURSOR_PATTERN = /^p([0-9]{1,9})$/;
512
+ /** The row a page cursor addresses. */
513
+ export interface AuditCursorV1 {
514
+ at: string;
515
+ botId: string;
516
+ runId: string;
517
+ occurrenceId: string;
518
+ }
519
+
520
+ const CURSOR_SEPARATOR = "\u0000";
521
+ const CURSOR_MAX_FIELD = 256;
522
+
523
+ /**
524
+ * The cursor is the sort key of the last row of a page, encoded opaquely.
525
+ *
526
+ * It addresses a row and not a position, so rows inserted at the head between
527
+ * one page and the next cannot shift the window: "Load more" continues from
528
+ * where the reader is, rather than from a count that has since moved.
529
+ */
530
+ export function encodeAuditCursorV1(cursor: AuditCursorV1): string {
531
+ return btoa(
532
+ [cursor.at, cursor.botId, cursor.runId, cursor.occurrenceId].join(
533
+ CURSOR_SEPARATOR,
534
+ ),
535
+ );
536
+ }
477
537
 
478
- /** The offset an opaque page cursor names. */
479
- export function decodeAuditOffsetV1(cursor: string | undefined): number {
480
- if (cursor === undefined) return 0;
481
- const match = CURSOR_PATTERN.exec(cursor);
482
- if (!match) throw new Error("audit cursor is invalid");
483
- const offset = Number(match[1]);
484
- if (!Number.isSafeInteger(offset) || offset > 100_000) {
538
+ export function decodeAuditCursorV1(
539
+ cursor: string | undefined,
540
+ ): AuditCursorV1 | undefined {
541
+ if (cursor === undefined) return undefined;
542
+ let decoded: string;
543
+ try {
544
+ decoded = atob(cursor);
545
+ } catch {
485
546
  throw new Error("audit cursor is invalid");
486
547
  }
487
- return offset;
548
+ const fields = decoded.split(CURSOR_SEPARATOR);
549
+ if (fields.length !== 4) throw new Error("audit cursor is invalid");
550
+ for (const field of fields) {
551
+ if (field.length > CURSOR_MAX_FIELD) {
552
+ throw new Error("audit cursor is invalid");
553
+ }
554
+ }
555
+ return {
556
+ at: fields[0]!,
557
+ botId: fields[1]!,
558
+ runId: fields[2]!,
559
+ occurrenceId: fields[3]!,
560
+ };
488
561
  }
package/src/testing.ts CHANGED
@@ -194,9 +194,10 @@ export class FakeAuditSql implements AuditSqlV1 {
194
194
  (left, right) => -order(left, right),
195
195
  );
196
196
  if (!sql.includes("LIMIT")) return answer(descending);
197
- const limit = Number(bindings[bindings.length - 2]);
198
- const offset = Number(bindings[bindings.length - 1]);
199
- return answer(descending.slice(offset, offset + limit));
197
+ // Paging is a key range now, not an offset: the limit is the last
198
+ // binding and the page starts wherever the cursor predicate left off.
199
+ const limit = Number(bindings[bindings.length - 1]);
200
+ return answer(descending.slice(0, limit));
200
201
  }
201
202
  throw new Error(`FakeAuditSql does not recognise: ${sql}`);
202
203
  }
@@ -208,13 +209,34 @@ export class FakeAuditSql implements AuditSqlV1 {
208
209
  if (where.startsWith("at <")) {
209
210
  return this.rowsIn(table).filter((row) => row.at < String(values[0]));
210
211
  }
212
+ // The keyset page predicate is always the last clause, and it is the one
213
+ // clause that is not `column = ?`.
214
+ const keysetAt = where.indexOf("(at < ?");
215
+ const columnWhere =
216
+ keysetAt < 0 ? where : where.slice(0, keysetAt).replace(/ AND $/u, "");
211
217
  const predicates: Array<(row: FakeRow) => boolean> = [];
212
- for (const clause of where.split(" AND ").filter(Boolean)) {
218
+ for (const clause of columnWhere.split(" AND ").filter(Boolean)) {
213
219
  const column = clause.split(" ")[0] as keyof FakeRow;
214
220
  if (!COLUMNS.includes(column as (typeof COLUMNS)[number])) continue;
215
221
  const expected = String(values.shift());
216
222
  predicates.push((row) => String(row[column]) === expected);
217
223
  }
224
+ if (keysetAt >= 0) {
225
+ const at = String(values[0]);
226
+ const botId = String(values[2]);
227
+ const runId = String(values[5]);
228
+ const occurrenceId = String(values[9]);
229
+ predicates.push(
230
+ (row) =>
231
+ row.at < at ||
232
+ (row.at === at && row.bot_id > botId) ||
233
+ (row.at === at && row.bot_id === botId && row.run_id > runId) ||
234
+ (row.at === at &&
235
+ row.bot_id === botId &&
236
+ row.run_id === runId &&
237
+ row.occurrence_id > occurrenceId),
238
+ );
239
+ }
218
240
  return this.rowsIn(table).filter((row) =>
219
241
  predicates.every((predicate) => predicate(row)),
220
242
  );