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

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,160 @@
1
+ import type { ConnectionState, MutationEventType, SyncHealth } from '../../types';
2
+ import type { OutboxItem, PendingWrite, TabRole } from '../../state/client-state';
3
+ import { createdAtFromId } from '../../mutation/rows';
4
+
5
+ /**
6
+ * Where a mutation is, as the DevTools Mutations tab shows it:
7
+ * - `pending` in the outbox, not sent yet (or sent and not answered)
8
+ * - `retrying` in the outbox after at least one failed push
9
+ * - `synced` the server accepted it
10
+ * - `rolled-back` the server rejected it; the local change was undone and the
11
+ * row moved to the failed-writes tray
12
+ * - `dropped` left the outbox without an answer this tab saw (a bucket
13
+ * switch, a sign-out, or its row was already gone)
14
+ */
15
+ export type DevToolsMutationStatus = 'pending' | 'retrying' | 'synced' | 'rolled-back' | 'dropped';
16
+
17
+ export interface DevToolsMutation {
18
+ /** `_00_pending_mutations:<ts>_<seq>_<tab>`, escaping removed. */
19
+ id: string;
20
+ op: MutationEventType;
21
+ recordId: string;
22
+ table: string;
23
+ /** Field names of the payload (the values stay in the page; see `mutationOp('get')`). */
24
+ fields?: string[];
25
+ status: DevToolsMutationStatus;
26
+ attempts: number;
27
+ queuedAt: number;
28
+ settledAt?: number;
29
+ error?: string;
30
+ }
31
+
32
+ /** What DevTools remembers about one mutation after it left the outbox. */
33
+ export interface MutationHistoryEntry {
34
+ id: string;
35
+ op: MutationEventType;
36
+ recordId: string;
37
+ table: string;
38
+ fields?: string[];
39
+ queuedAt: number;
40
+ outcome?: { status: 'synced' | 'rolled-back'; at: number; error?: string };
41
+ }
42
+
43
+ export interface DevToolsDebouncedWrite {
44
+ key: string;
45
+ recordId: string;
46
+ table: string;
47
+ fields: string[];
48
+ since: number;
49
+ /** Mirrored to `_00_pending_writes`, so a reload inside the delay keeps it. */
50
+ durable: boolean;
51
+ }
52
+
53
+ export interface DevToolsMutationsState {
54
+ /** `follower` tabs never drain: the leader pushes their writes. */
55
+ role: TabRole;
56
+ connection: ConnectionState;
57
+ health: SyncHealth['status'];
58
+ consecutiveFailures: number;
59
+ lastError?: string;
60
+ counts: {
61
+ pending: number;
62
+ retrying: number;
63
+ debounced: number;
64
+ /** Rows in the persistent failed-writes tray, any session. */
65
+ failed: number;
66
+ /** This session, since DevTools attached. */
67
+ synced: number;
68
+ rolledBack: number;
69
+ };
70
+ /** Newest first, capped at `cap`. */
71
+ entries: DevToolsMutation[];
72
+ total: number;
73
+ debounced: DevToolsDebouncedWrite[];
74
+ }
75
+
76
+ export interface MutationsInput {
77
+ outbox: ReadonlyArray<OutboxItem>;
78
+ pendingWrites: Iterable<PendingWrite>;
79
+ failedCount: number;
80
+ tabRole: TabRole;
81
+ health: SyncHealth;
82
+ history: Iterable<MutationHistoryEntry>;
83
+ cap: number;
84
+ }
85
+
86
+ /** When a mutation was queued, from the timestamp its id starts with (0 for a legacy id). */
87
+ export const queuedAtOf = (id: string): number => createdAtFromId(id) ?? 0;
88
+
89
+ /** The tab's mutation picture: the live outbox over what DevTools saw leave it. */
90
+ export function buildMutationsState(input: MutationsInput): DevToolsMutationsState {
91
+ const byId = new Map<string, DevToolsMutation>();
92
+ for (const h of input.history) {
93
+ byId.set(h.id, {
94
+ id: h.id,
95
+ op: h.op,
96
+ recordId: h.recordId,
97
+ table: h.table,
98
+ fields: h.fields,
99
+ status: h.outcome ? h.outcome.status : 'dropped',
100
+ attempts: 0,
101
+ queuedAt: h.queuedAt,
102
+ settledAt: h.outcome?.at,
103
+ error: h.outcome?.error,
104
+ });
105
+ }
106
+ for (const item of input.outbox) {
107
+ const seen = byId.get(item.id);
108
+ const status: DevToolsMutationStatus =
109
+ item.status === 'acked' ? 'synced' : item.attempts > 0 ? 'retrying' : 'pending';
110
+ byId.set(item.id, {
111
+ id: item.id,
112
+ op: item.type,
113
+ recordId: item.recordId,
114
+ table: item.table,
115
+ fields: seen?.fields,
116
+ status,
117
+ attempts: item.attempts,
118
+ queuedAt: seen?.queuedAt || queuedAtOf(item.id),
119
+ settledAt: item.ackedAt ?? seen?.settledAt,
120
+ });
121
+ }
122
+
123
+ const all = [...byId.values()].toSorted((a, b) => b.queuedAt - a.queuedAt || (a.id < b.id ? 1 : -1));
124
+ const count = (s: DevToolsMutationStatus) => all.filter((m) => m.status === s).length;
125
+ const debounced = [...input.pendingWrites].map((w) => ({
126
+ key: w.key,
127
+ recordId: w.recordId,
128
+ table: w.table,
129
+ fields: Object.keys(w.data),
130
+ since: w.firstAt,
131
+ durable: w.mirrorId !== null,
132
+ }));
133
+
134
+ return {
135
+ role: input.tabRole,
136
+ connection: input.health.connection,
137
+ health: input.health.status,
138
+ consecutiveFailures: input.health.consecutiveFailures,
139
+ lastError: input.health.error,
140
+ counts: {
141
+ pending: count('pending'),
142
+ retrying: count('retrying'),
143
+ debounced: debounced.length,
144
+ failed: input.failedCount,
145
+ synced: count('synced'),
146
+ rolledBack: count('rolled-back'),
147
+ },
148
+ entries: all.slice(0, input.cap),
149
+ total: all.length,
150
+ debounced,
151
+ };
152
+ }
153
+
154
+ /** Keep the newest `cap` entries of an insertion-ordered history map. */
155
+ export function trimHistory(history: Map<string, MutationHistoryEntry>, cap: number): void {
156
+ for (const id of history.keys()) {
157
+ if (history.size <= cap) return;
158
+ history.delete(id);
159
+ }
160
+ }
@@ -8,7 +8,7 @@ import { DevToolsService } from './index';
8
8
  * requested per event — including one per local DB query — so without
9
9
  * coalescing a page load's few hundred local queries turn a few MB of rows into
10
10
  * GBs of short-lived large-object garbage and OOM the renderer. These tests pin
11
- * the coalescing, not the payload.
11
+ * the coalescing, not the payload. `onStateChanged` stands in for any trigger.
12
12
  */
13
13
 
14
14
  function harness(recordCount = 500) {
@@ -60,12 +60,26 @@ function harness(recordCount = 500) {
60
60
  };
61
61
 
62
62
  const service = new DevToolsService(local, remote, logger, { tables: [] } as any, auth, dataManager);
63
+ // Whatever the next push should carry; tests move it between requests.
64
+ const mutations: any = {
65
+ outbox: [],
66
+ pendingWrites: new Map(),
67
+ failedCount: 0,
68
+ tabRole: 'solo',
69
+ sync: { health: { status: 'healthy', consecutiveFailures: 0, everConnected: true, connection: 'connected' } },
70
+ };
71
+ service.setMutationSource({
72
+ state: () => mutations,
73
+ listFailed: async () => [],
74
+ retryFailed: async () => false,
75
+ discardFailed: async () => false,
76
+ });
63
77
  // Announce a consumer, exactly like the extension's page-script does.
64
78
  for (const cb of listeners) {
65
79
  cb({ source: fakeWindow, data: { type: 'SP00KY_DEVTOOLS_CONNECT' } });
66
80
  }
67
81
  const statePushes = () => posted.filter((m) => m.type === 'SP00KY_STATE_CHANGED');
68
- return { service, statePushes, posted };
82
+ return { service, statePushes, posted, mutations };
69
83
  }
70
84
 
71
85
  afterEach(() => {
@@ -84,16 +98,14 @@ describe('DevToolsService state-push coalescing', () => {
84
98
  expect(statePushes().length).toBe(1);
85
99
  });
86
100
 
87
- it('collapses a burst of per-query events into ONE trailing push', () => {
101
+ it('collapses a burst of change requests into ONE trailing push', () => {
88
102
  vi.useFakeTimers();
89
103
  const { service, statePushes } = harness();
90
104
  const before = statePushes().length;
91
105
 
92
- // What a page load looks like: hundreds of LOCAL_QUERY events, each of which
93
- // used to serialize the entire query state.
94
- for (let i = 0; i < 400; i++) {
95
- (service as any).logEvent('LOCAL_QUERY', { query: 'SELECT * FROM game', vars: {} });
96
- }
106
+ // What a page load looks like: hundreds of requests, each of which used to
107
+ // serialize the entire query state.
108
+ for (let i = 0; i < 400; i++) service.onStateChanged();
97
109
  // Nothing extra yet — the burst is queued, not serialized 400 times.
98
110
  expect(statePushes().length).toBe(before);
99
111
 
@@ -106,21 +118,21 @@ describe('DevToolsService state-push coalescing', () => {
106
118
  const { service, statePushes } = harness();
107
119
  const before = statePushes().length;
108
120
 
109
- (service as any).logEvent('A', {});
121
+ service.onStateChanged();
110
122
  vi.advanceTimersByTime(300);
111
123
  expect(statePushes().length).toBe(before + 1);
112
124
 
113
- // An event arriving right after that flush is still inside the window, so it
125
+ // A request arriving right after that flush is still inside the window, so it
114
126
  // queues rather than pushing again...
115
- (service as any).logEvent('B', {});
127
+ service.onStateChanged();
116
128
  expect(statePushes().length).toBe(before + 1);
117
129
  vi.advanceTimersByTime(300);
118
130
  expect(statePushes().length).toBe(before + 2);
119
131
 
120
- // ...but once the tab has been idle past the window, the next event pushes
132
+ // ...but once the tab has been idle past the window, the next request pushes
121
133
  // straight away, so the panel never waits on a quiet app.
122
134
  vi.advanceTimersByTime(1000);
123
- (service as any).logEvent('C', {});
135
+ service.onStateChanged();
124
136
  vi.advanceTimersByTime(0);
125
137
  expect(statePushes().length).toBe(before + 3);
126
138
  });
@@ -130,7 +142,7 @@ describe('DevToolsService state-push coalescing', () => {
130
142
  const { service, statePushes, posted } = harness();
131
143
  const before = statePushes().length;
132
144
 
133
- (service as any).logEvent('A', {});
145
+ service.onStateChanged();
134
146
  (service as any).enabled = false; // panel closed while the push was queued
135
147
  vi.advanceTimersByTime(300);
136
148
  expect(statePushes().length).toBe(before);
@@ -139,16 +151,16 @@ describe('DevToolsService state-push coalescing', () => {
139
151
 
140
152
  it('serializes the LATEST state at flush time, not at request time', () => {
141
153
  vi.useFakeTimers();
142
- const { service, statePushes } = harness();
154
+ const { service, statePushes, mutations } = harness();
155
+ vi.advanceTimersByTime(300);
143
156
 
144
- (service as any).logEvent('FIRST', {});
145
- (service as any).logEvent('SECOND', {});
157
+ service.onStateChanged();
158
+ mutations.failedCount = 1;
159
+ service.onStateChanged();
160
+ mutations.failedCount = 2;
146
161
  vi.advanceTimersByTime(300);
147
162
 
148
- const last = statePushes().at(-1);
149
- const types = last.state.eventsHistory.map((e: any) => e.eventType);
150
- // Both events of the coalesced window are present in the single push.
151
- expect(types).toContain('FIRST');
152
- expect(types).toContain('SECOND');
163
+ // One push for the window, carrying what was true when it flushed.
164
+ expect(statePushes().at(-1).state.mutations.counts.failed).toBe(2);
153
165
  });
154
166
  });
@@ -1,6 +1,13 @@
1
1
  import { describe, it, expect, vi, afterEach } from 'vitest';
2
2
  import { RecordId } from 'surrealdb';
3
3
  import { DevToolsService } from './index';
4
+ import { initialHealth } from '../../state/client-state';
5
+ import { LogTap, createLogger } from '../../services/logger/index';
6
+
7
+ // Vitest runs in node, where `pino` resolves to its node build and ignores the
8
+ // `browser` options the tap hangs off. Apps bundle the browser build.
9
+ // The specifier is widened to `string`: pino ships no types for that entry.
10
+ vi.mock('pino', async () => ({ default: (await import('pino/browser.js' as string)).default }));
4
11
 
5
12
  /**
6
13
  * The pushed state used to carry every view's full record set (and both
@@ -58,7 +65,7 @@ function harness(recordCount = 500) {
58
65
  const service = new DevToolsService(local, remote, logger, { tables: [] } as any, auth, dataManager);
59
66
  for (const cb of listeners) cb({ source: fakeWindow, data: { type: 'SP00KY_DEVTOOLS_CONNECT' } });
60
67
  const statePushes = () => posted.filter((m) => m.type === 'SP00KY_STATE_CHANGED');
61
- return { service, statePushes, posted, fakeWindow, infoQueries };
68
+ return { service, statePushes, posted, fakeWindow, infoQueries, logger };
62
69
  }
63
70
 
64
71
  describe('DevTools pushed state shape', () => {
@@ -70,7 +77,7 @@ describe('DevTools pushed state shape', () => {
70
77
  it('carries counts and capped ids, never the rows', async () => {
71
78
  vi.useFakeTimers();
72
79
  const { service, statePushes } = harness(500);
73
- service.onQueryUpdated({ queryId: 'x', records: [] });
80
+ service.onStateChanged();
74
81
  await vi.advanceTimersByTimeAsync(300);
75
82
  const push = statePushes().at(-1);
76
83
  expect(push).toBeDefined();
@@ -108,19 +115,20 @@ describe('DevTools pushed state shape', () => {
108
115
  expect(fakeWindow.__00__.getQueryRows(12345)).toBeNull();
109
116
  });
110
117
 
111
- // Flaky under full-suite load only (the push that carries the event
112
- // occasionally lands before the event is recorded); passes in isolation.
113
- // DevTools moves to state-derived reads in a later commit; retried until then.
114
- it('records a stream update as counts, not the membership array', { retry: 3 }, async () => {
115
- vi.useFakeTimers();
116
- const { service, statePushes } = harness(2);
117
- service.onStreamUpdate({ queryHash: 'q1', localArray: [['a', 1], ['b', 1]], op: 'UPDATE' });
118
- await vi.advanceTimersByTimeAsync(300);
119
- const push = statePushes().at(-1);
120
- const ev = push.state.eventsHistory.find((e: any) => e.eventType === 'STREAM_UPDATE');
121
- expect(ev.payload.localCount).toBe(2);
122
- expect(ev.payload.updates).toBeUndefined();
123
- expect(ev.payload.localArray).toBeUndefined();
118
+ it('logs a stream update as counts and timings, never the membership array', () => {
119
+ const { service, logger } = harness(2);
120
+ const debug = vi.spyOn(logger, 'debug');
121
+ service.onStreamUpdate({ queryHash: 'q1', localArray: [['a', 1], ['b', 1]], op: 'UPDATE', storeApplyMs: 1.5 });
122
+ const [fields, msg] = debug.mock.calls.at(-1) as [any, string];
123
+ expect(msg).toBe('StreamUpdate');
124
+ expect(fields).toMatchObject({ queryHash: 'q1', op: 'UPDATE', localCount: 2, storeApplyMs: 1.5 });
125
+ expect(fields.localArray).toBeUndefined();
126
+ });
127
+
128
+ it('no longer keeps an event log, but still sends the field older panels key on', () => {
129
+ const { fakeWindow } = harness(1);
130
+ expect(fakeWindow.__00__.getState().eventsHistory).toEqual([]);
131
+ expect(fakeWindow.__00__.clearHistory).toBeUndefined();
124
132
  });
125
133
 
126
134
  it('ignores a synthetic re-materialize', async () => {
@@ -147,3 +155,109 @@ describe('DevTools pushed state shape', () => {
147
155
  expect(infoQueries.filter((q) => q.includes('INFO FOR DB')).length).toBe(connectRefreshes + 1);
148
156
  });
149
157
  });
158
+
159
+ describe('DevTools mutations and logs', () => {
160
+ afterEach(() => {
161
+ vi.restoreAllMocks();
162
+ vi.unstubAllGlobals();
163
+ vi.useRealTimers();
164
+ });
165
+
166
+ const mutationState = (): any => ({
167
+ outbox: [
168
+ {
169
+ id: '_00_pending_mutations:0000000001000_0001_t',
170
+ type: 'create',
171
+ recordId: 'thread:a',
172
+ table: 'thread',
173
+ status: 'pending',
174
+ ackedAt: null,
175
+ attempts: 0,
176
+ },
177
+ ],
178
+ pendingWrites: new Map(),
179
+ failedCount: 1,
180
+ tabRole: 'solo',
181
+ sync: { health: initialHealth('connected') },
182
+ });
183
+
184
+ it('pushes the outbox and what already left it', async () => {
185
+ vi.useFakeTimers();
186
+ const { service, statePushes } = harness(0);
187
+ service.setMutationSource({
188
+ state: mutationState,
189
+ listFailed: async () => [],
190
+ retryFailed: async () => true,
191
+ discardFailed: async () => false,
192
+ });
193
+ service.onMutation([
194
+ {
195
+ type: 'update',
196
+ mutation_id: new RecordId('_00_pending_mutations', '0000000002000_0002_t'),
197
+ record_id: new RecordId('thread', 'b'),
198
+ data: { title: 'a very long payload the push must not carry' },
199
+ tableName: 'thread',
200
+ },
201
+ ]);
202
+ service.onMutationOutcome({
203
+ mutationId: '_00_pending_mutations:0000000002000_0002_t',
204
+ recordId: 'thread:b',
205
+ eventType: 'update',
206
+ status: 'rolled-back',
207
+ error: 'denied',
208
+ });
209
+ await vi.advanceTimersByTimeAsync(300);
210
+ const m = statePushes().at(-1).state.mutations;
211
+ expect(m.counts).toMatchObject({ pending: 1, rolledBack: 1, failed: 1 });
212
+ expect(m.entries[0]).toMatchObject({
213
+ recordId: 'thread:b',
214
+ status: 'rolled-back',
215
+ error: 'denied',
216
+ fields: ['title'],
217
+ queuedAt: 2000,
218
+ });
219
+ expect(JSON.stringify(m)).not.toContain('very long payload');
220
+ });
221
+
222
+ it('answers the tray ops, with a reason when the row is gone', async () => {
223
+ const { service, fakeWindow } = harness(0);
224
+ service.setMutationSource({
225
+ state: mutationState,
226
+ listFailed: async () => [],
227
+ retryFailed: async () => true,
228
+ discardFailed: async () => false,
229
+ });
230
+ expect(await fakeWindow.__00__.mutationOp('retry', { id: 'm1' })).toEqual({ success: true });
231
+ expect(await fakeWindow.__00__.mutationOp('discard', { id: 'm1' })).toMatchObject({
232
+ success: false,
233
+ error: expect.stringContaining('not in the failed tray'),
234
+ });
235
+ expect(await fakeWindow.__00__.mutationOp('listFailed')).toEqual({ success: true, failed: [] });
236
+ });
237
+
238
+ it('pushes new log lines as deltas and keeps them out of the state', async () => {
239
+ vi.useFakeTimers();
240
+ vi.spyOn(console, 'log').mockImplementation(() => {});
241
+ const { service, posted, fakeWindow } = harness(0);
242
+ const tap = new LogTap('info');
243
+ const logger = createLogger('info', undefined, tap);
244
+ logger.info('before attach');
245
+ service.setLogTap(tap);
246
+ logger.warn('one');
247
+ logger.info('two');
248
+ await vi.advanceTimersByTimeAsync(300);
249
+ const pushes = posted.filter((m) => m.type === 'SP00KY_LOGS');
250
+ expect(pushes).toHaveLength(1);
251
+ expect(pushes[0].entries.map((e: any) => e.msg)).toEqual(['one', 'two']);
252
+ expect(fakeWindow.__00__.getState().logs).toEqual({ head: 3, consoleLevel: 'info', captureLevel: 'info' });
253
+ const backlog = fakeWindow.__00__.logOp('read', {});
254
+ expect(backlog.entries.map((e: any) => e.msg)).toEqual(['before attach', 'one', 'two']);
255
+ expect(fakeWindow.__00__.logOp('setCaptureLevel', { level: 'debug' })).toMatchObject({
256
+ success: true,
257
+ captureLevel: 'debug',
258
+ });
259
+ logger.debug('now captured');
260
+ expect(fakeWindow.__00__.logOp('read', { after: 3 }).entries.map((e: any) => e.msg)).toEqual(['now captured']);
261
+ expect(fakeWindow.__00__.logOp('setCaptureLevel', { level: 'loud' }).success).toBe(false);
262
+ });
263
+ });
@@ -133,7 +133,9 @@ export abstract class AbstractDatabaseService {
133
133
  await this.beforeQuery();
134
134
  const startTime = performance.now();
135
135
  try {
136
- this.logger.debug(
136
+ // Trace: the finished statement is logged at debug by the client,
137
+ // from the query event below. This line is for a statement that hangs.
138
+ this.logger.trace(
137
139
  { query, vars, Category: 'sp00ky-client::Database::query' },
138
140
  'Executing query'
139
141
  );
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { LogTap, createLogger, stringifyLog } from './index';
3
+
4
+ // Vitest runs in node, where `pino` resolves to its node build and ignores the
5
+ // `browser` options the tap hangs off. Apps bundle the browser build.
6
+ // The specifier is widened to `string`: pino ships no types for that entry.
7
+ vi.mock('pino', async () => ({ default: (await import('pino/browser.js' as string)).default }));
8
+
9
+ afterEach(() => {
10
+ vi.restoreAllMocks();
11
+ vi.unstubAllGlobals();
12
+ });
13
+
14
+ function silentConsole() {
15
+ return vi.spyOn(console, 'log').mockImplementation(() => {});
16
+ }
17
+
18
+ describe('LogTap', () => {
19
+ it('records what the logger writes, child bindings included', () => {
20
+ silentConsole();
21
+ const tap = new LogTap('info');
22
+ const logger = createLogger('info', undefined, tap);
23
+ logger.child({ service: 'Database' }).warn({ Category: 'x' }, 'slow query');
24
+ logger.debug('not at this level');
25
+ const { entries, head } = tap.read();
26
+ expect(entries).toHaveLength(1);
27
+ expect(head).toBe(1);
28
+ expect(entries[0]).toMatchObject({ seq: 1, level: 40, msg: 'slow query' });
29
+ expect(JSON.parse(entries[0].line)).toMatchObject({ service: 'Database', Category: 'x' });
30
+ });
31
+
32
+ it('captures below the console level without printing it, on existing children too', () => {
33
+ const printed = silentConsole();
34
+ const tap = new LogTap('info');
35
+ const logger = createLogger('info', undefined, tap);
36
+ const child = logger.child({ service: 'Sync' });
37
+ child.debug('before');
38
+ tap.setCaptureLevel('debug');
39
+ child.debug('after');
40
+ logger.info('shown');
41
+ expect(tap.read().entries.map((e) => e.msg)).toEqual(['after', 'shown']);
42
+ expect(printed).toHaveBeenCalledTimes(1);
43
+ expect(String(printed.mock.calls[0][0])).toContain('shown');
44
+ });
45
+
46
+ it('keeps the capture level for the tab session', () => {
47
+ const store = new Map<string, string>();
48
+ vi.stubGlobal('sessionStorage', {
49
+ getItem: (k: string) => store.get(k) ?? null,
50
+ setItem: (k: string, v: string) => void store.set(k, v),
51
+ removeItem: (k: string) => void store.delete(k),
52
+ });
53
+ new LogTap('info').setCaptureLevel('trace');
54
+ expect(new LogTap('info').captureLevel).toBe('trace');
55
+ new LogTap('info').setCaptureLevel('info');
56
+ expect(store.size).toBe(0);
57
+ });
58
+
59
+ it('is a bounded ring that reports what a slow reader missed', () => {
60
+ silentConsole();
61
+ const tap = new LogTap('info');
62
+ const logger = createLogger('info', undefined, tap);
63
+ for (let i = 0; i < LogTap.CAPACITY + 10; i++) logger.info(`line ${i}`);
64
+ const all = tap.read();
65
+ expect(all.entries).toHaveLength(LogTap.CAPACITY);
66
+ expect(all.dropped).toBe(10);
67
+ const delta = tap.read(all.head - 2);
68
+ expect(delta.entries.map((e) => e.msg)).toEqual([`line ${LogTap.CAPACITY + 8}`, `line ${LogTap.CAPACITY + 9}`]);
69
+ expect(delta.dropped).toBe(0);
70
+ });
71
+
72
+ it('does not count cleared lines as dropped', () => {
73
+ silentConsole();
74
+ const tap = new LogTap('info');
75
+ const logger = createLogger('info', undefined, tap);
76
+ logger.info('a');
77
+ logger.info('b');
78
+ tap.clear();
79
+ logger.info('c');
80
+ expect(tap.read(0)).toMatchObject({ dropped: 0, head: 3 });
81
+ expect(tap.read(0).entries.map((e) => e.msg)).toEqual(['c']);
82
+ });
83
+
84
+ it('caps a huge line', () => {
85
+ silentConsole();
86
+ const tap = new LogTap('info');
87
+ createLogger('info', undefined, tap).info({ blob: 'x'.repeat(LogTap.LINE_MAX * 2) }, 'big');
88
+ const [entry] = tap.read().entries;
89
+ expect(entry.line.length).toBe(LogTap.LINE_MAX + 1);
90
+ expect(entry.msg).toBe('big');
91
+ });
92
+
93
+ it('notifies listeners and survives one that throws', () => {
94
+ silentConsole();
95
+ const tap = new LogTap('info');
96
+ const logger = createLogger('info', undefined, tap);
97
+ const seen: number[] = [];
98
+ tap.subscribe(() => {
99
+ throw new Error('listener bug');
100
+ });
101
+ tap.subscribe(() => seen.push(tap.head));
102
+ expect(() => logger.info('x')).not.toThrow();
103
+ expect(seen).toEqual([1]);
104
+ });
105
+
106
+ it('keeps the otel transmit at the configured level when capture goes lower', () => {
107
+ silentConsole();
108
+ const sent: string[] = [];
109
+ const tap = new LogTap('info');
110
+ const logger = createLogger('info', { send: (level) => void sent.push(level) }, tap);
111
+ tap.setCaptureLevel('debug');
112
+ logger.debug('d');
113
+ logger.info('i');
114
+ expect(sent).toEqual(['info']);
115
+ });
116
+ });
117
+
118
+ describe('stringifyLog', () => {
119
+ it('keeps errors readable and never throws on bigints or cycles', () => {
120
+ const o: Record<string, unknown> = { err: new Error('boom'), n: 10n };
121
+ o.self = o;
122
+ const parsed = JSON.parse(stringifyLog(o));
123
+ expect(parsed.err).toMatchObject({ type: 'Error', message: 'boom' });
124
+ expect(parsed.n).toBe('10');
125
+ expect(parsed.self).toBe('[Circular]');
126
+ });
127
+
128
+ it('does not mistake a shared reference for a cycle', () => {
129
+ const shared = { a: 1 };
130
+ expect(JSON.parse(stringifyLog({ x: shared, y: shared }))).toEqual({ x: { a: 1 }, y: { a: 1 } });
131
+ });
132
+ });