@spooky-sync/core 0.0.1-canary.290 → 0.0.1-canary.292

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.
@@ -1,19 +1,20 @@
1
1
  import type { LocalStore, RemoteDatabaseService } from '../../services/database/index';
2
- import type { Logger } from '../../services/logger/index';
2
+ import type { Level } from 'pino';
3
+ import type { LogRead, LogTap, Logger } from '../../services/logger/index';
3
4
  import type { SchemaStructure } from '@spooky-sync/query-builder';
4
5
  import { RecordId } from 'surrealdb';
5
6
  import type { StreamUpdate, StreamUpdateReceiver } from '../../services/stream-processor/index';
6
7
  import { encodeRecordId } from '../../utils/index';
7
8
 
8
- // DevTools interfaces (matching extension expectations)
9
- export interface DevToolsEvent {
10
- id: number;
11
- timestamp: number;
12
- eventType: string;
13
- payload: any;
14
- }
15
-
16
- import type { QueryState, QueryTimings } from '../../types';
9
+ import type { MutationEventType, QueryState, QueryTimings } from '../../types';
10
+ import type { ClientState } from '../../state/client-state';
11
+ import {
12
+ parsePendingRow,
13
+ parseStoredRecordId,
14
+ storedIdString,
15
+ type FailedMutationRow,
16
+ type PendingMutationRow,
17
+ } from '../../mutation/rows';
17
18
  import type {
18
19
  AuthService,
19
20
  ActiveImpersonation,
@@ -29,6 +30,13 @@ import {
29
30
  } from './versions';
30
31
  import { walkOpfs, type BlobCacheInfo, type SharedTabsInfo, type StorageInfo } from './storage-info';
31
32
  import { FlagsAdminService, type LocalOverrideStore } from './flags';
33
+ import {
34
+ buildMutationsState,
35
+ queuedAtOf,
36
+ trimHistory,
37
+ type DevToolsMutationsState,
38
+ type MutationHistoryEntry,
39
+ } from './mutations';
32
40
 
33
41
  // Real bundled frontend versions, injected at build time by tsdown's
34
42
  // version-define plugin (see tsdown.config.ts). The `typeof` guard keeps these
@@ -51,6 +59,28 @@ export interface DevToolsQuerySource {
51
59
 
52
60
  export type ImpersonationOp = 'status' | 'listUsers' | 'start' | 'stop';
53
61
 
62
+ /** What the Mutations tab reads and does; the client wires it to the runtime. */
63
+ export interface DevToolsMutationSource {
64
+ state(): ClientState;
65
+ listFailed(): Promise<FailedMutationRow[]>;
66
+ retryFailed(mutationId: string): Promise<boolean>;
67
+ discardFailed(mutationId: string): Promise<boolean>;
68
+ }
69
+
70
+ export type MutationOp = 'listFailed' | 'get' | 'retry' | 'discard' | 'clearHistory';
71
+
72
+ export interface MutationOpResult {
73
+ success: boolean;
74
+ error?: string;
75
+ failed?: FailedMutationRow[];
76
+ /** `get`: the stored outbox row, or null once it left the outbox. */
77
+ row?: PendingMutationRow | null;
78
+ }
79
+
80
+ export type LogOp = 'read' | 'setCaptureLevel' | 'clear';
81
+
82
+ export type LogOpResult = { success: boolean; error?: string } & Partial<LogRead>;
83
+
54
84
  export interface ImpersonationOpResult {
55
85
  success: boolean;
56
86
  error?: string;
@@ -63,26 +93,25 @@ export interface ImpersonationOpResult {
63
93
  }
64
94
 
65
95
  export class DevToolsService implements StreamUpdateReceiver {
66
- private eventsHistory: DevToolsEvent[] = [];
67
- private eventIdCounter = 0;
68
96
  // Real bundled frontend version (injected at build time via tsdown `define`).
69
97
  private version = CORE_VERSION;
70
98
  // Backend stack info (versions + per-entity status), read via the
71
99
  // `fn::spooky::info()` SurrealQL function; empty/'unavailable' until resolved.
72
100
  private backendInfo: BackendInfo = emptyBackendInfo();
73
101
  // Dormant until a devtools consumer (extension panel or MCP) handshakes via
74
- // `SP00KY_DEVTOOLS_CONNECT`. While false, `notifyDevTools()`/`addEvent()` do no
75
- // work, so prod pays zero serialization/postMessage cost for an unwatched panel.
102
+ // `SP00KY_DEVTOOLS_CONNECT`. While false, `notifyDevTools()` and the mutation
103
+ // history do no work, so prod pays zero serialization/postMessage cost for an
104
+ // unwatched panel.
76
105
  // `window.__00__.getState()` stays live regardless, so the panel's first paint
77
106
  // (the on-demand GET_STATE pull) still works before the push channel turns on.
78
107
  private enabled = false;
79
108
 
80
109
  // A state push serializes EVERY active query's full record set (see
81
110
  // `getActiveQueries`) and postMessage clones it again, so its cost scales with
82
- // the whole client dataset — and it is triggered per event, including one per
83
- // local DB query (`DATABASE_LOCAL_QUERY` → `logEvent`). Unthrottled, a single
84
- // page load's few hundred local queries turn a handful of MB of rows into GBs
85
- // of short-lived large-object garbage and OOM the renderer (V8
111
+ // the whole client dataset — and it is requested on every sync event (it used
112
+ // to be one per local DB query, when those were recorded as events).
113
+ // Unthrottled, a page load's burst of requests turned a handful of MB of rows
114
+ // into GBs of short-lived large-object garbage and OOMed the renderer (V8
86
115
  // "young object promotion failed"). Coalesce instead: push immediately when
87
116
  // idle, then at most once per window, always serializing the LATEST state.
88
117
  private static readonly NOTIFY_MIN_INTERVAL_MS = 250;
@@ -104,6 +133,38 @@ export class DevToolsService implements StreamUpdateReceiver {
104
133
  /** Blob cache counters for the panel, wired by Sp00kyClient. */
105
134
  private blobInfoProvider: (() => BlobCacheInfo) | null = null;
106
135
 
136
+ /** Outbox, debounced writes and the failed tray, wired by Sp00kyClient. */
137
+ private mutationSource: DevToolsMutationSource | null = null;
138
+ /**
139
+ * Mutations seen since a consumer attached, by id, oldest first. The outbox
140
+ * only knows what is still queued; this is what lets the panel show a write
141
+ * as synced or rolled back after it left.
142
+ */
143
+ private mutationHistory = new Map<string, MutationHistoryEntry>();
144
+ private static readonly MUTATIONS_CAP = 200;
145
+
146
+ setMutationSource(source: DevToolsMutationSource): void {
147
+ this.mutationSource = source;
148
+ }
149
+
150
+ /**
151
+ * The page's log buffer. It records whether or not anybody is watching (so
152
+ * a panel opened late still sees the boot); while a consumer is attached,
153
+ * new lines go out as small `SP00KY_LOGS` deltas, never inside the state
154
+ * push, which would re-serialize the whole buffer every time.
155
+ */
156
+ private logTap: LogTap | null = null;
157
+ /** `seq` of the newest line already pushed. */
158
+ private logCursor = 0;
159
+ private logTimer: ReturnType<typeof setTimeout> | null = null;
160
+ private lastLogPushAt = 0;
161
+
162
+ setLogTap(tap: LogTap): void {
163
+ this.logTap = tap;
164
+ this.logCursor = tap.head;
165
+ tap.subscribe(() => this.scheduleLogPush());
166
+ }
167
+
107
168
  setBlobInfoProvider(provider: () => BlobCacheInfo): void {
108
169
  this.blobInfoProvider = provider;
109
170
  }
@@ -152,6 +213,9 @@ export class DevToolsService implements StreamUpdateReceiver {
152
213
  const type = (e.data as { type?: string } | undefined)?.type;
153
214
  if (type === 'SP00KY_DEVTOOLS_CONNECT') {
154
215
  this.enabled = true;
216
+ // The consumer pulls the backlog itself (`logOp('read')`); pushes
217
+ // carry only what arrives after this point.
218
+ this.logCursor = this.logTap?.head ?? 0;
155
219
  this.refreshLocalTables();
156
220
  this.notifyDevTools();
157
221
  } else if (type === 'SP00KY_DEVTOOLS_DISCONNECT') {
@@ -266,73 +330,184 @@ export class DevToolsService implements StreamUpdateReceiver {
266
330
  return result;
267
331
  }
268
332
 
269
- public onQueryInitialized(payload: any) {
333
+ public onStreamUpdate(update: StreamUpdate) {
334
+ // A synthetic re-materialize is not an ingest (DataModule.scheduleRematerialize).
335
+ if (update.synthetic) return;
336
+ // Counts and timings, not the `localArray` itself: that is one [id, version]
337
+ // pair per row of the view. The Logs tab shows this line at debug.
270
338
  this.logger.debug(
271
- { payload, Category: 'sp00ky-client::DevToolsService::onQueryInitialized' },
272
- 'QueryInitialized'
339
+ {
340
+ queryHash: update.queryHash,
341
+ op: update.op,
342
+ localCount: update.localArray?.length ?? 0,
343
+ materializationTimeMs: update.materializationTimeMs,
344
+ storeApplyMs: update.storeApplyMs,
345
+ circuitStepMs: update.circuitStepMs,
346
+ transformMs: update.transformMs,
347
+ Category: 'sp00ky-client::DevToolsService::onStreamUpdate',
348
+ },
349
+ 'StreamUpdate'
273
350
  );
274
- const queryHash = this.hashString(payload.queryId.toString());
351
+ this.notifyDevTools();
352
+ }
275
353
 
276
- this.addEvent('QUERY_REQUEST_INIT', {
277
- queryHash,
278
- query: payload.sql,
279
- variables: {},
280
- });
354
+ public onMutation(payload: any[]) {
355
+ for (const p of payload) this.recordQueued(p);
281
356
  this.notifyDevTools();
282
357
  }
283
358
 
284
- public onQueryUpdated(payload: any) {
285
- this.logger.debug(
286
- {
287
- id: payload.queryId?.toString(),
288
- Category: 'sp00ky-client::DevToolsService::onQueryUpdated',
289
- },
290
- 'QueryUpdated'
291
- );
292
- const queryHash = this.hashString(payload.queryId.toString());
359
+ /** A write entered the outbox. */
360
+ private recordQueued(p: any): void {
361
+ if (!this.enabled || !p?.mutation_id) return;
362
+ const id = storedIdString(p.mutation_id);
363
+ const recordId = p.record_id ? encodeRecordId(p.record_id) : '';
364
+ this.mutationHistory.set(id, {
365
+ id,
366
+ op: (p.type ?? 'create') as MutationEventType,
367
+ recordId,
368
+ table: typeof p.tableName === 'string' ? p.tableName : recordId.split(':')[0],
369
+ fields: p.data && typeof p.data === 'object' ? Object.keys(p.data) : [],
370
+ queuedAt: queuedAtOf(id) || Date.now(),
371
+ });
372
+ trimHistory(this.mutationHistory, DevToolsService.MUTATIONS_CAP);
373
+ }
293
374
 
294
- this.addEvent('QUERY_UPDATED', {
295
- queryHash,
296
- recordCount: Array.isArray(payload.records) ? payload.records.length : 0,
375
+ /** The server answered a write: accepted (`synced`) or rejected (`rolled-back`). */
376
+ public onMutationOutcome(e: {
377
+ mutationId: string;
378
+ recordId: string;
379
+ eventType: string;
380
+ status: 'synced' | 'rolled-back';
381
+ error?: string;
382
+ }): void {
383
+ if (!this.enabled) return;
384
+ const prev = this.mutationHistory.get(e.mutationId);
385
+ this.mutationHistory.set(e.mutationId, {
386
+ ...(prev ?? {
387
+ id: e.mutationId,
388
+ op: e.eventType as MutationEventType,
389
+ recordId: e.recordId,
390
+ table: e.recordId.split(':')[0],
391
+ queuedAt: queuedAtOf(e.mutationId),
392
+ }),
393
+ outcome: { status: e.status, at: Date.now(), error: e.error },
297
394
  });
395
+ trimHistory(this.mutationHistory, DevToolsService.MUTATIONS_CAP);
298
396
  this.notifyDevTools();
299
397
  }
300
398
 
301
- public onStreamUpdate(update: StreamUpdate) {
302
- // A synthetic re-materialize is not an ingest (DataModule.scheduleRematerialize).
303
- if (update.synthetic) return;
304
- this.logger.debug(
305
- { queryHash: update.queryHash, Category: 'sp00ky-client::DevToolsService::onStreamUpdate' },
306
- 'StreamUpdate'
307
- );
308
- // Counts and timings, not the `localArray` itself: that is one [id, version]
309
- // pair per row of the view, serialized on every single update.
310
- this.addEvent('STREAM_UPDATE', {
311
- queryHash: update.queryHash,
312
- op: update.op,
313
- localCount: update.localArray?.length ?? 0,
314
- materializationTimeMs: update.materializationTimeMs,
315
- storeApplyMs: update.storeApplyMs,
316
- circuitStepMs: update.circuitStepMs,
317
- transformMs: update.transformMs,
318
- });
399
+ /** Something the panel shows moved (a query's status, the outbox, sync health): re-push. */
400
+ public onStateChanged(): void {
319
401
  this.notifyDevTools();
320
402
  }
321
403
 
322
- public onMutation(payload: any[]) {
323
- const payloads = payload;
324
- payloads.forEach((p) => {
325
- this.addEvent('MUTATION_REQUEST_EXECUTION', {
326
- mutation: {
327
- type: p.type ?? 'create',
328
- // Field names only; a payload body (a PGN, a document) is not
329
- // something to clone on every write.
330
- fields: 'data' in p && p.data && typeof p.data === 'object' ? Object.keys(p.data) : [],
331
- selector: encodeRecordId(p.record_id),
332
- },
333
- });
404
+ private mutationsState(): DevToolsMutationsState | null {
405
+ const source = this.mutationSource;
406
+ if (!source) return null;
407
+ const s = source.state();
408
+ return buildMutationsState({
409
+ outbox: s.outbox,
410
+ pendingWrites: s.pendingWrites.values(),
411
+ failedCount: s.failedCount,
412
+ tabRole: s.tabRole,
413
+ health: s.sync.health,
414
+ history: this.mutationHistory.values(),
415
+ cap: DevToolsService.MUTATIONS_CAP,
334
416
  });
335
- this.notifyDevTools();
417
+ }
418
+
419
+ /**
420
+ * The Mutations tab's on-demand half: the failed tray (a local read), one
421
+ * queued row's payload, and the tray's retry / discard.
422
+ */
423
+ public async mutationOp(op: MutationOp, args: Record<string, unknown>): Promise<MutationOpResult> {
424
+ const source = this.mutationSource;
425
+ if (!source) return { success: false, error: 'this client does not expose its mutations' };
426
+ const id = String(args.id ?? '');
427
+ try {
428
+ switch (op) {
429
+ case 'listFailed':
430
+ return this.serializeForDevTools({ success: true, failed: await source.listFailed() });
431
+ case 'get': {
432
+ const res = await this.databaseService.query<any>('SELECT * FROM $ids', {
433
+ ids: [parseStoredRecordId(id)],
434
+ });
435
+ const raw = Array.isArray(res?.[0]) ? res[0][0] : undefined;
436
+ return this.serializeForDevTools({ success: true, row: raw ? parsePendingRow(raw) : null });
437
+ }
438
+ case 'retry':
439
+ return (await source.retryFailed(id))
440
+ ? { success: true }
441
+ : { success: false, error: `${id} is not in the failed tray` };
442
+ case 'discard':
443
+ return (await source.discardFailed(id))
444
+ ? { success: true }
445
+ : { success: false, error: `${id} is not in the failed tray` };
446
+ case 'clearHistory':
447
+ this.mutationHistory.clear();
448
+ this.notifyDevTools();
449
+ return { success: true };
450
+ default:
451
+ return { success: false, error: `Unknown mutation op: ${String(op)}` };
452
+ }
453
+ } catch (e) {
454
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
455
+ }
456
+ }
457
+
458
+ /** The Logs tab: read the buffer, change what it records, empty it. */
459
+ public logOp(op: LogOp, args: Record<string, unknown>): LogOpResult {
460
+ const tap = this.logTap;
461
+ if (!tap) return { success: false, error: 'this client does not capture its logs' };
462
+ try {
463
+ switch (op) {
464
+ case 'read': {
465
+ const limit = typeof args.limit === 'number' ? args.limit : undefined;
466
+ return { success: true, ...tap.read(Number(args.after ?? 0), limit) };
467
+ }
468
+ case 'setCaptureLevel':
469
+ tap.setCaptureLevel(String(args.level) as Level);
470
+ this.notifyDevTools();
471
+ return { success: true, captureLevel: tap.captureLevel, consoleLevel: tap.consoleLevel };
472
+ case 'clear':
473
+ tap.clear();
474
+ return { success: true, head: tap.head };
475
+ default:
476
+ return { success: false, error: `Unknown log op: ${String(op)}` };
477
+ }
478
+ } catch (e) {
479
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
480
+ }
481
+ }
482
+
483
+ /** Same coalescing as {@link notifyDevTools}, for the log delta channel. */
484
+ private scheduleLogPush(): void {
485
+ if (!this.enabled || typeof window === 'undefined' || this.logTimer !== null) return;
486
+ const waited = Date.now() - this.lastLogPushAt;
487
+ const delay = Math.max(0, DevToolsService.NOTIFY_MIN_INTERVAL_MS - waited);
488
+ this.logTimer = setTimeout(() => {
489
+ this.logTimer = null;
490
+ if (this.enabled) this.flushLogs();
491
+ }, delay);
492
+ }
493
+
494
+ private flushLogs(): void {
495
+ const tap = this.logTap;
496
+ if (!tap) return;
497
+ this.lastLogPushAt = Date.now();
498
+ const delta = tap.read(this.logCursor);
499
+ this.logCursor = delta.head;
500
+ if (delta.entries.length === 0) return;
501
+ window.postMessage(
502
+ {
503
+ type: 'SP00KY_LOGS',
504
+ source: 'sp00ky-devtools-page',
505
+ entries: delta.entries,
506
+ head: delta.head,
507
+ dropped: delta.dropped,
508
+ },
509
+ '*'
510
+ );
336
511
  }
337
512
 
338
513
  private hashString(str: string): number {
@@ -346,23 +521,6 @@ export class DevToolsService implements StreamUpdateReceiver {
346
521
  return hash;
347
522
  }
348
523
 
349
- public logEvent(eventType: string, payload: any) {
350
- this.addEvent(eventType, payload);
351
- this.notifyDevTools();
352
- }
353
-
354
- private addEvent(eventType: string, payload: any) {
355
- // No consumer attached → skip recording (and the recursive serialize it does).
356
- if (!this.enabled) return;
357
- this.eventsHistory.push({
358
- id: this.eventIdCounter++,
359
- timestamp: Date.now(),
360
- eventType,
361
- payload: this.serializeForDevTools(payload),
362
- });
363
- if (this.eventsHistory.length > 100) this.eventsHistory.shift();
364
- }
365
-
366
524
  /** Unwrap a SurrealDB `INFO FOR DB` result to its `{ tables, ... }` object. */
367
525
  private unwrapInfo(res: any): any {
368
526
  if (!Array.isArray(res) || !res[0]) return null;
@@ -413,7 +571,10 @@ export class DevToolsService implements StreamUpdateReceiver {
413
571
  // the single local op queue.
414
572
  if (opts.refreshTables) this.refreshLocalTables();
415
573
  return this.serializeForDevTools({
416
- eventsHistory: [...this.eventsHistory],
574
+ // The event log is gone (the Logs and Mutations tabs carry what it did);
575
+ // still sent empty because panels before canary.291 read it to tell this
576
+ // state shape apart from their own.
577
+ eventsHistory: [],
417
578
  activeQueries: Object.fromEntries(this.getActiveQueries()),
418
579
  auth: {
419
580
  authenticated: this.authService.isAuthenticated,
@@ -447,6 +608,18 @@ export class DevToolsService implements StreamUpdateReceiver {
447
608
  // Shared-tabs role state (null when the feature is off / fell back).
448
609
  tabs: this.tabsInfoProvider?.() ?? null,
449
610
  },
611
+ // Outbox, debounced writes, tray count and recent outcomes. Small by
612
+ // construction (capped entries, field names only, never payloads).
613
+ mutations: this.mutationsState(),
614
+ // Log buffer metadata only; the lines travel as `SP00KY_LOGS` deltas and
615
+ // through `logOp('read')`.
616
+ logs: this.logTap
617
+ ? {
618
+ head: this.logTap.head,
619
+ consoleLevel: this.logTap.consoleLevel,
620
+ captureLevel: this.logTap.captureLevel,
621
+ }
622
+ : null,
450
623
  });
451
624
  }
452
625
 
@@ -701,6 +874,9 @@ export class DevToolsService implements StreamUpdateReceiver {
701
874
  // nothing here grants anything.
702
875
  impersonationOp: (op: ImpersonationOp, args?: Record<string, unknown>) =>
703
876
  this.impersonationOp(op, args ?? {}),
877
+ // ---- Mutations + Logs tabs ---------------------------------------
878
+ mutationOp: (op: MutationOp, args?: Record<string, unknown>) => this.mutationOp(op, args ?? {}),
879
+ logOp: (op: LogOp, args?: Record<string, unknown>) => this.logOp(op, args ?? {}),
704
880
  setFlagEnabled: (key: string, enabled: boolean) =>
705
881
  this.flagsAdmin.setFlagEnabled(key, enabled),
706
882
  setFlagUserVariant: (key: string, variant: string, remove: boolean, userId?: string) =>
@@ -708,10 +884,6 @@ export class DevToolsService implements StreamUpdateReceiver {
708
884
  setLocalFlagOverride: (key: string, variant: string | null, payload?: unknown) =>
709
885
  this.flagsAdmin.setLocalFlagOverride(key, variant, payload),
710
886
  clearLocalFlagOverrides: () => this.flagsAdmin.clearLocalFlagOverrides(),
711
- clearHistory: () => {
712
- this.eventsHistory = [];
713
- this.notifyDevTools();
714
- },
715
887
  refreshVersions: () => this.refreshBackendVersions(),
716
888
  getStorageInfo: (opts?: { tableCounts?: boolean }) => this.getStorageInfo(opts),
717
889
  requestPersistentStorage: () => this.requestPersistentStorage(),
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { OutboxItem, PendingWrite } from '../../state/client-state';
3
+ import { initialHealth } from '../../state/client-state';
4
+ import { buildMutationsState, trimHistory, type MutationHistoryEntry } from './mutations';
5
+
6
+ const id = (ts: number, n = 1) => `_00_pending_mutations:${String(ts).padStart(13, '0')}_${String(n).padStart(4, '0')}_tab`;
7
+
8
+ const item = (over: Partial<OutboxItem> & { id: string }): OutboxItem => ({
9
+ type: 'update',
10
+ recordId: 'thread:a',
11
+ table: 'thread',
12
+ status: 'pending',
13
+ ackedAt: null,
14
+ attempts: 0,
15
+ ...over,
16
+ });
17
+
18
+ const build = (over: Partial<Parameters<typeof buildMutationsState>[0]> = {}) =>
19
+ buildMutationsState({
20
+ outbox: [],
21
+ pendingWrites: [],
22
+ failedCount: 0,
23
+ tabRole: 'solo',
24
+ health: initialHealth('connected'),
25
+ history: [],
26
+ cap: 200,
27
+ ...over,
28
+ });
29
+
30
+ describe('buildMutationsState', () => {
31
+ it('reads status off the outbox, queued time off the id', () => {
32
+ const s = build({
33
+ outbox: [
34
+ item({ id: id(1000) }),
35
+ item({ id: id(2000), attempts: 3 }),
36
+ item({ id: id(3000), status: 'acked', ackedAt: 3500 }),
37
+ ],
38
+ });
39
+ expect(s.entries.map((e) => [e.queuedAt, e.status])).toEqual([
40
+ [3000, 'synced'],
41
+ [2000, 'retrying'],
42
+ [1000, 'pending'],
43
+ ]);
44
+ expect(s.entries[0].settledAt).toBe(3500);
45
+ expect(s.counts).toMatchObject({ pending: 1, retrying: 1, synced: 1, rolledBack: 0 });
46
+ });
47
+
48
+ it('keeps what left the outbox, and the live outbox wins over history', () => {
49
+ const history: MutationHistoryEntry[] = [
50
+ { id: id(1000), op: 'create', recordId: 'thread:a', table: 'thread', fields: ['title'], queuedAt: 1000, outcome: { status: 'synced', at: 1100 } },
51
+ { id: id(2000), op: 'update', recordId: 'thread:b', table: 'thread', fields: ['body'], queuedAt: 2000, outcome: { status: 'rolled-back', at: 2100, error: 'denied' } },
52
+ { id: id(3000), op: 'update', recordId: 'thread:c', table: 'thread', fields: ['x'], queuedAt: 3000 },
53
+ { id: id(4000), op: 'delete', recordId: 'thread:d', table: 'thread', queuedAt: 4000 },
54
+ ];
55
+ const s = build({ history, outbox: [item({ id: id(3000), recordId: 'thread:c', attempts: 1 })] });
56
+ const byId = new Map(s.entries.map((e) => [e.id, e]));
57
+ expect(byId.get(id(1000))).toMatchObject({ status: 'synced', settledAt: 1100, fields: ['title'] });
58
+ expect(byId.get(id(2000))).toMatchObject({ status: 'rolled-back', error: 'denied' });
59
+ expect(byId.get(id(3000))).toMatchObject({ status: 'retrying', attempts: 1, fields: ['x'] });
60
+ expect(byId.get(id(4000))).toMatchObject({ status: 'dropped' });
61
+ });
62
+
63
+ it('caps entries but counts and totals everything', () => {
64
+ const outbox = Array.from({ length: 5 }, (_, i) => item({ id: id(1000 + i) }));
65
+ const s = build({ outbox, cap: 2 });
66
+ expect(s.entries.map((e) => e.queuedAt)).toEqual([1004, 1003]);
67
+ expect(s.total).toBe(5);
68
+ expect(s.counts.pending).toBe(5);
69
+ });
70
+
71
+ it('lists debounced writes and carries role, health and tray count', () => {
72
+ const w: PendingWrite = { key: 'k', table: 'doc', recordId: 'doc:1', data: { title: 't', body: 'b' }, before: null, firstAt: 42, mirrorId: null };
73
+ const s = build({
74
+ pendingWrites: [w],
75
+ failedCount: 3,
76
+ tabRole: 'follower',
77
+ health: { ...initialHealth('reconnecting'), status: 'degraded', consecutiveFailures: 4, error: 'socket closed' },
78
+ });
79
+ expect(s.debounced).toEqual([{ key: 'k', recordId: 'doc:1', table: 'doc', fields: ['title', 'body'], since: 42, durable: false }]);
80
+ expect(s).toMatchObject({ role: 'follower', connection: 'reconnecting', health: 'degraded', consecutiveFailures: 4, lastError: 'socket closed' });
81
+ expect(s.counts).toMatchObject({ failed: 3, debounced: 1 });
82
+ });
83
+ });
84
+
85
+ describe('trimHistory', () => {
86
+ it('drops the oldest entries first', () => {
87
+ const h = new Map<string, MutationHistoryEntry>();
88
+ for (const n of [1, 2, 3]) h.set(`m${n}`, { id: `m${n}`, op: 'create', recordId: 'a:1', table: 'a', queuedAt: n });
89
+ trimHistory(h, 2);
90
+ expect([...h.keys()]).toEqual(['m2', 'm3']);
91
+ });
92
+ });