@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.
@@ -1,6 +1,13 @@
1
1
  //#region src/services/tabs/tabs-broker-worker.ts
2
2
  const PING_INTERVAL_MS = 5e3;
3
3
  const PONG_TIMEOUT_MS = 15e3;
4
+ /** How long a tab has to answer `become-leader` with `leader-ready`. A tab
5
+ * that hangs mid-promotion sends neither `leader-ready` nor `leader-failed`,
6
+ * and keeps answering pings, so nothing else here would notice: the
7
+ * namespace would keep a leader that is not `ready`, mint no follower ports,
8
+ * and refuse to re-elect (`electIfNeeded` bails while `ns.leader` is set)
9
+ * until every tab was reloaded. Treat the silence as a failed promotion. */
10
+ const PROMOTION_TIMEOUT_MS = 2e4;
4
11
  const FORCE_TAKEOVER_TIMEOUT_MS = 1e3;
5
12
  const LEADER_FAILURE_BACKOFF_MS = 1e3;
6
13
  const FAILED_CYCLES_BEFORE_MEMORY = 3;
@@ -35,7 +42,8 @@ function getNamespace(fingerprint, bucketId) {
35
42
  failedCycles: 0,
36
43
  electing: false,
37
44
  attachRetry: /* @__PURE__ */ new Map(),
38
- tabLockMonitor: null
45
+ tabLockMonitor: null,
46
+ promotionTimer: null
39
47
  };
40
48
  namespaces.set(key, ns);
41
49
  }
@@ -63,6 +71,7 @@ function clearLeader(ns, opts) {
63
71
  const leader = ns.leader;
64
72
  if (!leader) return null;
65
73
  ns.leader = null;
74
+ clearPromotionTimer(ns);
66
75
  ns.tabLockMonitor?.abort();
67
76
  ns.tabLockMonitor = null;
68
77
  const tab = ns.tabs.get(leader.tabId);
@@ -86,6 +95,39 @@ function clearLeader(ns, opts) {
86
95
  workerLock
87
96
  };
88
97
  }
98
+ function clearPromotionTimer(ns) {
99
+ if (ns.promotionTimer) clearTimeout(ns.promotionTimer);
100
+ ns.promotionTimer = null;
101
+ }
102
+ /**
103
+ * A promotion did not work out: back the tab off, clear the leadership, and
104
+ * re-elect. Shared by the tab's own `leader-failed` report and by the
105
+ * {@link PROMOTION_TIMEOUT_MS} deadline, so a tab that hangs mid-promotion
106
+ * costs the namespace the same 1s backoff as one that fails loudly, instead of
107
+ * owning it forever.
108
+ *
109
+ * Every reason counts toward `failedCycles`. Gating that on 'opfs-unavailable'
110
+ * left any other recurring failure (e.g. a tab lock nobody frees) looping at
111
+ * the backoff interval forever, with allowMemoryFallback never granted, so
112
+ * every tab timed out in start() and booted solo - each one then contending
113
+ * for the OPFS pool on its own, which is exactly what shared-tabs exists to
114
+ * stop.
115
+ */
116
+ function failPromotion(ns, tabId, leadershipId, opts) {
117
+ if (ns.leader?.tabId !== tabId || ns.leader.leadershipId !== leadershipId) return;
118
+ ns.failedCycles += 1;
119
+ ns.failedUntil.set(tabId, Date.now() + LEADER_FAILURE_BACKOFF_MS);
120
+ const previous = clearLeader(ns, {
121
+ demote: false,
122
+ removeTab: false
123
+ });
124
+ const tab = ns.tabs.get(tabId);
125
+ if (tab) tab.heldLeadership = null;
126
+ electIfNeeded(ns, opts.timedOut && previous ? {
127
+ ...previous,
128
+ workerLock: `sp00ky-tabs:${ns.fingerprint}:${ns.bucketId}:worker:${leadershipId}`
129
+ } : previous);
130
+ }
89
131
  /**
90
132
  * Fast crash detection: queue a Web Lock request on the leader's tab lock.
91
133
  * Locks release the instant their holder's tab dies, so being GRANTED means
@@ -174,6 +216,11 @@ function electIfNeeded(ns, previous = null) {
174
216
  allowMemoryFallback: ns.failedCycles >= FAILED_CYCLES_BEFORE_MEMORY,
175
217
  resumeHeld
176
218
  });
219
+ clearPromotionTimer(ns);
220
+ ns.promotionTimer = setTimeout(() => {
221
+ ns.promotionTimer = null;
222
+ failPromotion(ns, candidate.tabId, leadershipId, { timedOut: true });
223
+ }, PROMOTION_TIMEOUT_MS);
177
224
  } finally {
178
225
  ns.electing = false;
179
226
  }
@@ -359,6 +406,7 @@ function handleTabMessage(port, msg, ports) {
359
406
  }
360
407
  ns.leader.ready = true;
361
408
  ns.failedCycles = 0;
409
+ clearPromotionTimer(ns);
362
410
  startTabLockMonitor(ns, msg.leadershipId);
363
411
  const tab = ns.tabs.get(msg.tabId);
364
412
  if (tab) tab.heldLeadership = {
@@ -374,19 +422,9 @@ function handleTabMessage(port, msg, ports) {
374
422
  assignFollowerPorts(ns);
375
423
  break;
376
424
  }
377
- case "leader-failed": {
378
- if (ns.leader?.tabId !== msg.tabId || ns.leader.leadershipId !== msg.leadershipId) break;
379
- ns.failedCycles += 1;
380
- ns.failedUntil.set(msg.tabId, Date.now() + LEADER_FAILURE_BACKOFF_MS);
381
- const previous = clearLeader(ns, {
382
- demote: false,
383
- removeTab: false
384
- });
385
- const tab = ns.tabs.get(msg.tabId);
386
- if (tab) tab.heldLeadership = null;
387
- electIfNeeded(ns, previous);
425
+ case "leader-failed":
426
+ failPromotion(ns, msg.tabId, msg.leadershipId, { timedOut: false });
388
427
  break;
389
- }
390
428
  case "follower-port-attached":
391
429
  if (ns.leader?.tabId === msg.tabId && ns.leader.leadershipId === msg.leadershipId) followerAttached(ns, msg.followerTabId);
392
430
  break;
package/dist/types.d.ts CHANGED
@@ -334,6 +334,37 @@ interface LocalStore extends LocalCacheEngine {
334
334
  /** Fires immediately with the current snapshot, then on every change.
335
335
  * Returns an unsubscribe function. */
336
336
  subscribeToStorageHealth?(cb: (health: StorageHealth) => void): () => void;
337
+ /**
338
+ * Every cached row's `(id, _00_rv)` per table, ids in stable `table:id`
339
+ * form. The in-browser circuit primes and reconciles itself from this on
340
+ * boot instead of re-downloading the working set. OPTIONAL: an engine
341
+ * without it boots the circuit empty, as before.
342
+ */
343
+ scanVersions?(tables: string[]): Promise<Record<string, [string, number][]>>;
344
+ /**
345
+ * Circuit snapshot storage, keyed. Lives in the same durable store as the
346
+ * rows (OPFS SQLite), so it is per-bucket by construction, atomic, and
347
+ * readable by follower tabs over the port transport. OPTIONAL: an engine
348
+ * without it primes from `scanVersions` alone.
349
+ */
350
+ getSnapshot?(key: string): Promise<StoredSnapshot | null>;
351
+ putSnapshot?(key: string, bytes: Uint8Array, meta: SnapshotMeta): Promise<void>;
352
+ deleteSnapshot?(key: string): Promise<void>;
353
+ }
354
+ /** Describes a stored circuit snapshot; what decides whether it is usable. */
355
+ interface SnapshotMeta {
356
+ /** Bump when the wire shape the circuit reads changes. */
357
+ formatVersion: number;
358
+ /** Hash of the schema the rows were projected under. */
359
+ schemaHash: string;
360
+ savedAt: number;
361
+ /** Highest `_00_rv` per table at save time (diagnostics). */
362
+ maxRv?: Record<string, number>;
363
+ [key: string]: unknown;
364
+ }
365
+ interface StoredSnapshot {
366
+ bytes: Uint8Array;
367
+ meta: SnapshotMeta;
337
368
  }
338
369
  /** Selected local cache backend. Mirrors the `persistenceClient` config pattern. */
339
370
  type LocalEngineChoice = 'surrealdb' | 'sqlite' | LocalStore;
@@ -538,21 +569,19 @@ interface Sp00kyConfig<S extends SchemaStructure> {
538
569
  */
539
570
  sharedTabs?: boolean;
540
571
  /**
541
- * Persist the in-browser SSP circuit (store + view caches) as a snapshot so a
542
- * reload can restore it instead of re-materializing. Default `false`, and
543
- * that default is deliberate.
572
+ * Persist the in-browser SSP circuit's store as a snapshot in the local
573
+ * store, so a reload restores it and only steps in what changed since.
574
+ * Defaults to `true` under `localEngine: 'sqlite'` (the OPFS-backed store
575
+ * can hold the bytes) and `false` otherwise.
544
576
  *
545
- * The circuit is DERIVED state: the durable local store (OPFS SQLite) is the
546
- * source of truth, and every first paint already reads row bodies from it
547
- * (`DataManager.createNewQuery` / `materializeRecords`) using the circuit only
548
- * for row identity and ordering. A snapshot buys nothing on reload while
549
- * costing a full deep clone of every row of every ingested table plus a JSON
550
- * encode of the result, `Circuit::save` in the Rust core, mirroring the
551
- * server's rule in `ssp-node`: *never per-ingest*.
577
+ * Without a snapshot the circuit is primed by reading every cached row back
578
+ * out of the local store; either way a reload never re-downloads the working
579
+ * set, which is what an empty circuit used to force: every id in the
580
+ * server's list_ref classified as missing.
552
581
  *
553
- * When enabled, snapshots are written on a checkpoint interval
554
- * ({@link circuitCheckpointMs}) and on `pagehide`, never per ingest or per
555
- * query registration. Enable only for a workload that has measured a win.
582
+ * Snapshots are store-only (views are re-registered per session) and written
583
+ * on a checkpoint interval ({@link circuitCheckpointMs}) and when the page
584
+ * goes hidden, never per ingest or per query registration.
556
585
  */
557
586
  persistCircuit?: boolean;
558
587
  /**
@@ -560,6 +589,16 @@ interface Sp00kyConfig<S extends SchemaStructure> {
560
589
  * 30000. Ignored when `persistCircuit` is off.
561
590
  */
562
591
  circuitCheckpointMs?: number;
592
+ /**
593
+ * Keep only the fields registered queries evaluate (filter predicates, join
594
+ * keys, sort keys, plus `id`/`_00_rv`) per row in the in-browser circuit.
595
+ * Default `true`. The circuit only ever reads those fields; bodies are
596
+ * rendered from the local store. Measured on 7700 rows with 20 KB bodies:
597
+ * the wasm heap peak went from ~500 MB to ~24 MB and the snapshot from
598
+ * 156 MB to 1.2 MB. A query that evaluates a field earlier rows were kept
599
+ * without has that field merged in from the local store on registration.
600
+ */
601
+ circuitProjection?: boolean;
563
602
  /** A pino browser transmit object for forwarding logs (e.g. via @spooky-sync/core/otel). */
564
603
  otelTransmit?: PinoTransmit;
565
604
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.204",
3
+ "version": "0.0.1-canary.205",
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.204",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.204",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.205",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.205",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "blurhash": "^2.0.5",
@@ -88,6 +88,19 @@ export class CacheModule implements StreamUpdateReceiver {
88
88
  return this.versionLookups[recordId] ?? 0;
89
89
  }
90
90
 
91
+ /**
92
+ * Seed the version memo from rows the circuit was primed with out of the
93
+ * local store, so the first post-reload sync diff does not re-download
94
+ * bodies the browser already has. Only rows the prime actually put into the
95
+ * circuit belong here: a memo entry with no circuit row would make the diff
96
+ * flag the id forever while nothing ever fetches it.
97
+ */
98
+ public primeVersions(entries: [string, number][]): void {
99
+ for (const [id, rv] of entries) {
100
+ if (rv > (this.versionLookups[id] ?? 0)) this.versionLookups[id] = rv;
101
+ }
102
+ }
103
+
91
104
  /** Drop the version cache on a bucket switch — a stale version would make
92
105
  * the sync diff skip fetching a body the new bucket legitimately needs. */
93
106
  public clearVersionLookups(): void {
@@ -174,13 +187,17 @@ export class CacheModule implements StreamUpdateReceiver {
174
187
  // ingestMany coalesces the per-record stream updates into a single
175
188
  // notification per affected query — the UI then updates once, after the
176
189
  // whole batch is ingested, instead of row-by-row.
190
+ const versionOf = new Map<string, number>();
177
191
  const bulk = populatedRecords.map((record) => {
178
192
  const recordId = encodeRecordId(record.record.id);
179
- this.versionLookups[recordId] = record.version;
193
+ versionOf.set(recordId, record.version);
180
194
  return { table: record.table, op: record.op, id: recordId, record: record.record };
181
195
  });
182
- this.streamProcessor.ingestMany(bulk);
183
- this.ingestRelay?.(bulk as CacheIngestTuple[]);
196
+ // Memo and relay only what the circuit actually took: a chunk that
197
+ // failed mid-batch is neither "known" here nor fanned out to followers.
198
+ const ingested = this.streamProcessor.ingestMany(bulk);
199
+ for (const t of ingested) this.versionLookups[t.id] = versionOf.get(t.id) ?? 0;
200
+ if (ingested.length > 0) this.ingestRelay?.(ingested as CacheIngestTuple[]);
184
201
 
185
202
  this.logger.debug(
186
203
  { count: records.length, Category: 'sp00ky-client::CacheModule::saveBatch' },
@@ -221,7 +238,7 @@ export class CacheModule implements StreamUpdateReceiver {
221
238
 
222
239
  // 2. Ingest deletion into DBSP (pass record data so predicates can be matched)
223
240
  delete this.versionLookups[id];
224
- this.streamProcessor.ingest(table, 'DELETE', id, recordData);
241
+ this.streamProcessor.ingestMany([{ table, op: 'DELETE', id, record: recordData }]);
225
242
  this.ingestRelay?.([{ table, op: 'DELETE', id, record: recordData }]);
226
243
 
227
244
  this.logger.debug(
@@ -327,7 +327,9 @@ export class DevToolsService implements StreamUpdateReceiver {
327
327
  const info = this.unwrapInfo(res);
328
328
  this.localTablesAt = Date.now();
329
329
  if (info && info.tables) {
330
- const names = Object.keys(info.tables);
330
+ // The circuit snapshot table holds a BLOB, not JSON rows; the
331
+ // explorer cannot render it and has nothing to show for it.
332
+ const names = Object.keys(info.tables).filter((n) => n !== '_00_circuit_snapshot');
331
333
  const changed =
332
334
  names.length !== this.localTables.length ||
333
335
  names.some((n, i) => n !== this.localTables[i]);
@@ -8,6 +8,9 @@ import { SyncEventTypes, createSyncEventSystem } from './events/index';
8
8
  import { encodeRecordId } from '../../utils/index';
9
9
  import { cleanRecord } from '../../utils/parser';
10
10
 
11
+ /** Ids per remote `SELECT * FROM $ids` round trip. */
12
+ const FETCH_CHUNK = 500;
13
+
11
14
  /**
12
15
  * SyncEngine handles the core sync operations: fetching remote records,
13
16
  * caching them locally, and ingesting into DBSP.
@@ -55,10 +58,16 @@ export class SyncEngine {
55
58
  stillRemoteIds = await this.handleRemovedRecords(removed);
56
59
  }
57
60
 
58
- // Fetch added/updated records from remote
59
- const toFetch = [...added, ...updated];
60
- const idsToFetch = toFetch.map((x) => x.id);
61
- if (idsToFetch.length === 0) {
61
+ // Fetch added/updated records from remote. Skip ids whose body the local
62
+ // store already holds at this version (the circuit prime seeded the memo
63
+ // from `_00_rv`): after a reload every id in the server's list_ref used to
64
+ // classify as `added` against an empty circuit, and this fetched the
65
+ // whole working set again.
66
+ const toFetch = [...added, ...updated].filter((item) => {
67
+ const localVersion = this.cache.lookup(encodeRecordId(item.id));
68
+ return !(localVersion && item.version <= localVersion);
69
+ });
70
+ if (toFetch.length === 0) {
62
71
  return { remoteFetchMs: 0, stillRemoteIds };
63
72
  }
64
73
 
@@ -67,66 +76,75 @@ export class SyncEngine {
67
76
  for (const item of toFetch) {
68
77
  versionMap.set(encodeRecordId(item.id), item.version);
69
78
  }
79
+ const addedIds = new Set(added.map((item) => encodeRecordId(item.id)));
70
80
 
71
81
  // Fetch records from remote — avoid SELECT *, <subquery> FROM $param
72
82
  // pattern which drops the * fields in SurrealDB v3 (known bug).
73
83
  // Versions are already known from the diff's list_ref data.
74
- const remoteFetchStart = performance.now();
75
- const [remoteResults] = await this.remote.query<[RecordWithId[]]>(
76
- 'SELECT * FROM $idsToFetch',
77
- { idsToFetch }
78
- );
79
- const remoteFetchMs = performance.now() - remoteFetchStart;
80
-
81
- // Prepare batch for cache (which handles both DB and DBSP)
82
- const cacheBatch: CacheRecord[] = [];
83
-
84
- for (const record of remoteResults) {
85
- if (!record?.id) {
86
- this.logger.warn(
87
- {
88
- record,
89
- idsToFetch,
90
- Category: 'sp00ky-client::SyncEngine::syncRecords',
91
- },
92
- 'Remote record has no id (possibly deleted). Skipping record'
93
- );
94
- continue;
95
- }
96
- const fullId = encodeRecordId(record.id);
97
- const table = record.id.table.toString();
98
- const isAdded = added.some((item) => encodeRecordId(item.id) === fullId);
99
- const version = versionMap.get(fullId) ?? 0;
100
-
101
- const localVersion = this.cache.lookup(fullId);
102
- if (localVersion && version <= localVersion) {
103
- this.logger.info(
104
- {
105
- recordId: fullId,
106
- version,
107
- localVersion,
108
- Category: 'sp00ky-client::SyncEngine::syncRecords',
109
- },
110
- 'Local version is higher than remote version. Skipping record'
111
- );
112
- continue;
84
+ //
85
+ // Chunked so a genuinely cold load lands progressively: one response
86
+ // holding thousands of bodies, then one transaction MERGEing all of them,
87
+ // was a single main-thread stall and a memory spike on top of it.
88
+ let remoteFetchMs = 0;
89
+ const remoteResults: RecordWithId[] = [];
90
+ for (let i = 0; i < toFetch.length; i += FETCH_CHUNK) {
91
+ const idsToFetch = toFetch.slice(i, i + FETCH_CHUNK).map((x) => x.id);
92
+ const remoteFetchStart = performance.now();
93
+ const [chunkResults] = await this.remote.query<[RecordWithId[]]>(
94
+ 'SELECT * FROM $idsToFetch',
95
+ { idsToFetch }
96
+ );
97
+ remoteFetchMs += performance.now() - remoteFetchStart;
98
+
99
+ // Prepare batch for cache (which handles both DB and DBSP)
100
+ const cacheBatch: CacheRecord[] = [];
101
+ for (const record of chunkResults ?? []) {
102
+ if (!record?.id) {
103
+ this.logger.warn(
104
+ {
105
+ record,
106
+ idsToFetch,
107
+ Category: 'sp00ky-client::SyncEngine::syncRecords',
108
+ },
109
+ 'Remote record has no id (possibly deleted). Skipping record'
110
+ );
111
+ continue;
112
+ }
113
+ remoteResults.push(record);
114
+ const fullId = encodeRecordId(record.id);
115
+ const table = record.id.table.toString();
116
+ const version = versionMap.get(fullId) ?? 0;
117
+
118
+ const localVersion = this.cache.lookup(fullId);
119
+ if (localVersion && version <= localVersion) {
120
+ this.logger.info(
121
+ {
122
+ recordId: fullId,
123
+ version,
124
+ localVersion,
125
+ Category: 'sp00ky-client::SyncEngine::syncRecords',
126
+ },
127
+ 'Local version is higher than remote version. Skipping record'
128
+ );
129
+ continue;
130
+ }
131
+ const tableSchema = this.schema.tables.find((t) => t.name === table);
132
+ const cleanedRecord = tableSchema
133
+ ? cleanRecord(tableSchema.columns, record)
134
+ : record;
135
+
136
+ cacheBatch.push({
137
+ table,
138
+ op: addedIds.has(fullId) ? 'CREATE' : 'UPDATE',
139
+ record: cleanedRecord as RecordWithId,
140
+ version,
141
+ });
113
142
  }
114
- const tableSchema = this.schema.tables.find((t) => t.name === table);
115
- const cleanedRecord = tableSchema
116
- ? cleanRecord(tableSchema.columns, record)
117
- : record;
118
-
119
- cacheBatch.push({
120
- table,
121
- op: isAdded ? 'CREATE' : 'UPDATE',
122
- record: cleanedRecord as RecordWithId,
123
- version,
124
- });
125
- }
126
143
 
127
- // Use CacheModule to handle both local DB and DBSP ingestion
128
- if (cacheBatch.length > 0) {
129
- await this.cache.saveBatch(cacheBatch);
144
+ // Use CacheModule to handle both local DB and DBSP ingestion
145
+ if (cacheBatch.length > 0) {
146
+ await this.cache.saveBatch(cacheBatch);
147
+ }
130
148
  }
131
149
 
132
150
  this.events.emit(SyncEventTypes.RemoteDataIngested, {
@@ -527,14 +527,40 @@ export class Sp00kySync<S extends SchemaStructure> {
527
527
  this.tabId = tabId;
528
528
  }
529
529
 
530
- /** Leader duties: drain the shared outbox, own the single list_ref LIVE,
531
- * relay LIVE events and rollbacks to followers via `hub`. Idempotent for a
532
- * boot-time leader; a runtime promotion (failover) reloads the outbox,
533
- * which now holds EVERY tab's rows, and restarts LIVE under this session. */
534
- public async promoteToLeader(hub: LeaderSyncHub): Promise<void> {
530
+ /** In-flight {@link resumeLeaderDuties}, so a second call joins the first
531
+ * instead of double-draining the outbox. */
532
+ private leaderDutiesInFlight: Promise<void> | null = null;
533
+
534
+ /** Resolves once the in-browser circuit has been primed from the local
535
+ * store. Every sync diff waits on it: diffing against an empty circuit
536
+ * classifies the whole working set as missing and re-downloads it. */
537
+ private primeGate: () => Promise<void> = () => Promise.resolve();
538
+ /** The prime we last waited on. `whenPrimed` hands out one promise per
539
+ * prime, so a new identity means a new prime (boot, bucket switch) ran. */
540
+ private settledPrime: Promise<void> | null = null;
541
+
542
+ setPrimeGate(gate: () => Promise<void>): void {
543
+ this.primeGate = gate;
544
+ this.settledPrime = null;
545
+ }
546
+
547
+ /**
548
+ * Leader WIRING only, and deliberately synchronous.
549
+ *
550
+ * The coordinator publishes the leader role and tells the broker
551
+ * `leader-ready` the moment the store is adopted, and the broker can mint a
552
+ * follower's ports on the very next tick. So the follower-message handler
553
+ * has to be live before this returns, or a mutation forwarded in that window
554
+ * is dropped. Everything that can block (outbox reload, LIVE restart) moved
555
+ * to {@link resumeLeaderDuties}: a promotion that waits on the network holds
556
+ * `leader-ready` back, and a broker whose leader never reports ready serves
557
+ * no follower ports and re-elects no one, which wedges the whole namespace.
558
+ */
559
+ public promoteToLeader(hub: LeaderSyncHub): void {
535
560
  this.tabRole = 'leader';
536
561
  this.hub = hub;
537
562
  this.forwarder = null;
563
+ this.leaderDutiesInFlight = null;
538
564
  hub.onFollowerMessage = (tabId, msg) => {
539
565
  void tabId;
540
566
  switch (msg.type) {
@@ -548,19 +574,34 @@ export class Sp00kySync<S extends SchemaStructure> {
548
574
  break;
549
575
  }
550
576
  };
551
- if (this.isInit) {
577
+ }
578
+
579
+ /** Leader duties: drain the shared outbox, own the single list_ref LIVE,
580
+ * relay LIVE events and rollbacks to followers. Idempotent for a boot-time
581
+ * leader; a runtime promotion (failover) reloads the outbox, which now
582
+ * holds EVERY tab's rows, and restarts LIVE under this session. Runs in the
583
+ * background off the promotion path, so however long it takes (or if it
584
+ * never finishes) the tab is already a working leader. */
585
+ public resumeLeaderDuties(): Promise<void> {
586
+ if (this.leaderDutiesInFlight) return this.leaderDutiesInFlight;
587
+ const run = (async () => {
588
+ if (!this.isInit || this.tabRole !== 'leader') return;
552
589
  await this.upQueue.loadFromDatabase();
553
590
  void this.scheduler.syncUp();
554
591
  if (this.currentUserId || this.anonLiveEnabled) {
555
592
  this.startListRefPoll();
556
593
  await this.restartRefLiveQuery().catch((err) => {
557
594
  this.logger.warn(
558
- { err, Category: 'sp00ky-client::Sp00kySync::promoteToLeader' },
595
+ { err, Category: 'sp00ky-client::Sp00kySync::resumeLeaderDuties' },
559
596
  'LIVE restart failed on promotion; poll fallback covers it'
560
597
  );
561
598
  });
562
599
  }
563
- }
600
+ })();
601
+ this.leaderDutiesInFlight = run;
602
+ return run.finally(() => {
603
+ if (this.leaderDutiesInFlight === run) this.leaderDutiesInFlight = null;
604
+ });
564
605
  }
565
606
 
566
607
  /** Follower duties: no outbox drain, no LIVE. Mutations forward to the
@@ -570,6 +611,9 @@ export class Sp00kySync<S extends SchemaStructure> {
570
611
  this.tabRole = 'follower';
571
612
  this.hub = null;
572
613
  this.forwarder = forwarder;
614
+ // A still-running resumeLeaderDuties self-cancels on its `tabRole` guard;
615
+ // drop the handle so a later re-promotion starts a fresh drain.
616
+ this.leaderDutiesInFlight = null;
573
617
  void this.killRefLiveQuery();
574
618
  forwarder.onLeaderMessage = (msg) => {
575
619
  switch (msg.type) {
@@ -1531,6 +1575,19 @@ export class Sp00kySync<S extends SchemaStructure> {
1531
1575
  * means the single resulting UI update lands after this completes.
1532
1576
  */
1533
1577
  private async runSyncForQuery(hash: string, diff: RecordVersionDiff): Promise<void> {
1578
+ // The diff was computed against whatever the circuit held at call time; if
1579
+ // the boot prime is still filling it, wait and recompute from the primed
1580
+ // `localArray` so the delta is real rather than "everything".
1581
+ const prime = this.primeGate();
1582
+ if (prime !== this.settledPrime) {
1583
+ await prime;
1584
+ this.settledPrime = prime;
1585
+ const fresh = this.dataModule.getQueryByHash(hash);
1586
+ if (!fresh) return;
1587
+ const recomputed = new ArraySyncer(fresh.config.localArray, fresh.config.remoteArray).nextSet();
1588
+ if (!recomputed) return;
1589
+ diff = recomputed;
1590
+ }
1534
1591
  // Don't let sync re-add a record the user just deleted locally. The remote
1535
1592
  // delete is queued in the outbox, so until it's processed the server's
1536
1593
  // `_00_list_ref` still lists the record — the diff then classifies it as
@@ -138,6 +138,39 @@ export interface LocalStore extends LocalCacheEngine {
138
138
  /** Fires immediately with the current snapshot, then on every change.
139
139
  * Returns an unsubscribe function. */
140
140
  subscribeToStorageHealth?(cb: (health: StorageHealth) => void): () => void;
141
+ /**
142
+ * Every cached row's `(id, _00_rv)` per table, ids in stable `table:id`
143
+ * form. The in-browser circuit primes and reconciles itself from this on
144
+ * boot instead of re-downloading the working set. OPTIONAL: an engine
145
+ * without it boots the circuit empty, as before.
146
+ */
147
+ scanVersions?(tables: string[]): Promise<Record<string, [string, number][]>>;
148
+ /**
149
+ * Circuit snapshot storage, keyed. Lives in the same durable store as the
150
+ * rows (OPFS SQLite), so it is per-bucket by construction, atomic, and
151
+ * readable by follower tabs over the port transport. OPTIONAL: an engine
152
+ * without it primes from `scanVersions` alone.
153
+ */
154
+ getSnapshot?(key: string): Promise<StoredSnapshot | null>;
155
+ putSnapshot?(key: string, bytes: Uint8Array, meta: SnapshotMeta): Promise<void>;
156
+ deleteSnapshot?(key: string): Promise<void>;
157
+ }
158
+
159
+ /** Describes a stored circuit snapshot; what decides whether it is usable. */
160
+ export interface SnapshotMeta {
161
+ /** Bump when the wire shape the circuit reads changes. */
162
+ formatVersion: number;
163
+ /** Hash of the schema the rows were projected under. */
164
+ schemaHash: string;
165
+ savedAt: number;
166
+ /** Highest `_00_rv` per table at save time (diagnostics). */
167
+ maxRv?: Record<string, number>;
168
+ [key: string]: unknown;
169
+ }
170
+
171
+ export interface StoredSnapshot {
172
+ bytes: Uint8Array;
173
+ meta: SnapshotMeta;
141
174
  }
142
175
 
143
176
  /** Selected local cache backend. Mirrors the `persistenceClient` config pattern. */
@@ -556,3 +556,61 @@ describe('create() tx result shaping (fast path parity)', () => {
556
556
  expect(outboxRow.data).toEqual(payload);
557
557
  });
558
558
  });
559
+
560
+
561
+ // The circuit snapshot + version scan the boot-time prime reads through this
562
+ // engine. Bytes and meta must round-trip as ONE batch (a reader must never see
563
+ // new bytes with old meta) and a table that does not exist yet scans as empty.
564
+ describe('SqliteCacheEngine circuit snapshot + scanVersions', () => {
565
+ it('stores bytes and meta in one batch and reads them back', async () => {
566
+ const stored = new Map<string, unknown>();
567
+ const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
568
+ const types: string[] = [];
569
+ stubTransport(engine, (type: string, payload: any) => {
570
+ types.push(type);
571
+ if (type === 'open') return { persisted: true };
572
+ if (type === 'batch') {
573
+ for (const stmt of payload as { sql: string; bind: unknown[] }[]) {
574
+ stored.set(stmt.bind[0] as string, stmt.bind[1]);
575
+ }
576
+ return {};
577
+ }
578
+ if (type === 'exec') {
579
+ const ids = (payload.bind ?? []) as string[];
580
+ return { rows: ids.filter((id) => stored.has(id)).map((id) => ({ id, data: stored.get(id) })) };
581
+ }
582
+ return {};
583
+ });
584
+ await engine.connect('anon');
585
+
586
+ expect(await engine.getSnapshot('circuit')).toBeNull();
587
+ const bytes = new Uint8Array([1, 2, 3]);
588
+ await engine.putSnapshot('circuit', bytes, { formatVersion: 1, schemaHash: 'h', savedAt: 5 });
589
+ expect(types.filter((t) => t === 'batch')).toHaveLength(1);
590
+ const snap = await engine.getSnapshot('circuit');
591
+ expect(snap?.bytes).toEqual(bytes);
592
+ expect(snap?.meta).toEqual({ formatVersion: 1, schemaHash: 'h', savedAt: 5 });
593
+ });
594
+
595
+ it('scans (id, rv) per table and reads a missing table as empty', async () => {
596
+ const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
597
+ stubTransport(engine, (type: string, payload: any) => {
598
+ if (type === 'open') return { persisted: true };
599
+ if (type === 'exec') {
600
+ if (String(payload.sql).includes('"thing"')) {
601
+ return { rows: [{ id: 'thing:a', rv: 3 }, { id: 'thing:b', rv: null }] };
602
+ }
603
+ throw new Error('no such table');
604
+ }
605
+ return {};
606
+ });
607
+ await engine.connect('anon');
608
+ expect(await engine.scanVersions(['thing', 'ghost'])).toEqual({
609
+ thing: [
610
+ ['thing:a', 3],
611
+ ['thing:b', 0],
612
+ ],
613
+ ghost: [],
614
+ });
615
+ });
616
+ });