@spooky-sync/core 0.0.1-canary.204 → 0.0.1-canary.205

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.
@@ -53,7 +53,12 @@ export interface CoordinatorHooks {
53
53
  exposeClientPort(clientId: string, port: MessagePort): Promise<void>;
54
54
  removeClientPort(clientId: string): Promise<void>;
55
55
  /** Sync layer role changes (implemented in the sync module). */
56
- becomeSyncLeader(hub: LeaderSyncHub): Promise<void>;
56
+ /** Synchronous leader WIRING. Must not block: the role is published and
57
+ * `leader-ready` is sent right after it returns. */
58
+ becomeSyncLeader(hub: LeaderSyncHub): void;
59
+ /** The blocking half of the leader handover (outbox drain, LIVE restart),
60
+ * run in the background once the tab is already a working leader. */
61
+ resumeSyncLeaderDuties(): Promise<void>;
57
62
  becomeSyncFollower(forwarder: SyncForwarder): void;
58
63
  becomeSyncSolo(): void;
59
64
  /** Current storage health, for db-ready sent to late-joining followers. */
@@ -208,6 +213,10 @@ export class TabsCoordinator {
208
213
  leadershipId = 0;
209
214
  leaderTabId: TabId | null = null;
210
215
  brokerRole: 'pending' | TabRole = 'pending';
216
+ /** Wall time from `become-leader` to `leader-ready` on the last promotion.
217
+ * Reported in the DevTools role-change event: a slow store adopt (OPFS pool
218
+ * contention after the previous leader died) shows up here as seconds. */
219
+ lastPromotionMs: number | null = null;
211
220
  private fingerprint: string;
212
221
  private bucketId: string;
213
222
  private broker: TabBrokerClient;
@@ -285,7 +294,12 @@ export class TabsCoordinator {
285
294
  if (this.role !== role) {
286
295
  this.role = role;
287
296
  this.deps.logger.info(
288
- { role, leadershipId: this.leadershipId, Category: 'sp00ky-client::TabsCoordinator' },
297
+ {
298
+ role,
299
+ leadershipId: this.leadershipId,
300
+ promotionMs: role === 'leader' ? this.lastPromotionMs : undefined,
301
+ Category: 'sp00ky-client::TabsCoordinator',
302
+ },
289
303
  'Tab role changed'
290
304
  );
291
305
  for (const cb of this.roleListeners) cb(role);
@@ -379,6 +393,7 @@ export class TabsCoordinator {
379
393
  if (this.closed) return;
380
394
  if (msg.leadershipId <= this.leadershipId && !msg.resumeHeld) return;
381
395
  const previousRole = this.role;
396
+ const startedAt = Date.now();
382
397
  try {
383
398
  if (previousRole === 'follower') this.teardownFollower('promoted');
384
399
  this.leadershipId = msg.leadershipId;
@@ -408,7 +423,16 @@ export class TabsCoordinator {
408
423
  });
409
424
  void health;
410
425
  this.hub = new LeaderSyncHub(msg.leadershipId, this.deps.logger);
411
- await this.deps.hooks.becomeSyncLeader(this.hub);
426
+ // Owning the store IS being the leader: it unparks this tab's ops and is
427
+ // everything a follower's ports need. Publish the role and tell the
428
+ // broker BEFORE any sync work. Waiting on the outbox drain and the LIVE
429
+ // restart here once left a tab that owned the OPFS pool but never sent
430
+ // `leader-ready`, so the broker minted no follower ports and re-elected
431
+ // no one (`assignFollowerPorts` and `electIfNeeded` both bail on that
432
+ // state) and the whole namespace stayed leaderless until a reload. Those
433
+ // duties are best-effort anyway: the list_ref poll covers a missing LIVE.
434
+ this.deps.hooks.becomeSyncLeader(this.hub);
435
+ this.lastPromotionMs = Date.now() - startedAt;
412
436
  this.setRole('leader');
413
437
  this.broker.send({
414
438
  type: 'leader-ready',
@@ -416,6 +440,14 @@ export class TabsCoordinator {
416
440
  bucketId: this.bucketId,
417
441
  leadershipId: msg.leadershipId,
418
442
  });
443
+ // Off the promotion chain on purpose: a hang here must not block the
444
+ // next role transition (demote, bucket switch) for the life of the tab.
445
+ void this.deps.hooks.resumeSyncLeaderDuties().catch((e) => {
446
+ this.deps.logger.error(
447
+ { err: e, Category: 'sp00ky-client::TabsCoordinator' },
448
+ 'Leader duties failed after promotion'
449
+ );
450
+ });
419
451
  } catch (e) {
420
452
  const reason = e instanceof Error ? e.message : String(e);
421
453
  this.deps.logger.error(
@@ -33,6 +33,13 @@ import type {
33
33
  // ---- constants duplicated from protocol.ts (keep in sync) -------------------
34
34
  const PING_INTERVAL_MS = 5000;
35
35
  const PONG_TIMEOUT_MS = 15_000;
36
+ /** How long a tab has to answer `become-leader` with `leader-ready`. A tab
37
+ * that hangs mid-promotion sends neither `leader-ready` nor `leader-failed`,
38
+ * and keeps answering pings, so nothing else here would notice: the
39
+ * namespace would keep a leader that is not `ready`, mint no follower ports,
40
+ * and refuse to re-elect (`electIfNeeded` bails while `ns.leader` is set)
41
+ * until every tab was reloaded. Treat the silence as a failed promotion. */
42
+ const PROMOTION_TIMEOUT_MS = 20_000;
36
43
  const FORCE_TAKEOVER_TIMEOUT_MS = 1000;
37
44
  const LEADER_FAILURE_BACKOFF_MS = 1000;
38
45
  const FAILED_CYCLES_BEFORE_MEMORY = 3;
@@ -72,6 +79,8 @@ interface Namespace {
72
79
  * lock (granted = the leader tab died; locks release instantly on death,
73
80
  * unlike the pong timeout). */
74
81
  tabLockMonitor: AbortController | null;
82
+ /** Deadline for the pending `become-leader` to report `leader-ready`. */
83
+ promotionTimer: ReturnType<typeof setTimeout> | null;
75
84
  }
76
85
 
77
86
  const brokerInstanceId =
@@ -116,6 +125,7 @@ function getNamespace(fingerprint: string, bucketId: string): Namespace {
116
125
  electing: false,
117
126
  attachRetry: new Map(),
118
127
  tabLockMonitor: null,
128
+ promotionTimer: null,
119
129
  };
120
130
  namespaces.set(key, ns);
121
131
  }
@@ -165,6 +175,7 @@ function clearLeader(
165
175
  const leader = ns.leader;
166
176
  if (!leader) return null;
167
177
  ns.leader = null;
178
+ clearPromotionTimer(ns);
168
179
  ns.tabLockMonitor?.abort();
169
180
  ns.tabLockMonitor = null;
170
181
  const tab = ns.tabs.get(leader.tabId);
@@ -188,6 +199,54 @@ function clearLeader(
188
199
  return { tabId: leader.tabId, leadershipId: leader.leadershipId, workerLock };
189
200
  }
190
201
 
202
+ function clearPromotionTimer(ns: Namespace): void {
203
+ if (ns.promotionTimer) clearTimeout(ns.promotionTimer);
204
+ ns.promotionTimer = null;
205
+ }
206
+
207
+ /**
208
+ * A promotion did not work out: back the tab off, clear the leadership, and
209
+ * re-elect. Shared by the tab's own `leader-failed` report and by the
210
+ * {@link PROMOTION_TIMEOUT_MS} deadline, so a tab that hangs mid-promotion
211
+ * costs the namespace the same 1s backoff as one that fails loudly, instead of
212
+ * owning it forever.
213
+ *
214
+ * Every reason counts toward `failedCycles`. Gating that on 'opfs-unavailable'
215
+ * left any other recurring failure (e.g. a tab lock nobody frees) looping at
216
+ * the backoff interval forever, with allowMemoryFallback never granted, so
217
+ * every tab timed out in start() and booted solo - each one then contending
218
+ * for the OPFS pool on its own, which is exactly what shared-tabs exists to
219
+ * stop.
220
+ */
221
+ function failPromotion(
222
+ ns: Namespace,
223
+ tabId: string,
224
+ leadershipId: number,
225
+ opts: { timedOut: boolean }
226
+ ): void {
227
+ if (ns.leader?.tabId !== tabId || ns.leader.leadershipId !== leadershipId) return;
228
+ ns.failedCycles += 1;
229
+ ns.failedUntil.set(tabId, Date.now() + LEADER_FAILURE_BACKOFF_MS);
230
+ const previous = clearLeader(ns, { demote: false, removeTab: false });
231
+ const tab = ns.tabs.get(tabId);
232
+ if (tab) tab.heldLeadership = null;
233
+ // A tab that reported `leader-failed` has already given back its tab lock and
234
+ // any worker it opened, so there is nothing for the next candidate to wait
235
+ // on. A tab that went silent may have opened the OPFS pool and never told us,
236
+ // so hand the next election the lock name it would be holding: the probe in
237
+ // `waitForPreviousLeaderLocks` returns immediately when it is free, and
238
+ // steals it when it is not. `heldLeadership` is deliberately NOT used for
239
+ // this - it means "confirmed leader" and drives the broker-restart resume.
240
+ const stranded =
241
+ opts.timedOut && previous
242
+ ? {
243
+ ...previous,
244
+ workerLock: `sp00ky-tabs:${ns.fingerprint}:${ns.bucketId}:worker:${leadershipId}`,
245
+ }
246
+ : previous;
247
+ electIfNeeded(ns, stranded);
248
+ }
249
+
191
250
  /**
192
251
  * Fast crash detection: queue a Web Lock request on the leader's tab lock.
193
252
  * Locks release the instant their holder's tab dies, so being GRANTED means
@@ -277,6 +336,11 @@ function electIfNeeded(ns: Namespace, previous: ClearedLeader | null = null): vo
277
336
  allowMemoryFallback: ns.failedCycles >= FAILED_CYCLES_BEFORE_MEMORY,
278
337
  resumeHeld,
279
338
  });
339
+ clearPromotionTimer(ns);
340
+ ns.promotionTimer = setTimeout(() => {
341
+ ns.promotionTimer = null;
342
+ failPromotion(ns, candidate.tabId, leadershipId, { timedOut: true });
343
+ }, PROMOTION_TIMEOUT_MS);
280
344
  } finally {
281
345
  ns.electing = false;
282
346
  }
@@ -489,6 +553,7 @@ function handleTabMessage(port: MessagePort, msg: TabToBrokerMessage, ports: rea
489
553
  }
490
554
  ns.leader.ready = true;
491
555
  ns.failedCycles = 0;
556
+ clearPromotionTimer(ns);
492
557
  startTabLockMonitor(ns, msg.leadershipId);
493
558
  const tab = ns.tabs.get(msg.tabId);
494
559
  if (tab) {
@@ -511,18 +576,7 @@ function handleTabMessage(port: MessagePort, msg: TabToBrokerMessage, ports: rea
511
576
  break;
512
577
  }
513
578
  case 'leader-failed': {
514
- if (ns.leader?.tabId !== msg.tabId || ns.leader.leadershipId !== msg.leadershipId) break;
515
- // Every reason counts. Gating this on 'opfs-unavailable' left any other
516
- // recurring failure (e.g. a tab lock nobody frees) looping at the backoff
517
- // interval forever, with allowMemoryFallback never granted, so every tab
518
- // timed out in start() and booted solo — each one then contending for the
519
- // OPFS pool on its own, which is exactly what shared-tabs exists to stop.
520
- ns.failedCycles += 1;
521
- ns.failedUntil.set(msg.tabId, Date.now() + LEADER_FAILURE_BACKOFF_MS);
522
- const previous = clearLeader(ns, { demote: false, removeTab: false });
523
- const tab = ns.tabs.get(msg.tabId);
524
- if (tab) tab.heldLeadership = null;
525
- electIfNeeded(ns, previous);
579
+ failPromotion(ns, msg.tabId, msg.leadershipId, { timedOut: false });
526
580
  break;
527
581
  }
528
582
  case 'follower-port-attached': {
package/src/sp00ky.ts CHANGED
@@ -492,15 +492,16 @@ export class Sp00kyClient<S extends SchemaStructure> {
492
492
  this.streamProcessor = new StreamProcessorService(
493
493
  new EventSystem(['stream_update']),
494
494
  this.local,
495
- this.persistenceClient,
496
495
  logger
497
496
  );
498
- // Circuit snapshots are opt-in and checkpointed, never per-ingest. See
499
- // `persistCircuit` in types.ts for the measurements behind that default.
497
+ // Circuit snapshots are checkpointed, never per-ingest, and on by default
498
+ // where the store can hold them (the OPFS SQLite engine). See
499
+ // `persistCircuit` / `circuitProjection` in types.ts.
500
500
  this.streamProcessor.configureCircuitPersistence(
501
- config.persistCircuit ?? false,
501
+ config.persistCircuit ?? config.localEngine === 'sqlite',
502
502
  config.circuitCheckpointMs
503
503
  );
504
+ this.streamProcessor.configureProjection(config.circuitProjection ?? true);
504
505
  this.migrator = new LocalMigrator(this.local, logger);
505
506
 
506
507
  this.cache = new CacheModule(
@@ -560,6 +561,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
560
561
  connectionSupervisor: this.connectionSupervisor,
561
562
  }
562
563
  );
564
+ this.sync.setPrimeGate(() => this.streamProcessor.whenPrimed());
563
565
 
564
566
  // Initialize feature flags. Reuses the down-queue to register SSP plans
565
567
  // on `_00_user_feature` and the auth subscription to re-register handles
@@ -661,13 +663,14 @@ export class Sp00kyClient<S extends SchemaStructure> {
661
663
  onLeaderLost: (reason) => engine.onLeaderLost(reason),
662
664
  exposeClientPort: (clientId, port) => engine.exposeClientPort(clientId, port),
663
665
  removeClientPort: (clientId) => engine.removeClientPort(clientId),
664
- becomeSyncLeader: async (hub) => {
666
+ becomeSyncLeader: (hub) => {
665
667
  this.streamProcessor.setPersistenceEnabled(true);
666
668
  this.cache.setIngestRelay((tuples) => hub.relayIngest(tuples));
667
669
  this.sync.setTabContext('leader', tabId);
668
670
  this.dataModule.setTabId(tabId);
669
- await this.sync.promoteToLeader(hub);
671
+ this.sync.promoteToLeader(hub);
670
672
  },
673
+ resumeSyncLeaderDuties: () => this.sync.resumeLeaderDuties(),
671
674
  becomeSyncFollower: (forwarder) => {
672
675
  this.streamProcessor.setPersistenceEnabled(false);
673
676
  this.cache.setIngestRelay(null);
@@ -812,6 +815,16 @@ export class Sp00kyClient<S extends SchemaStructure> {
812
815
  // the current one instead of frozen at whatever boot saw.
813
816
  this.tabsCoordinator.onRoleChange((role) => {
814
817
  this.sharedActive = role !== 'solo';
818
+ // Surface the transition in DevTools. Reconstructing a failover from
819
+ // the `database.tabs` snapshot alone is guesswork after the fact; a
820
+ // discrete event gives `get_events` the leader handover directly.
821
+ this.devTools.logEvent('TABS_ROLE_CHANGED', {
822
+ role,
823
+ tabId: this.tabsCoordinator?.tabId,
824
+ leadershipId: this.tabsCoordinator?.leadershipId,
825
+ leaderTabId: this.tabsCoordinator?.leaderTabId,
826
+ promotionMs: this.tabsCoordinator?.lastPromotionMs,
827
+ });
815
828
  });
816
829
  try {
817
830
  const role = await this.tabsCoordinator.start(bootBucket);
@@ -862,11 +875,14 @@ export class Sp00kyClient<S extends SchemaStructure> {
862
875
  }
863
876
  })();
864
877
 
865
- this.streamProcessor.setStateKeySuffix(bootBucket);
866
878
  await this.streamProcessor.init();
867
879
  // Seed table `select` permissions from the schema before any query is
868
880
  // registered — otherwise the SSP default-denies every non-`_00_` table.
869
881
  this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
882
+ // Fill the circuit from the local store in the background (snapshot +
883
+ // reconcile, or every cached row). Not awaited: first paint reads the
884
+ // store directly; sync waits on `whenPrimed` before its first diff.
885
+ void this.primeCircuit();
870
886
  this.logger.debug(
871
887
  { Category: 'sp00ky-client::Sp00kyClient::init' },
872
888
  'StreamProcessor initialized'
@@ -1179,10 +1195,10 @@ export class Sp00kyClient<S extends SchemaStructure> {
1179
1195
  'Blob cache bucket switch failed'
1180
1196
  );
1181
1197
  }
1182
- this.streamProcessor.setStateKeySuffix(target);
1183
1198
  await this.streamProcessor.reset();
1184
1199
  this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
1185
1200
  this.cache.clearVersionLookups();
1201
+ void this.primeCircuit();
1186
1202
  // Preload dedup is per-bucket: the `_00_preload` markers + cached rows it
1187
1203
  // guards live in the local store we just swapped away from. Keeping the
1188
1204
  // hashes would make `preload()` skip warming the NEW bucket (its store is
@@ -1226,6 +1242,8 @@ export class Sp00kyClient<S extends SchemaStructure> {
1226
1242
  // Flush the blob manifest while the local store is still open — the flush
1227
1243
  // writes `_00_blob` rows through it — and revoke every object URL.
1228
1244
  await this.blobs.close().catch(() => {});
1245
+ // Last chance to persist the circuit while the store is still ours.
1246
+ await this.streamProcessor.checkpoint('close').catch(() => {});
1229
1247
  // Leaving the broker first hands leadership to another tab (and releases
1230
1248
  // the OPFS handles via the worker shutdown) before the store closes.
1231
1249
  if (this.tabsCoordinator) await this.tabsCoordinator.stop();
@@ -1633,6 +1651,34 @@ export class Sp00kyClient<S extends SchemaStructure> {
1633
1651
  * the former to `encodeRecordId` reads `.table` off a string and throws
1634
1652
  * during boot.
1635
1653
  */
1654
+ /**
1655
+ * Prime the in-browser circuit from the local store. Builds the context the
1656
+ * stream processor needs: every synced table (the app schema plus the
1657
+ * server-written meta tables that sync down), a schema hash so a snapshot
1658
+ * projected under another schema is not trusted, and the ids whose local
1659
+ * `_00_rv` was bumped by an unsettled mutation.
1660
+ */
1661
+ private async primeCircuit(): Promise<void> {
1662
+ const tables = [
1663
+ ...this.config.schema.tables.map((t) => t.name),
1664
+ '_00_user_feature',
1665
+ '_00_app_release',
1666
+ ];
1667
+ let pendingIds = new Set<string>();
1668
+ try {
1669
+ const pending = await this.dataModule.getPendingRecordIds();
1670
+ pendingIds = new Set([...pending.writes, ...pending.deletes]);
1671
+ } catch {
1672
+ /* no outbox yet: nothing is pending */
1673
+ }
1674
+ await this.streamProcessor.primeFromLocal({
1675
+ tables,
1676
+ schemaHash: String(hash53(this.config.schemaSurql)),
1677
+ pendingIds,
1678
+ onVersions: (_table, entries) => this.cache.primeVersions(entries),
1679
+ });
1680
+ }
1681
+
1636
1682
  private sessionAuthId(): string | null {
1637
1683
  const id = this.auth.currentUser?.id;
1638
1684
  if (!id) return null;
package/src/types.ts CHANGED
@@ -205,21 +205,19 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
205
205
  */
206
206
  sharedTabs?: boolean;
207
207
  /**
208
- * Persist the in-browser SSP circuit (store + view caches) as a snapshot so a
209
- * reload can restore it instead of re-materializing. Default `false`, and
210
- * that default is deliberate.
208
+ * Persist the in-browser SSP circuit's store as a snapshot in the local
209
+ * store, so a reload restores it and only steps in what changed since.
210
+ * Defaults to `true` under `localEngine: 'sqlite'` (the OPFS-backed store
211
+ * can hold the bytes) and `false` otherwise.
211
212
  *
212
- * The circuit is DERIVED state: the durable local store (OPFS SQLite) is the
213
- * source of truth, and every first paint already reads row bodies from it
214
- * (`DataManager.createNewQuery` / `materializeRecords`) using the circuit only
215
- * for row identity and ordering. A snapshot buys nothing on reload while
216
- * costing a full deep clone of every row of every ingested table plus a JSON
217
- * encode of the result, `Circuit::save` in the Rust core, mirroring the
218
- * server's rule in `ssp-node`: *never per-ingest*.
213
+ * Without a snapshot the circuit is primed by reading every cached row back
214
+ * out of the local store; either way a reload never re-downloads the working
215
+ * set, which is what an empty circuit used to force: every id in the
216
+ * server's list_ref classified as missing.
219
217
  *
220
- * When enabled, snapshots are written on a checkpoint interval
221
- * ({@link circuitCheckpointMs}) and on `pagehide`, never per ingest or per
222
- * query registration. Enable only for a workload that has measured a win.
218
+ * Snapshots are store-only (views are re-registered per session) and written
219
+ * on a checkpoint interval ({@link circuitCheckpointMs}) and when the page
220
+ * goes hidden, never per ingest or per query registration.
223
221
  */
224
222
  persistCircuit?: boolean;
225
223
  /**
@@ -227,6 +225,16 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
227
225
  * 30000. Ignored when `persistCircuit` is off.
228
226
  */
229
227
  circuitCheckpointMs?: number;
228
+ /**
229
+ * Keep only the fields registered queries evaluate (filter predicates, join
230
+ * keys, sort keys, plus `id`/`_00_rv`) per row in the in-browser circuit.
231
+ * Default `true`. The circuit only ever reads those fields; bodies are
232
+ * rendered from the local store. Measured on 7700 rows with 20 KB bodies:
233
+ * the wasm heap peak went from ~500 MB to ~24 MB and the snapshot from
234
+ * 156 MB to 1.2 MB. A query that evaluates a field earlier rows were kept
235
+ * without has that field merged in from the local store on registration.
236
+ */
237
+ circuitProjection?: boolean;
230
238
  /** A pino browser transmit object for forwarding logs (e.g. via @spooky-sync/core/otel). */
231
239
  otelTransmit?: PinoTransmit;
232
240
  /**