@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/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, LocalStore as At, SyncHealthStatus as B, TokenClaims as Bt, QueryTimeToLive as C, SignOutHook as Ct, RecordVersionArray as D, createAuthEventSystem as Dt, ReconnectConfig as E, AuthEventTypes as Et, StorageHealth as F, ImpersonationInfo as Ft, WebPushConfig as G, UpdateOptions as H, impersonationFromToken as Ht, StorageHealthStatus as I, ImpersonationTimerHost as It, WebPushErrorCode as J, WebPushDevice as K, StoreType as L, ImpersonationTimers as Lt, Sp00kyConfig as M, EventDefinition as Mt, Sp00kyQueryResult as N, EventSystem as Nt, RecordVersionDiff as O, RemoteDatabaseService as Ot, Sp00kyQueryResultPromise as P, IMPERSONATION_ACCESS as Pt, WebPushModule as Q, SyncHealth as R, ORIGIN_TOKEN_KEY as Rt, QueryStatusCallback as S, SignOutContext as St, QueryUpdateCallback as T, AuthEventTypeMap as Tt, PUSH_ENDPOINT_KEY as U, renewDelayMs as Ut, TimingPhase as V, decodeTokenClaims as Vt, PushDeviceKind as W, sameImpersonation 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, SealedQuery as jt, RegistrationTimings as k, Logger$1 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, SESSION_POLL_MS as zt };
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.290",
3
+ "version": "0.0.1-canary.292",
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.290",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.290",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.292",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.292",
80
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
81
  "@surrealdb/wasm": "^3.0.3",
82
82
  "blurhash": "^2.0.5",
@@ -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 logger = createLogger(config.logLevel ?? 'info', config.otelTransmit);
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: 'devtools', name: 'X', data: 1 });
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
- s.local.getEvents().subscribe('DATABASE_LOCAL_QUERY', (event: any) => this.devTools.logEvent('LOCAL_QUERY', event.payload));
223
- s.remote.getEvents().subscribe('DATABASE_REMOTE_QUERY', (event: any) => this.devTools.logEvent('REMOTE_QUERY', event.payload));
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
- this.devTools.logEvent('QUERY_STATUS_CHANGED', { queryHash: e.hash, status: e.status });
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
- this.devTools.logEvent('QUERY_AUTHORITY_CHANGED', { queryHash: e.hash, known: e.known });
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
- this.devTools.logEvent('QUERY_VIEW_LOST', { queryHash: e.hash });
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 'devtools':
241
- this.devTools.logEvent(e.name, e.data);
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;
@@ -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 };