@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.
- package/dist/index.d.ts +4 -5
- package/dist/index.js +1462 -1046
- package/dist/types.d.ts +66 -1
- package/package.json +3 -3
- package/src/client/services.ts +6 -2
- package/src/client/sp00ky-client.test.ts +33 -1
- package/src/client/sp00ky-client.ts +52 -7
- package/src/kernel/events.ts +0 -1
- package/src/modules/devtools/index.ts +264 -92
- package/src/modules/devtools/mutations.test.ts +92 -0
- package/src/modules/devtools/mutations.ts +160 -0
- package/src/modules/devtools/notify-throttle.test.ts +34 -22
- package/src/modules/devtools/state-shape.test.ts +129 -15
- package/src/services/database/database.ts +3 -1
- package/src/services/logger/index.test.ts +132 -0
- package/src/services/logger/index.ts +187 -6
- package/src/testing/fake-services.ts +17 -3
|
@@ -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
|
|
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
|
|
93
|
-
//
|
|
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
|
-
|
|
121
|
+
service.onStateChanged();
|
|
110
122
|
vi.advanceTimersByTime(300);
|
|
111
123
|
expect(statePushes().length).toBe(before + 1);
|
|
112
124
|
|
|
113
|
-
//
|
|
125
|
+
// A request arriving right after that flush is still inside the window, so it
|
|
114
126
|
// queues rather than pushing again...
|
|
115
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
157
|
+
service.onStateChanged();
|
|
158
|
+
mutations.failedCount = 1;
|
|
159
|
+
service.onStateChanged();
|
|
160
|
+
mutations.failedCount = 2;
|
|
146
161
|
vi.advanceTimersByTime(300);
|
|
147
162
|
|
|
148
|
-
|
|
149
|
-
|
|
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.
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
expect(
|
|
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
|
-
|
|
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
|
+
});
|