@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
package/dist/types.d.ts
CHANGED
|
@@ -483,6 +483,71 @@ type LocalEngineChoice = 'surrealdb' | 'sqlite' | LocalStore;
|
|
|
483
483
|
//#endregion
|
|
484
484
|
//#region src/services/logger/index.d.ts
|
|
485
485
|
type Logger$1 = Logger;
|
|
486
|
+
/** One line the logger wrote, as the DevTools Logs tab and the MCP read it. */
|
|
487
|
+
interface CapturedLog {
|
|
488
|
+
/** Monotonic for the page's lifetime; a reader resumes after the last one it holds. */
|
|
489
|
+
seq: number;
|
|
490
|
+
time: number;
|
|
491
|
+
/** Pino's numeric level: 10 trace … 60 fatal. */
|
|
492
|
+
level: number;
|
|
493
|
+
msg: string;
|
|
494
|
+
/** The whole log object as JSON (what the console printed), capped at {@link LogTap.LINE_MAX}. */
|
|
495
|
+
line: string;
|
|
496
|
+
}
|
|
497
|
+
interface LogRead {
|
|
498
|
+
entries: CapturedLog[];
|
|
499
|
+
/** `seq` of the newest captured line; pass it back as `after` to resume. */
|
|
500
|
+
head: number;
|
|
501
|
+
/** Lines after `after` the ring overwrote before they were read (cleared ones excluded). */
|
|
502
|
+
dropped: number;
|
|
503
|
+
consoleLevel: Level;
|
|
504
|
+
captureLevel: Level;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* JSON for a log object that can never throw into the call site: Errors keep
|
|
508
|
+
* their message and stack (plain `JSON.stringify` prints `{}`), bigints become
|
|
509
|
+
* strings, and a cycle prints `[Circular]` instead of throwing.
|
|
510
|
+
*/
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* The page-side log buffer behind the DevTools Logs tab. Always on and
|
|
514
|
+
* bounded: a line costs the JSON the console write produces anyway, and a
|
|
515
|
+
* panel opened after boot still sees the boot.
|
|
516
|
+
*
|
|
517
|
+
* It also owns the logger's level. The console prints at the configured level
|
|
518
|
+
* only; `setCaptureLevel` changes what is RECORDED (debug without flooding the
|
|
519
|
+
* console), live on every child logger, and keeps it for this tab's session so
|
|
520
|
+
* a reload captures its boot at that level too.
|
|
521
|
+
*/
|
|
522
|
+
declare class LogTap {
|
|
523
|
+
readonly consoleLevel: Level;
|
|
524
|
+
static readonly CAPACITY = 500;
|
|
525
|
+
static readonly LINE_MAX = 4000;
|
|
526
|
+
private readonly ring;
|
|
527
|
+
private seq;
|
|
528
|
+
/** `seq` at the last `clear()`: lines up to here are gone on purpose, not dropped. */
|
|
529
|
+
private clearedThrough;
|
|
530
|
+
private readonly listeners;
|
|
531
|
+
private readonly loggers;
|
|
532
|
+
private captureLevelValue;
|
|
533
|
+
constructor(consoleLevel: Level);
|
|
534
|
+
/** `seq` of the newest captured line. */
|
|
535
|
+
get head(): number;
|
|
536
|
+
get captureLevel(): Level;
|
|
537
|
+
/** What pino has to produce for both the console and the buffer. */
|
|
538
|
+
get effectiveLevel(): Level;
|
|
539
|
+
/** Whether the console prints a line at this numeric level. */
|
|
540
|
+
printsToConsole(level: number): boolean;
|
|
541
|
+
/** Remember a logger (the root and every child) so a level change reaches it. */
|
|
542
|
+
track(logger: Logger$1): void;
|
|
543
|
+
setCaptureLevel(level: Level): void;
|
|
544
|
+
record(o: Record<string, unknown>, line: string): void;
|
|
545
|
+
/** Lines after `after` (0 = everything still buffered), oldest first. */
|
|
546
|
+
read(after?: number, limit?: number): LogRead;
|
|
547
|
+
clear(): void;
|
|
548
|
+
/** Called synchronously inside the log call: keep it cheap, never log from it. */
|
|
549
|
+
subscribe(cb: () => void): () => void;
|
|
550
|
+
}
|
|
486
551
|
//#endregion
|
|
487
552
|
//#region src/services/database/database.d.ts
|
|
488
553
|
declare abstract class AbstractDatabaseService {
|
|
@@ -2018,4 +2083,4 @@ interface DebounceOptions {
|
|
|
2018
2083
|
flushOnHide?: boolean;
|
|
2019
2084
|
}
|
|
2020
2085
|
//#endregion
|
|
2021
|
-
export { WebPushSubscribeOptions as $, RunOptions as A,
|
|
2086
|
+
export { WebPushSubscribeOptions as $, RunOptions as A, Logger$1 as At, SyncHealthStatus as B, SESSION_POLL_MS as Bt, QueryTimeToLive as C, SignOutHook as Ct, RecordVersionArray as D, createAuthEventSystem as Dt, ReconnectConfig as E, AuthEventTypes as Et, StorageHealth as F, IMPERSONATION_ACCESS as Ft, WebPushConfig as G, sameImpersonation as Gt, UpdateOptions as H, decodeTokenClaims as Ht, StorageHealthStatus as I, ImpersonationInfo as It, WebPushErrorCode as J, WebPushDevice as K, StoreType as L, ImpersonationTimerHost as Lt, Sp00kyConfig as M, SealedQuery as Mt, Sp00kyQueryResult as N, EventDefinition as Nt, RecordVersionDiff as O, RemoteDatabaseService as Ot, Sp00kyQueryResultPromise as P, EventSystem as Pt, WebPushModule as Q, SyncHealth as R, ImpersonationTimers as Rt, QueryStatusCallback as S, SignOutContext as St, QueryUpdateCallback as T, AuthEventTypeMap as Tt, PUSH_ENDPOINT_KEY as U, impersonationFromToken as Ut, TimingPhase as V, TokenClaims as Vt, PushDeviceKind as W, renewDelayMs as Wt, WebPushMessage as X, WebPushInfo as Y, WebPushMessageInput as Z, QueryConfig as _, isPushPayload as _t, Level$1 as a, WebPushUpdateOptions as at, QueryState as b, ImpersonationCandidate as bt, MutationCallback as c, BridgeTokenMessage as ct, PersistenceClient as d, PushNotificationAction as dt, WebPushSupport as et, PhaseStat as f, PushOp as ft, QueryAuthorityCallback as g, WebPushClientMessage as gt, PreloadRefresh as h, PushUrgency as ht, InlineRow as i, WebPushUnsupportedReason as it, ServerViewMeta as j, LocalStore as jt, RegistrationTimings as k, LogTap as kt, MutationEvent as l, PUSH_PAYLOAD_VERSION as lt, PreloadOptions as m, PushPayloadKind as mt, DebounceOptions as n, WebPushSyncStatus as nt, MATERIALIZATION_SAMPLE_WINDOW as o, detectWebPushSupport as ot, PinoTransmit as p, PushPayload as pt, WebPushError as q, EventSubscriptionOptions as r, WebPushUnsubscribeOptions as rt, MembershipOutcome as s, BridgeSignOutMessage as st, ConnectionState as t, WebPushSyncOptions as tt, MutationEventType as u, PushNotification as ut, QueryConfigRecord as v, ActiveImpersonation as vt, QueryTimings as w, AuthEventSystem as wt, QueryStatus as x, SIGN_OUT_HOOK_TIMEOUT_MS as xt, QueryHash as y, AuthService as yt, SyncHealthConfig as z, ORIGIN_TOKEN_KEY as zt };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.291",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -75,8 +75,8 @@
|
|
|
75
75
|
}
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
79
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
78
|
+
"@spooky-sync/query-builder": "0.0.1-canary.291",
|
|
79
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.291",
|
|
80
80
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
81
81
|
"@surrealdb/wasm": "^3.0.3",
|
|
82
82
|
"blurhash": "^2.0.5",
|
package/src/client/services.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Uuid } from 'surrealdb';
|
|
|
2
2
|
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
3
3
|
import type { Sp00kyConfig, PersistenceClient } from '../types';
|
|
4
4
|
import type { Logger } from '../services/logger/index';
|
|
5
|
-
import { createLogger } from '../services/logger/index';
|
|
5
|
+
import { LogTap, createLogger } from '../services/logger/index';
|
|
6
6
|
import { ConnectionSupervisor, LocalMigrator, RemoteDatabaseService, createLocalEngine } from '../services/database/index';
|
|
7
7
|
import type { LocalStore } from '../services/database/index';
|
|
8
8
|
import { StreamProcessorService } from '../services/stream-processor/index';
|
|
@@ -63,6 +63,8 @@ export function mintSalt(): string {
|
|
|
63
63
|
/** Everything the facade talks to besides the runtime. */
|
|
64
64
|
export interface Services<S extends SchemaStructure> {
|
|
65
65
|
logger: Logger;
|
|
66
|
+
/** What the logger wrote, for the DevTools Logs tab. Absent in test fakes. */
|
|
67
|
+
logTap?: LogTap;
|
|
66
68
|
local: LocalStore;
|
|
67
69
|
remote: RemoteDatabaseService;
|
|
68
70
|
connectionSupervisor: ConnectionSupervisor;
|
|
@@ -89,7 +91,8 @@ export interface ServiceHost {
|
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
export function createServices<S extends SchemaStructure>(config: Sp00kyConfig<S>): Services<S> {
|
|
92
|
-
const
|
|
94
|
+
const logTap = new LogTap(config.logLevel ?? 'info');
|
|
95
|
+
const logger = createLogger(config.logLevel ?? 'info', config.otelTransmit, logTap);
|
|
93
96
|
if (config.crdt) void preloadLoro();
|
|
94
97
|
const tabsSupport = detectSharedTabsSupport(config);
|
|
95
98
|
const local = createLocalEngine(config.localEngine, config.database, logger, { shared: tabsSupport.supported });
|
|
@@ -119,6 +122,7 @@ export function createServices<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
119
122
|
const tabId = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tab_${Math.random().toString(36).slice(2)}`;
|
|
120
123
|
return {
|
|
121
124
|
logger,
|
|
125
|
+
logTap,
|
|
122
126
|
local,
|
|
123
127
|
remote,
|
|
124
128
|
connectionSupervisor,
|
|
@@ -34,6 +34,36 @@ function makeClient(over: Parameters<typeof fakeAdapters>[0] = {}) {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
describe('Sp00kyClient facade', () => {
|
|
37
|
+
it('hands DevTools the outbox and the failed tray', async () => {
|
|
38
|
+
const { client } = makeClient();
|
|
39
|
+
await client.init();
|
|
40
|
+
const source = (client as any).devTools.mutationSource;
|
|
41
|
+
expect(source.state()).toBe(client.state);
|
|
42
|
+
expect(await source.listFailed()).toEqual([]);
|
|
43
|
+
expect(await source.retryFailed('_00_pending_mutations:gone')).toBe(false);
|
|
44
|
+
expect(await source.discardFailed('_00_pending_mutations:gone')).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('logs each finished statement at debug, and nothing below it', () => {
|
|
48
|
+
const { services } = makeClient();
|
|
49
|
+
const lines: unknown[] = [];
|
|
50
|
+
const log = services.logger as any;
|
|
51
|
+
log.debug = (fields: unknown, msg: string) => lines.push([fields, msg]);
|
|
52
|
+
log.isLevelEnabled = () => true;
|
|
53
|
+
const long = `SELECT * FROM thing WHERE ${'a = 1 AND '.repeat(30)}b = 2`;
|
|
54
|
+
services.emitStoreEvent('DATABASE_LOCAL_QUERY', { query: 'SELECT\n 1', vars: {}, duration: 1.234, success: true });
|
|
55
|
+
services.emitStoreEvent('DATABASE_REMOTE_QUERY', { query: long, duration: 3, success: false, error: 'boom' });
|
|
56
|
+
services.emitStoreEvent('DATABASE_LOCAL_QUERY', undefined);
|
|
57
|
+
log.isLevelEnabled = () => false;
|
|
58
|
+
services.emitStoreEvent('DATABASE_LOCAL_QUERY', { query: 'SELECT 3' });
|
|
59
|
+
expect(lines).toEqual([
|
|
60
|
+
[expect.objectContaining({ store: 'local', ms: 1.2, ok: true }), 'SELECT 1'],
|
|
61
|
+
[expect.objectContaining({ store: 'remote', query: long, ok: false, error: 'boom' }), `${long.slice(0, 160)}…`],
|
|
62
|
+
[expect.objectContaining({ store: 'local', ms: 0, ok: true }), ''],
|
|
63
|
+
]);
|
|
64
|
+
expect((lines[0] as any[])[0].query).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
37
67
|
it('init resolves from the local store while the remote connect never does', async () => {
|
|
38
68
|
const { client, a } = makeClient();
|
|
39
69
|
expect(client.isLocalReady()).toBe(false);
|
|
@@ -178,7 +208,9 @@ describe('Sp00kyClient facade', () => {
|
|
|
178
208
|
runtime.emit({ type: 'query:authority', hash: 'h', known: true });
|
|
179
209
|
runtime.emit({ type: 'query:view-lost', hash: 'h' });
|
|
180
210
|
runtime.emit({ type: 'mutation:event', event: { type: 'create', record_id: new RecordId('thing', '1'), data: {} } });
|
|
181
|
-
runtime.emit({ type: '
|
|
211
|
+
runtime.emit({ type: 'mutation:settled', mutationId: 'm', recordId: 'thing:1', eventType: 'create' });
|
|
212
|
+
runtime.emit({ type: 'mutation:rolled-back', mutationId: 'm2', recordId: 'thing:1', eventType: 'update', error: 'denied' });
|
|
213
|
+
runtime.emit({ type: 'tray:changed', count: 1 });
|
|
182
214
|
expect(await client.authenticate('tok')).toBe('tok');
|
|
183
215
|
await client.deauthenticate();
|
|
184
216
|
expect(await client.useRemote(async () => 'r')).toBe('r');
|
|
@@ -59,6 +59,8 @@ import { ImpersonationBanner } from '../modules/auth/impersonation-banner';
|
|
|
59
59
|
import type { LeaderSyncHub, SyncForwarder } from '../services/tabs/coordinator';
|
|
60
60
|
|
|
61
61
|
const UNKNOWN_STORAGE_HEALTH: StorageHealth = Object.freeze({ status: 'unknown', fallback: false });
|
|
62
|
+
/** Longest SurrealQL a statement log line shows as its message. */
|
|
63
|
+
const STATEMENT_MSG_MAX = 160;
|
|
62
64
|
|
|
63
65
|
export interface Sp00kyClientDeps<S extends SchemaStructure> {
|
|
64
66
|
services?: Services<S>;
|
|
@@ -168,6 +170,13 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
168
170
|
this.devTools.setFeatureFlagOverrides(this.featureFlags);
|
|
169
171
|
s.streamProcessor.addReceiver(this.devTools);
|
|
170
172
|
this.devTools.setBlobInfoProvider(() => s.blobs.stats());
|
|
173
|
+
this.devTools.setMutationSource({
|
|
174
|
+
state: () => this.runtime.state,
|
|
175
|
+
listFailed: () => this.listFailedMutations(),
|
|
176
|
+
retryFailed: (id) => this.retryFailedMutation(id),
|
|
177
|
+
discardFailed: (id) => this.discardFailedMutation(id),
|
|
178
|
+
});
|
|
179
|
+
if (s.logTap) this.devTools.setLogTap(s.logTap);
|
|
171
180
|
if (config.sharedTabs) {
|
|
172
181
|
this.devTools.setTabsInfoProvider(() => {
|
|
173
182
|
const c = s.tabs;
|
|
@@ -219,26 +228,62 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
219
228
|
if (e.type === 'tabs:sendTo' && this.hub) this.hub.sendTo(e.tabId, e.message as never);
|
|
220
229
|
});
|
|
221
230
|
this.runtime.on('*', (e) => this.forwardToDevTools(e));
|
|
222
|
-
|
|
223
|
-
|
|
231
|
+
// One debug line per finished statement, for both stores and every engine
|
|
232
|
+
// (the SQLite engine logs none of its own). Guarded: this is the hottest
|
|
233
|
+
// path there is, and at the default `info` the line is never written.
|
|
234
|
+
// The message is the SurrealQL itself, so the Logs tab reads like a query
|
|
235
|
+
// trail; a long statement is cut there and kept whole in `query`.
|
|
236
|
+
const logStatement = (store: 'local' | 'remote') => (event: any) => {
|
|
237
|
+
if (!s.logger.isLevelEnabled('debug')) return;
|
|
238
|
+
const q = event.payload ?? {};
|
|
239
|
+
const sql = String(q.query ?? '').replace(/\s+/g, ' ').trim();
|
|
240
|
+
const cut = sql.length > STATEMENT_MSG_MAX;
|
|
241
|
+
s.logger.debug(
|
|
242
|
+
{
|
|
243
|
+
store,
|
|
244
|
+
...(cut ? { query: q.query } : {}),
|
|
245
|
+
vars: q.vars,
|
|
246
|
+
ms: Math.round((q.duration ?? 0) * 10) / 10,
|
|
247
|
+
ok: q.success !== false,
|
|
248
|
+
error: q.error,
|
|
249
|
+
Category: `sp00ky-client::${store}`,
|
|
250
|
+
},
|
|
251
|
+
cut ? `${sql.slice(0, STATEMENT_MSG_MAX)}…` : sql
|
|
252
|
+
);
|
|
253
|
+
};
|
|
254
|
+
s.local.getEvents().subscribe('DATABASE_LOCAL_QUERY', logStatement('local'));
|
|
255
|
+
s.remote.getEvents().subscribe('DATABASE_REMOTE_QUERY', logStatement('remote'));
|
|
224
256
|
}
|
|
225
257
|
|
|
258
|
+
/** Runtime events the DevTools panel follows; query transitions also go to the log. */
|
|
226
259
|
private forwardToDevTools(e: OutEvent): void {
|
|
260
|
+
const log = this.services.logger;
|
|
227
261
|
switch (e.type) {
|
|
228
262
|
case 'query:status':
|
|
229
|
-
|
|
263
|
+
log.debug({ hash: e.hash, status: e.status, Category: 'sp00ky-client::query' }, 'Query status');
|
|
264
|
+
this.devTools.onStateChanged();
|
|
230
265
|
break;
|
|
231
266
|
case 'query:authority':
|
|
232
|
-
|
|
267
|
+
log.debug({ hash: e.hash, known: e.known, Category: 'sp00ky-client::query' }, e.known ? 'Query membership known' : 'Query membership unknown');
|
|
268
|
+
this.devTools.onStateChanged();
|
|
233
269
|
break;
|
|
234
270
|
case 'query:view-lost':
|
|
235
|
-
|
|
271
|
+
log.info({ hash: e.hash, Category: 'sp00ky-client::query' }, 'Query view lost on the server; re-registering');
|
|
272
|
+
this.devTools.onStateChanged();
|
|
236
273
|
break;
|
|
237
274
|
case 'mutation:event':
|
|
238
275
|
this.devTools.onMutation([e.event]);
|
|
239
276
|
break;
|
|
240
|
-
case '
|
|
241
|
-
this.devTools.
|
|
277
|
+
case 'mutation:settled':
|
|
278
|
+
this.devTools.onMutationOutcome({ mutationId: e.mutationId, recordId: e.recordId, eventType: e.eventType, status: 'synced' });
|
|
279
|
+
break;
|
|
280
|
+
case 'mutation:rolled-back':
|
|
281
|
+
this.devTools.onMutationOutcome({ mutationId: e.mutationId, recordId: e.recordId, eventType: e.eventType, status: 'rolled-back', error: e.error });
|
|
282
|
+
break;
|
|
283
|
+
case 'tray:changed':
|
|
284
|
+
case 'health:changed':
|
|
285
|
+
case 'activity:changed':
|
|
286
|
+
this.devTools.onStateChanged();
|
|
242
287
|
break;
|
|
243
288
|
default:
|
|
244
289
|
break;
|
package/src/kernel/events.ts
CHANGED
|
@@ -54,5 +54,4 @@ export type OutEvent =
|
|
|
54
54
|
| { type: 'activity:changed'; fetching: number; pending: number }
|
|
55
55
|
| { type: 'tabs:broadcast'; message: unknown }
|
|
56
56
|
| { type: 'tabs:sendTo'; tabId: string; message: unknown }
|
|
57
|
-
| { type: 'devtools'; name: string; data?: unknown }
|
|
58
57
|
| { type: 'log'; level: 'debug' | 'info' | 'warn' | 'error'; message: string; data?: unknown };
|