@spooky-sync/core 0.0.1-canary.155 → 0.0.1-canary.157

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 CHANGED
@@ -140,6 +140,11 @@ declare class StreamProcessorService {
140
140
  private stateKeySuffix;
141
141
  private stateGeneration;
142
142
  private persistState;
143
+ private persistCircuit;
144
+ private checkpointMs;
145
+ private checkpointTimer;
146
+ private snapshotDirty;
147
+ private pagehideHandler;
143
148
  constructor(events: EventSystem<StreamProcessorEvents>, db: LocalStore, persistenceClient: PersistenceClient, logger: Logger);
144
149
  /**
145
150
  * Add a receiver for stream updates.
@@ -200,8 +205,34 @@ declare class StreamProcessorService {
200
205
  * afterwards (a fresh circuit default-denies every table).
201
206
  */
202
207
  reset(): Promise<void>;
208
+ /**
209
+ * Release the wasm circuit and stop checkpointing. Call when the client is
210
+ * torn down; a recreated client (provider remount, HMR) would otherwise stack
211
+ * one full circuit per instance.
212
+ */
213
+ dispose(): void;
214
+ /**
215
+ * Explicitly run the wasm-bindgen destructor. Guarded: stale wasm builds may
216
+ * not expose `free`, and a double free must not take the app down.
217
+ */
218
+ private freeProcessor;
203
219
  /** Toggle circuit-state persistence (shared-tabs follower/leader role). */
204
220
  setPersistenceEnabled(enabled: boolean): void;
221
+ /**
222
+ * Opt into snapshot persistence (`persistCircuit`). Off by default: see the
223
+ * `persistCircuit` field comment for why per-ingest snapshots were removed.
224
+ * Must be called before `init()` for a snapshot to be restored at boot.
225
+ */
226
+ configureCircuitPersistence(enabled: boolean, checkpointMs?: number): void;
227
+ /**
228
+ * Record that the circuit changed. Cheap and O(1), the expensive snapshot is
229
+ * deferred to the checkpoint timer, and skipped entirely when
230
+ * `persistCircuit` is off (the default).
231
+ */
232
+ private markSnapshotDirty;
233
+ private startCheckpoints;
234
+ /** Stop checkpointing and drop the `pagehide` listener. */
235
+ stopCheckpoints(): void;
205
236
  loadState(): Promise<void>;
206
237
  /**
207
238
  * Seed per-table `select` permission predicates ({ [table]: whereText }).
package/dist/index.js CHANGED
@@ -5694,16 +5694,20 @@ async function walkOpfs(maxEntries = 2e3, maxDepth = 8) {
5694
5694
 
5695
5695
  //#endregion
5696
5696
  //#region src/modules/devtools/index.ts
5697
- const CORE_VERSION = "0.0.1-canary.155";
5698
- const WASM_VERSION = "0.0.1-canary.155";
5697
+ const CORE_VERSION = "0.0.1-canary.157";
5698
+ const WASM_VERSION = "0.0.1-canary.157";
5699
5699
  const SURREAL_VERSION = "3.0.3";
5700
- var DevToolsService = class {
5700
+ var DevToolsService = class DevToolsService {
5701
5701
  eventsHistory = [];
5702
5702
  eventIdCounter = 0;
5703
5703
  version = CORE_VERSION;
5704
5704
  backendInfo = emptyBackendInfo();
5705
5705
  enabled = false;
5706
- /** Shared-tabs snapshot for the panel, wired by Sp00kyClient when active. */
5706
+ static NOTIFY_MIN_INTERVAL_MS = 250;
5707
+ notifyTimer = null;
5708
+ lastNotifyAt = 0;
5709
+ /** Shared-tabs snapshot for the panel, wired by Sp00kyClient whenever the
5710
+ * feature was REQUESTED (so an inactive/degraded tab still reports why). */
5707
5711
  tabsInfoProvider = null;
5708
5712
  setTabsInfoProvider(provider) {
5709
5713
  this.tabsInfoProvider = provider;
@@ -5925,6 +5929,7 @@ var DevToolsService = class {
5925
5929
  status: "unknown",
5926
5930
  fallback: false
5927
5931
  },
5932
+ tabs: this.tabsInfoProvider?.() ?? null,
5928
5933
  browser: {},
5929
5934
  opfs: {
5930
5935
  supported: false,
@@ -5969,9 +5974,30 @@ var DevToolsService = class {
5969
5974
  return { granted: false };
5970
5975
  }
5971
5976
  }
5977
+ /**
5978
+ * Request a state push. Coalesced (see {@link NOTIFY_MIN_INTERVAL_MS}): the
5979
+ * first call after an idle period pushes straight away so the panel stays
5980
+ * responsive, and any calls during the window collapse into ONE trailing push
5981
+ * that serializes the state as of the flush, not as of the request. Callers
5982
+ * stay fire-and-forget.
5983
+ */
5972
5984
  notifyDevTools() {
5973
5985
  if (!this.enabled) return;
5974
- if (typeof window !== "undefined") window.postMessage({
5986
+ if (typeof window === "undefined") return;
5987
+ if (this.notifyTimer !== null) return;
5988
+ const waited = Date.now() - this.lastNotifyAt;
5989
+ if (waited >= DevToolsService.NOTIFY_MIN_INTERVAL_MS) {
5990
+ this.flushNotify();
5991
+ return;
5992
+ }
5993
+ this.notifyTimer = setTimeout(() => {
5994
+ this.notifyTimer = null;
5995
+ if (this.enabled) this.flushNotify();
5996
+ }, DevToolsService.NOTIFY_MIN_INTERVAL_MS - waited);
5997
+ }
5998
+ flushNotify() {
5999
+ this.lastNotifyAt = Date.now();
6000
+ window.postMessage({
5975
6001
  type: "SP00KY_STATE_CHANGED",
5976
6002
  source: "sp00ky-devtools-page",
5977
6003
  state: this.getState()
@@ -6296,6 +6322,14 @@ var AuthService = class {
6296
6322
 
6297
6323
  //#endregion
6298
6324
  //#region src/services/stream-processor/index.ts
6325
+ /**
6326
+ * Read a circuit snapshot out of the pre-`persistCircuit` persisted shape
6327
+ * (`[[{ state }]]`, a raw SurrealDB result). Returns null for anything else.
6328
+ */
6329
+ function extractLegacyState(result) {
6330
+ if (Array.isArray(result) && Array.isArray(result[0]) && typeof result[0][0]?.state === "string") return result[0][0].state;
6331
+ return null;
6332
+ }
6299
6333
  var StreamProcessorService = class {
6300
6334
  logger;
6301
6335
  processor;
@@ -6310,6 +6344,11 @@ var StreamProcessorService = class {
6310
6344
  stateKeySuffix = "";
6311
6345
  stateGeneration = 0;
6312
6346
  persistState = true;
6347
+ persistCircuit = false;
6348
+ checkpointMs = 3e4;
6349
+ checkpointTimer = null;
6350
+ snapshotDirty = false;
6351
+ pagehideHandler = null;
6313
6352
  constructor(events, db, persistenceClient, logger) {
6314
6353
  this.events = events;
6315
6354
  this.db = db;
@@ -6390,7 +6429,7 @@ var StreamProcessorService = class {
6390
6429
  const buffered = Array.from(this.batchBuffer.values());
6391
6430
  this.batchBuffer.clear();
6392
6431
  if (buffered.length > 0) this.dispatchUpdates(buffered);
6393
- this.saveState();
6432
+ this.markSnapshotDirty();
6394
6433
  }
6395
6434
  /**
6396
6435
  * Initialize the WASM module and processor.
@@ -6434,19 +6473,99 @@ var StreamProcessorService = class {
6434
6473
  this.stateGeneration++;
6435
6474
  this.batching = false;
6436
6475
  this.batchBuffer.clear();
6476
+ this.snapshotDirty = false;
6477
+ const previous = this.processor;
6437
6478
  this.processor = new Sp00kyProcessor();
6479
+ this.freeProcessor(previous);
6438
6480
  this.logger.info({ Category: "sp00ky-client::StreamProcessorService::reset" }, "Stream processor reset (fresh circuit)");
6439
6481
  }
6482
+ /**
6483
+ * Release the wasm circuit and stop checkpointing. Call when the client is
6484
+ * torn down; a recreated client (provider remount, HMR) would otherwise stack
6485
+ * one full circuit per instance.
6486
+ */
6487
+ dispose() {
6488
+ this.stopCheckpoints();
6489
+ const previous = this.processor;
6490
+ this.processor = void 0;
6491
+ this.isInitialized = false;
6492
+ this.batching = false;
6493
+ this.batchBuffer.clear();
6494
+ this.receivers = [];
6495
+ this.freeProcessor(previous);
6496
+ }
6497
+ /**
6498
+ * Explicitly run the wasm-bindgen destructor. Guarded: stale wasm builds may
6499
+ * not expose `free`, and a double free must not take the app down.
6500
+ */
6501
+ freeProcessor(processor) {
6502
+ if (!processor || typeof processor.free !== "function") return;
6503
+ try {
6504
+ processor.free();
6505
+ } catch (e) {
6506
+ this.logger.debug({
6507
+ error: e,
6508
+ Category: "sp00ky-client::StreamProcessorService::freeProcessor"
6509
+ }, "Failed to free previous wasm circuit");
6510
+ }
6511
+ }
6440
6512
  /** Toggle circuit-state persistence (shared-tabs follower/leader role). */
6441
6513
  setPersistenceEnabled(enabled) {
6442
6514
  this.persistState = enabled;
6515
+ if (!enabled) this.stopCheckpoints();
6516
+ }
6517
+ /**
6518
+ * Opt into snapshot persistence (`persistCircuit`). Off by default: see the
6519
+ * `persistCircuit` field comment for why per-ingest snapshots were removed.
6520
+ * Must be called before `init()` for a snapshot to be restored at boot.
6521
+ */
6522
+ configureCircuitPersistence(enabled, checkpointMs) {
6523
+ this.persistCircuit = enabled;
6524
+ if (checkpointMs && checkpointMs > 0) this.checkpointMs = checkpointMs;
6525
+ if (!enabled) this.stopCheckpoints();
6526
+ }
6527
+ /**
6528
+ * Record that the circuit changed. Cheap and O(1), the expensive snapshot is
6529
+ * deferred to the checkpoint timer, and skipped entirely when
6530
+ * `persistCircuit` is off (the default).
6531
+ */
6532
+ markSnapshotDirty() {
6533
+ if (!this.persistCircuit || !this.persistState) return;
6534
+ this.snapshotDirty = true;
6535
+ this.startCheckpoints();
6536
+ }
6537
+ startCheckpoints() {
6538
+ if (this.checkpointTimer) return;
6539
+ this.checkpointTimer = setInterval(() => {
6540
+ if (!this.snapshotDirty) return;
6541
+ this.snapshotDirty = false;
6542
+ this.saveState();
6543
+ }, this.checkpointMs);
6544
+ if (typeof window !== "undefined" && !this.pagehideHandler) {
6545
+ this.pagehideHandler = () => {
6546
+ if (!this.snapshotDirty) return;
6547
+ this.snapshotDirty = false;
6548
+ this.saveState();
6549
+ };
6550
+ window.addEventListener("pagehide", this.pagehideHandler);
6551
+ }
6552
+ }
6553
+ /** Stop checkpointing and drop the `pagehide` listener. */
6554
+ stopCheckpoints() {
6555
+ if (this.checkpointTimer) {
6556
+ clearInterval(this.checkpointTimer);
6557
+ this.checkpointTimer = null;
6558
+ }
6559
+ if (this.pagehideHandler && typeof window !== "undefined") window.removeEventListener("pagehide", this.pagehideHandler);
6560
+ this.pagehideHandler = null;
6561
+ this.snapshotDirty = false;
6443
6562
  }
6444
6563
  async loadState() {
6445
- if (!this.processor || !this.persistState) return;
6564
+ if (!this.processor || !this.persistState || !this.persistCircuit) return;
6446
6565
  try {
6447
6566
  const result = await this.persistenceClient.get(this.stateKey());
6448
- if (Array.isArray(result) && result.length > 0 && Array.isArray(result[0]) && result[0].length > 0 && result[0][0]?.state) {
6449
- const state = result[0][0].state;
6567
+ const state = typeof result === "string" ? result : extractLegacyState(result);
6568
+ if (state) {
6450
6569
  this.logger.info({
6451
6570
  stateLength: state.length,
6452
6571
  Category: "sp00ky-client::StreamProcessorService::loadState"
@@ -6500,7 +6619,7 @@ var StreamProcessorService = class {
6500
6619
  }, "Session auth context updated");
6501
6620
  }
6502
6621
  async saveState() {
6503
- if (!this.processor || !this.persistState) return;
6622
+ if (!this.processor || !this.persistState || !this.persistCircuit) return;
6504
6623
  const generation = this.stateGeneration;
6505
6624
  try {
6506
6625
  if (typeof this.processor.save_state === "function") {
@@ -6559,7 +6678,7 @@ var StreamProcessorService = class {
6559
6678
  }));
6560
6679
  this.notifyUpdates(updates);
6561
6680
  }
6562
- if (!this.batching) this.saveState();
6681
+ if (!this.batching) this.markSnapshotDirty();
6563
6682
  return rawUpdates;
6564
6683
  } catch (e) {
6565
6684
  this.logger.error({
@@ -6612,7 +6731,7 @@ var StreamProcessorService = class {
6612
6731
  snapshotMs: initialUpdate.timing_snapshot_ms ?? 0
6613
6732
  }
6614
6733
  };
6615
- this.saveState();
6734
+ this.markSnapshotDirty();
6616
6735
  this.logger.debug({
6617
6736
  queryHash: queryPlan.queryHash,
6618
6737
  surql: queryPlan.surql,
@@ -6635,7 +6754,7 @@ var StreamProcessorService = class {
6635
6754
  if (!this.processor) return;
6636
6755
  try {
6637
6756
  this.processor.unregister_view(queryHash);
6638
- this.saveState();
6757
+ this.markSnapshotDirty();
6639
6758
  } catch (e) {
6640
6759
  this.logger.error({
6641
6760
  error: e,
@@ -8757,6 +8876,7 @@ var Sp00kyClient = class {
8757
8876
  else this.persistenceClient = config.persistenceClient;
8758
8877
  this.persistenceClient = new ResilientPersistenceClient(this.persistenceClient, logger);
8759
8878
  this.streamProcessor = new StreamProcessorService(new EventSystem(["stream_update"]), this.local, this.persistenceClient, logger);
8879
+ this.streamProcessor.configureCircuitPersistence(config.persistCircuit ?? false, config.circuitCheckpointMs);
8760
8880
  this.migrator = new LocalMigrator(this.local, logger);
8761
8881
  this.cache = new CacheModule(this.local, this.streamProcessor, (update) => {
8762
8882
  this.dataModule.onStreamUpdate(update);
@@ -8784,24 +8904,33 @@ var Sp00kyClient = class {
8784
8904
  this.devTools = new DevToolsService(this.local, this.remote, logger, this.config.schema, this.auth, this.dataModule);
8785
8905
  this.streamProcessor.addReceiver(this.devTools);
8786
8906
  this.setupCallbacks();
8787
- if (tabsSupport.supported) {
8788
- this.tabsCoordinator = this.buildTabsCoordinator();
8907
+ if (tabsSupport.supported) this.tabsCoordinator = this.buildTabsCoordinator();
8908
+ else if (this.config.sharedTabs) this.logger.info({
8909
+ reason: tabsSupport.reason,
8910
+ Category: "sp00ky-client::Sp00kyClient::tabs"
8911
+ }, "sharedTabs requested but unsupported here; running solo");
8912
+ if (this.config.sharedTabs) {
8913
+ const unsupportedReason = tabsSupport.supported ? void 0 : tabsSupport.reason;
8789
8914
  this.devTools.setTabsInfoProvider(() => {
8790
- if (!this.sharedActive || !this.tabsCoordinator) return null;
8791
8915
  const c = this.tabsCoordinator;
8916
+ if (!this.sharedActive || !c) return {
8917
+ active: false,
8918
+ reason: unsupportedReason ?? "fell-back"
8919
+ };
8920
+ const hub = c.syncHub;
8792
8921
  return {
8922
+ active: true,
8793
8923
  role: c.role,
8794
8924
  tabId: c.tabId,
8795
8925
  leadershipId: c.leadershipId,
8796
- leaderTabId: c.leaderTabId,
8797
- followers: c.syncHub?.followerCount ?? null,
8798
- relayedBatches: c.syncHub?.relayedBatches ?? null
8926
+ leaderTabId: c.role === "leader" ? c.tabId : c.leaderTabId,
8927
+ ...hub ? {
8928
+ followers: hub.followerCount,
8929
+ relayedBatches: hub.relayedBatches
8930
+ } : {}
8799
8931
  };
8800
8932
  });
8801
- } else if (this.config.sharedTabs) this.logger.info({
8802
- reason: tabsSupport.reason,
8803
- Category: "sp00ky-client::Sp00kyClient::tabs"
8804
- }, "sharedTabs requested but unsupported here; running solo");
8933
+ }
8805
8934
  }
8806
8935
  /** The shared-tabs role machinery, wired to this client's modules. */
8807
8936
  buildTabsCoordinator() {
@@ -8810,7 +8939,7 @@ var Sp00kyClient = class {
8810
8939
  return new TabsCoordinator({
8811
8940
  tabId,
8812
8941
  fingerprint: computeTabsFingerprint({
8813
- coreVersion: "0.0.1-canary.155",
8942
+ coreVersion: "0.0.1-canary.157",
8814
8943
  schemaHash: hash53(this.config.schemaSurql),
8815
8944
  endpoint: this.config.database.endpoint ?? "",
8816
8945
  namespace: this.config.database.namespace,
@@ -9106,6 +9235,7 @@ var Sp00kyClient = class {
9106
9235
  if (this.tabsCoordinator) await this.tabsCoordinator.stop();
9107
9236
  await this.local.close();
9108
9237
  await this.remote.close();
9238
+ this.streamProcessor.dispose();
9109
9239
  }
9110
9240
  /**
9111
9241
  * Subscribe to a feature flag for the current user. Returns a
package/dist/types.d.ts CHANGED
@@ -199,6 +199,13 @@ interface EngineStorageDiagnostics {
199
199
  }[];
200
200
  error?: string;
201
201
  }
202
+ /**
203
+ * Shared-tabs coordination state. Reported ONLY when `sharedTabs: true` was
204
+ * configured (apps that never asked for it get `null`, so the panel shows
205
+ * nothing). `active: false` with a `reason` is itself the useful signal: the
206
+ * app asked to share one store and this tab is not, so it owns or contends for
207
+ * the OPFS pool alone.
208
+ */
202
209
  //#endregion
203
210
  //#region src/services/database/cache-engine.d.ts
204
211
  /**
@@ -488,6 +495,29 @@ interface Sp00kyConfig<S extends SchemaStructure> {
488
495
  * Inspect via `window.__00__.getState().database.tabs` and `__sqliteStats`.
489
496
  */
490
497
  sharedTabs?: boolean;
498
+ /**
499
+ * Persist the in-browser SSP circuit (store + view caches) as a snapshot so a
500
+ * reload can restore it instead of re-materializing. Default `false`, and
501
+ * that default is deliberate.
502
+ *
503
+ * The circuit is DERIVED state: the durable local store (OPFS SQLite) is the
504
+ * source of truth, and every first paint already reads row bodies from it
505
+ * (`DataManager.createNewQuery` / `materializeRecords`) using the circuit only
506
+ * for row identity and ordering. A snapshot buys nothing on reload while
507
+ * costing a full deep clone of every row of every ingested table plus a JSON
508
+ * encode of the result, `Circuit::save` in the Rust core, mirroring the
509
+ * server's rule in `ssp-node`: *never per-ingest*.
510
+ *
511
+ * When enabled, snapshots are written on a checkpoint interval
512
+ * ({@link circuitCheckpointMs}) and on `pagehide`, never per ingest or per
513
+ * query registration. Enable only for a workload that has measured a win.
514
+ */
515
+ persistCircuit?: boolean;
516
+ /**
517
+ * Checkpoint interval in milliseconds for {@link persistCircuit}. Defaults to
518
+ * 30000. Ignored when `persistCircuit` is off.
519
+ */
520
+ circuitCheckpointMs?: number;
491
521
  /** A pino browser transmit object for forwarding logs (e.g. via @spooky-sync/core/otel). */
492
522
  otelTransmit?: PinoTransmit;
493
523
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.155",
3
+ "version": "0.0.1-canary.157",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,8 @@
60
60
  }
61
61
  },
62
62
  "dependencies": {
63
- "@spooky-sync/query-builder": "0.0.1-canary.155",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.155",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.157",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.157",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "fast-json-patch": "^3.1.1",
@@ -22,7 +22,7 @@ import {
22
22
  parseBackendInfo,
23
23
  UNAVAILABLE,
24
24
  } from './versions';
25
- import { walkOpfs, type StorageInfo } from './storage-info';
25
+ import { walkOpfs, type SharedTabsInfo, type StorageInfo } from './storage-info';
26
26
 
27
27
  // Real bundled frontend versions, injected at build time by tsdown's
28
28
  // version-define plugin (see tsdown.config.ts). The `typeof` guard keeps these
@@ -51,10 +51,23 @@ export class DevToolsService implements StreamUpdateReceiver {
51
51
  // (the on-demand GET_STATE pull) still works before the push channel turns on.
52
52
  private enabled = false;
53
53
 
54
- /** Shared-tabs snapshot for the panel, wired by Sp00kyClient when active. */
55
- private tabsInfoProvider: (() => Record<string, unknown> | null) | null = null;
56
-
57
- setTabsInfoProvider(provider: () => Record<string, unknown> | null): void {
54
+ // A state push serializes EVERY active query's full record set (see
55
+ // `getActiveQueries`) and postMessage clones it again, so its cost scales with
56
+ // the whole client dataset — and it is triggered per event, including one per
57
+ // local DB query (`DATABASE_LOCAL_QUERY` `logEvent`). Unthrottled, a single
58
+ // page load's few hundred local queries turn a handful of MB of rows into GBs
59
+ // of short-lived large-object garbage and OOM the renderer (V8
60
+ // "young object promotion failed"). Coalesce instead: push immediately when
61
+ // idle, then at most once per window, always serializing the LATEST state.
62
+ private static readonly NOTIFY_MIN_INTERVAL_MS = 250;
63
+ private notifyTimer: ReturnType<typeof setTimeout> | null = null;
64
+ private lastNotifyAt = 0;
65
+
66
+ /** Shared-tabs snapshot for the panel, wired by Sp00kyClient whenever the
67
+ * feature was REQUESTED (so an inactive/degraded tab still reports why). */
68
+ private tabsInfoProvider: (() => SharedTabsInfo | null) | null = null;
69
+
70
+ setTabsInfoProvider(provider: () => SharedTabsInfo | null): void {
58
71
  this.tabsInfoProvider = provider;
59
72
  }
60
73
 
@@ -357,6 +370,7 @@ export class DevToolsService implements StreamUpdateReceiver {
357
370
  bucketId: this.databaseService.currentBucketId,
358
371
  },
359
372
  health: this.databaseService.storageHealth ?? { status: 'unknown', fallback: false },
373
+ tabs: this.tabsInfoProvider?.() ?? null,
360
374
  browser: {},
361
375
  opfs: { supported: false, entries: [], totalBytes: 0, truncated: false },
362
376
  };
@@ -406,19 +420,42 @@ export class DevToolsService implements StreamUpdateReceiver {
406
420
  }
407
421
  }
408
422
 
423
+ /**
424
+ * Request a state push. Coalesced (see {@link NOTIFY_MIN_INTERVAL_MS}): the
425
+ * first call after an idle period pushes straight away so the panel stays
426
+ * responsive, and any calls during the window collapse into ONE trailing push
427
+ * that serializes the state as of the flush, not as of the request. Callers
428
+ * stay fire-and-forget.
429
+ */
409
430
  private notifyDevTools() {
410
431
  // No consumer attached → no getState() serialization, no postMessage broadcast.
411
432
  if (!this.enabled) return;
412
- if (typeof window !== 'undefined') {
413
- window.postMessage(
414
- {
415
- type: 'SP00KY_STATE_CHANGED',
416
- source: 'sp00ky-devtools-page',
417
- state: this.getState(),
418
- },
419
- '*'
420
- );
433
+ if (typeof window === 'undefined') return;
434
+ // A trailing push is already queued; it will carry this change too.
435
+ if (this.notifyTimer !== null) return;
436
+
437
+ const waited = Date.now() - this.lastNotifyAt;
438
+ if (waited >= DevToolsService.NOTIFY_MIN_INTERVAL_MS) {
439
+ this.flushNotify();
440
+ return;
421
441
  }
442
+ this.notifyTimer = setTimeout(() => {
443
+ this.notifyTimer = null;
444
+ // Still gated on `enabled`: the panel may have disconnected while queued.
445
+ if (this.enabled) this.flushNotify();
446
+ }, DevToolsService.NOTIFY_MIN_INTERVAL_MS - waited);
447
+ }
448
+
449
+ private flushNotify() {
450
+ this.lastNotifyAt = Date.now();
451
+ window.postMessage(
452
+ {
453
+ type: 'SP00KY_STATE_CHANGED',
454
+ source: 'sp00ky-devtools-page',
455
+ state: this.getState(),
456
+ },
457
+ '*'
458
+ );
422
459
  }
423
460
 
424
461
  private serializeForDevTools(data: any, seen = new WeakSet<object>()): any {
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { DevToolsService } from './index';
4
+
5
+ /**
6
+ * A state push serializes EVERY active query's full record set and postMessage
7
+ * clones it again, so its cost scales with the whole client dataset. It is
8
+ * requested per event — including one per local DB query — so without
9
+ * coalescing a page load's few hundred local queries turn a few MB of rows into
10
+ * GBs of short-lived large-object garbage and OOM the renderer. These tests pin
11
+ * the coalescing, not the payload.
12
+ */
13
+
14
+ function harness(recordCount = 500) {
15
+ const posted: any[] = [];
16
+ const listeners: ((e: any) => void)[] = [];
17
+ const fakeWindow: any = {
18
+ postMessage: (msg: any) => posted.push(msg),
19
+ addEventListener: (_type: string, cb: (e: any) => void) => listeners.push(cb),
20
+ dispatchEvent: () => true,
21
+ };
22
+ fakeWindow.self = fakeWindow;
23
+ vi.stubGlobal('window', fakeWindow);
24
+ vi.stubGlobal('CustomEvent', class {
25
+ type: string;
26
+ constructor(type: string) {
27
+ this.type = type;
28
+ }
29
+ });
30
+
31
+ const noop = () => {};
32
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
33
+ logger.child = () => logger;
34
+
35
+ const local: any = {
36
+ query: async () => [],
37
+ getConfig: () => ({ store: 'memory' }),
38
+ currentBucketId: 'anon',
39
+ storageHealth: { status: 'memory', fallback: false },
40
+ };
41
+ const remote: any = { query: async () => [] };
42
+ const auth: any = {
43
+ isAuthenticated: false,
44
+ currentUser: undefined,
45
+ eventSystem: { subscribe: noop },
46
+ };
47
+ // One query holding a lot of rows: the thing whose repeated serialization is
48
+ // what actually blows the heap.
49
+ const records = Array.from({ length: recordCount }, (_, i) => ({ id: `game:${i}`, pgn: 'x' }));
50
+ const dataManager: any = {
51
+ getActiveQueries: () => [
52
+ {
53
+ config: { id: new RecordId('_00_query', 'q1'), params: {} },
54
+ status: 'idle',
55
+ records,
56
+ updateCount: 1,
57
+ },
58
+ ],
59
+ phaseTimings: () => ({}),
60
+ };
61
+
62
+ const service = new DevToolsService(local, remote, logger, { tables: [] } as any, auth, dataManager);
63
+ // Announce a consumer, exactly like the extension's page-script does.
64
+ for (const cb of listeners) {
65
+ cb({ source: fakeWindow, data: { type: 'SP00KY_DEVTOOLS_CONNECT' } });
66
+ }
67
+ const statePushes = () => posted.filter((m) => m.type === 'SP00KY_STATE_CHANGED');
68
+ return { service, statePushes, posted };
69
+ }
70
+
71
+ afterEach(() => {
72
+ vi.useRealTimers();
73
+ vi.unstubAllGlobals();
74
+ });
75
+
76
+ describe('DevToolsService state-push coalescing', () => {
77
+ it('pushes once immediately on connect', () => {
78
+ vi.useFakeTimers();
79
+ const { statePushes } = harness();
80
+ expect(statePushes().length).toBe(1);
81
+ });
82
+
83
+ it('collapses a burst of per-query events into ONE trailing push', () => {
84
+ vi.useFakeTimers();
85
+ const { service, statePushes } = harness();
86
+ const before = statePushes().length;
87
+
88
+ // What a page load looks like: hundreds of LOCAL_QUERY events, each of which
89
+ // used to serialize the entire query state.
90
+ for (let i = 0; i < 400; i++) {
91
+ (service as any).logEvent('LOCAL_QUERY', { query: 'SELECT * FROM game', vars: {} });
92
+ }
93
+ // Nothing extra yet — the burst is queued, not serialized 400 times.
94
+ expect(statePushes().length).toBe(before);
95
+
96
+ vi.advanceTimersByTime(300);
97
+ expect(statePushes().length).toBe(before + 1);
98
+ });
99
+
100
+ it('still pushes again once the window has passed', () => {
101
+ vi.useFakeTimers();
102
+ const { service, statePushes } = harness();
103
+ const before = statePushes().length;
104
+
105
+ (service as any).logEvent('A', {});
106
+ vi.advanceTimersByTime(300);
107
+ expect(statePushes().length).toBe(before + 1);
108
+
109
+ // An event arriving right after that flush is still inside the window, so it
110
+ // queues rather than pushing again...
111
+ (service as any).logEvent('B', {});
112
+ expect(statePushes().length).toBe(before + 1);
113
+ vi.advanceTimersByTime(300);
114
+ expect(statePushes().length).toBe(before + 2);
115
+
116
+ // ...but once the tab has been idle past the window, the next event pushes
117
+ // straight away, so the panel never waits on a quiet app.
118
+ vi.advanceTimersByTime(1000);
119
+ (service as any).logEvent('C', {});
120
+ expect(statePushes().length).toBe(before + 3);
121
+ });
122
+
123
+ it('drops a queued push when the consumer disconnects mid-window', () => {
124
+ vi.useFakeTimers();
125
+ const { service, statePushes, posted } = harness();
126
+ const before = statePushes().length;
127
+
128
+ (service as any).logEvent('A', {});
129
+ (service as any).enabled = false; // panel closed while the push was queued
130
+ vi.advanceTimersByTime(300);
131
+ expect(statePushes().length).toBe(before);
132
+ expect(posted.some((m) => m.type === 'SP00KY_STATE_CHANGED' && m.state === undefined)).toBe(false);
133
+ });
134
+
135
+ it('serializes the LATEST state at flush time, not at request time', () => {
136
+ vi.useFakeTimers();
137
+ const { service, statePushes } = harness();
138
+
139
+ (service as any).logEvent('FIRST', {});
140
+ (service as any).logEvent('SECOND', {});
141
+ vi.advanceTimersByTime(300);
142
+
143
+ const last = statePushes().at(-1);
144
+ const types = last.state.eventsHistory.map((e: any) => e.eventType);
145
+ // Both events of the coalesced window are present in the single push.
146
+ expect(types).toContain('FIRST');
147
+ expect(types).toContain('SECOND');
148
+ });
149
+ });