@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.
- 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
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import type { LocalStore, RemoteDatabaseService } from '../../services/database/index';
|
|
2
|
-
import type {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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()
|
|
75
|
-
// work, so prod pays zero serialization/postMessage cost for an
|
|
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
|
|
83
|
-
// local DB query
|
|
84
|
-
// page load's
|
|
85
|
-
// of short-lived large-object garbage and
|
|
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
|
|
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
|
-
{
|
|
272
|
-
|
|
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
|
-
|
|
351
|
+
this.notifyDevTools();
|
|
352
|
+
}
|
|
275
353
|
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|