@spooky-sync/core 0.0.1-canary.203 → 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.
Files changed (35) hide show
  1. package/dist/index.d.ts +170 -40
  2. package/dist/index.js +733 -187
  3. package/dist/sqlite-open.js +28 -1
  4. package/dist/sqlite-worker.js +11 -8
  5. package/dist/tabs-broker-worker.js +51 -13
  6. package/dist/types.d.ts +58 -13
  7. package/package.json +3 -3
  8. package/src/modules/cache/index.ts +21 -4
  9. package/src/modules/data/data.pending-ids.test.ts +125 -0
  10. package/src/modules/data/index.ts +64 -0
  11. package/src/modules/devtools/index.ts +3 -1
  12. package/src/modules/sync/engine.ts +76 -58
  13. package/src/modules/sync/queue/queue-down.test.ts +73 -0
  14. package/src/modules/sync/queue/queue-down.ts +59 -21
  15. package/src/modules/sync/scheduler.retry.test.ts +82 -1
  16. package/src/modules/sync/scheduler.ts +59 -4
  17. package/src/modules/sync/sync.ts +94 -8
  18. package/src/services/database/cache-engine.ts +33 -0
  19. package/src/services/database/sqlite-cache-engine.test.ts +58 -0
  20. package/src/services/database/sqlite-cache-engine.ts +84 -9
  21. package/src/services/database/sqlite-plan-sql.ts +32 -0
  22. package/src/services/database/sqlite-projection.test.ts +99 -0
  23. package/src/services/database/sqlite-select.ts +19 -9
  24. package/src/services/database/surreal-cache-engine.ts +20 -0
  25. package/src/services/stream-processor/index.ts +471 -225
  26. package/src/services/stream-processor/stream-processor.batch.test.ts +3 -13
  27. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  28. package/src/services/stream-processor/stream-processor.reset.test.ts +77 -67
  29. package/src/services/stream-processor/wasm-types.ts +32 -1
  30. package/src/services/tabs/broker.test.ts +49 -0
  31. package/src/services/tabs/coordinator.test.ts +6 -1
  32. package/src/services/tabs/coordinator.ts +35 -3
  33. package/src/services/tabs/tabs-broker-worker.ts +66 -12
  34. package/src/sp00ky.ts +55 -8
  35. package/src/types.ts +27 -13
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as renderWhereSql, c as resolveRelations, i as renderOrderSql, l as stableKey, o as reviveRow, r as project, s as serializeRow, t as PROMOTION_OPEN_OPTIONS } from "./sqlite-open.js";
1
+ import { a as renderOrderSql, c as serializeRow, i as projectedDataSql, l as resolveRelations, o as renderWhereSql, r as project, s as reviveRow, t as PROMOTION_OPEN_OPTIONS, u as stableKey } from "./sqlite-open.js";
2
2
  import { DateTime, Duration, RecordId, Surreal, Uuid, applyDiagnostics, createRemoteEngines } from "surrealdb";
3
3
  import { QueryBuilder, RecordId as RecordId$1, cyrb53 } from "@spooky-sync/query-builder";
4
4
  import pino from "pino";
@@ -1616,6 +1616,21 @@ var SurrealCacheEngine = class extends LocalDatabaseService {
1616
1616
  if (typeof id !== "string") return id;
1617
1617
  return new RecordId(table, id.startsWith(`${table}:`) ? id.slice(table.length + 1) : id);
1618
1618
  }
1619
+ /**
1620
+ * `(id, _00_rv)` of every row per table. Runs on the main thread against the
1621
+ * embedded engine (no worker here), which is why the circuit prime caps how
1622
+ * much it is willing to read through this path.
1623
+ */
1624
+ async scanVersions(tables) {
1625
+ const out = {};
1626
+ for (const table of tables) try {
1627
+ const [rows] = await this.query(`SELECT id, _00_rv FROM ${table};`);
1628
+ out[table] = (rows ?? []).map((r) => [stableKey(r.id), Number(r._00_rv) || 0]);
1629
+ } catch {
1630
+ out[table] = [];
1631
+ }
1632
+ return out;
1633
+ }
1619
1634
  async getById(table, id) {
1620
1635
  const [row] = await this.query("SELECT * FROM ONLY $__id;", { __id: this.toRecordId(table, id) });
1621
1636
  return row && typeof row === "object" ? row : null;
@@ -2136,8 +2151,10 @@ var PortSqliteTransport = class extends BaseTransport {
2136
2151
  * on a fresh bucket throws "no such table". Keep in sync with the systemSchema
2137
2152
  * block in `local-migrator.ts`.
2138
2153
  */
2154
+ const SNAPSHOT_TABLE = "_00_circuit_snapshot";
2139
2155
  const SYSTEM_TABLES = [
2140
2156
  "_00_stream_processor_state",
2157
+ "_00_circuit_snapshot",
2141
2158
  "_00_query",
2142
2159
  "_00_preload",
2143
2160
  "_00_window",
@@ -2259,7 +2276,7 @@ var SqliteCacheEngine = class {
2259
2276
  diag.freelistBytes = rows?.[0]?.freelist;
2260
2277
  if (opts?.tableCounts) {
2261
2278
  const { rows: tables } = await this.call("exec", { sql: "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" });
2262
- const names = (tables ?? []).map((r) => r.name);
2279
+ const names = (tables ?? []).map((r) => r.name).filter((n) => n !== SNAPSHOT_TABLE);
2263
2280
  if (names.length) {
2264
2281
  const sql = names.map((n) => `SELECT '${n.replace(/'/g, "''")}' AS t, COUNT(*) AS n FROM "${n.replace(/"/g, "\"\"")}"`).join(" UNION ALL ");
2265
2282
  const { rows: counts } = await this.call("exec", { sql });
@@ -2658,16 +2675,15 @@ var SqliteCacheEngine = class {
2658
2675
  }
2659
2676
  await this.ensureTable(plan.table);
2660
2677
  const bind = [];
2661
- let sql = `SELECT data FROM "${plan.table}"`;
2678
+ let sql = `SELECT ${plan.select ? projectedDataSql(plan.select, bind) : "data"} FROM "${plan.table}"`;
2662
2679
  if (plan.where && plan.where.length > 0) sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
2663
2680
  if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
2664
2681
  else sql += ` ORDER BY id`;
2665
2682
  if (plan.limit !== void 0) sql += ` LIMIT ${Number(plan.limit)}`;
2666
2683
  if (plan.offset !== void 0) sql += ` OFFSET ${Number(plan.offset)}`;
2667
2684
  const rows = await this.execRows(sql, bind);
2668
- const projected = plan.select ? rows.map((r) => project(r, plan.select)) : rows;
2669
- await resolveRelations(projected, plan.relations, this);
2670
- return projected;
2685
+ await resolveRelations(rows, plan.relations, this);
2686
+ return rows;
2671
2687
  }
2672
2688
  async fetchRelation(req) {
2673
2689
  getStats().relationFetches++;
@@ -2686,19 +2702,74 @@ var SqliteCacheEngine = class {
2686
2702
  if (ids.length === 0) return [];
2687
2703
  await this.ensureTable(table);
2688
2704
  const keys = ids.map(stableKey);
2689
- let sql = `SELECT data FROM "${table}" WHERE id IN (${keys.map(() => "?").join(", ")})`;
2705
+ const placeholders = keys.map(() => "?").join(", ");
2706
+ const bind = [];
2707
+ const dataCol = opts?.select ? projectedDataSql(opts.select, bind) : "data";
2708
+ bind.push(...keys);
2709
+ let sql = `SELECT ${dataCol} FROM "${table}" WHERE id IN (${placeholders})`;
2690
2710
  if (opts?.orderBy && opts.orderBy.length > 0) sql += renderOrderSql(opts.orderBy);
2691
- let rows = await this.execRows(sql, keys);
2711
+ let rows = await this.execRows(sql, bind);
2692
2712
  if (!opts?.orderBy || opts.orderBy.length === 0) {
2693
2713
  const pos = new Map(keys.map((k, i) => [k, i]));
2694
2714
  rows = rows.sort((a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0));
2695
2715
  }
2696
- return opts?.select ? rows.map((r) => project(r, opts.select)) : rows;
2716
+ return rows;
2697
2717
  }
2698
2718
  async getById(table, id) {
2699
2719
  await this.ensureTable(table);
2700
2720
  return (await this.execRows(`SELECT data FROM "${table}" WHERE id = ?`, [stableKey(id)]))[0] ?? null;
2701
2721
  }
2722
+ /**
2723
+ * `(id, _00_rv)` of every row per table, straight off the worker: no body
2724
+ * parse, one round trip per table. A table that does not exist yet reads as
2725
+ * empty rather than failing the whole scan.
2726
+ */
2727
+ async scanVersions(tables) {
2728
+ const out = {};
2729
+ for (const table of tables) try {
2730
+ const { rows } = await this.call("exec", { sql: `SELECT id, json_extract(data, '$._00_rv') AS rv FROM "${table.replace(/"/g, "\"\"")}"` });
2731
+ out[table] = (rows ?? []).map((r) => [r.id, Number(r.rv) || 0]);
2732
+ } catch {
2733
+ out[table] = [];
2734
+ }
2735
+ return out;
2736
+ }
2737
+ async getSnapshot(key) {
2738
+ const { rows } = await this.call("exec", {
2739
+ sql: `SELECT id, data FROM "${SNAPSHOT_TABLE}" WHERE id IN (?, ?)`,
2740
+ bind: [key, `${key}:meta`]
2741
+ });
2742
+ let bytes = null;
2743
+ let meta = null;
2744
+ for (const r of rows ?? []) if (r.id === key) {
2745
+ if (r.data instanceof Uint8Array) bytes = r.data;
2746
+ else if (r.data instanceof ArrayBuffer) bytes = new Uint8Array(r.data);
2747
+ else if (typeof r.data === "string") bytes = new TextEncoder().encode(r.data);
2748
+ } else if (typeof r.data === "string") try {
2749
+ meta = JSON.parse(r.data);
2750
+ } catch {
2751
+ meta = null;
2752
+ }
2753
+ return bytes && meta ? {
2754
+ bytes,
2755
+ meta
2756
+ } : null;
2757
+ }
2758
+ async putSnapshot(key, bytes, meta) {
2759
+ await this.call("batch", [{
2760
+ sql: `INSERT OR REPLACE INTO "${SNAPSHOT_TABLE}" (id, data) VALUES (?, ?)`,
2761
+ bind: [key, bytes]
2762
+ }, {
2763
+ sql: `INSERT OR REPLACE INTO "${SNAPSHOT_TABLE}" (id, data) VALUES (?, ?)`,
2764
+ bind: [`${key}:meta`, JSON.stringify(meta)]
2765
+ }]);
2766
+ }
2767
+ async deleteSnapshot(key) {
2768
+ await this.call("run", {
2769
+ sql: `DELETE FROM "${SNAPSHOT_TABLE}" WHERE id IN (?, ?)`,
2770
+ bind: [key, `${key}:meta`]
2771
+ });
2772
+ }
2702
2773
  async upsert(table, id, data, mode) {
2703
2774
  await this.ensureTable(table);
2704
2775
  const key = stableKey(id);
@@ -3586,6 +3657,15 @@ var DataModule = class DataModule {
3586
3657
  }
3587
3658
  settledWrites = /* @__PURE__ */ new Map();
3588
3659
  settledDeletes = /* @__PURE__ */ new Map();
3660
+ pendingIds = null;
3661
+ pendingIdsAt = 0;
3662
+ pendingIdsInflight = null;
3663
+ static PENDING_IDS_TTL_MS = 250;
3664
+ /** Drop the cached outbox ids. Cheap; call it on anything that could change
3665
+ * `_00_pending_mutations`. */
3666
+ invalidatePendingIds() {
3667
+ this.pendingIds = null;
3668
+ }
3589
3669
  /**
3590
3670
  * Grace period for a settled write. Long enough to cover an SSP round trip
3591
3671
  * that is running slowly (seconds, not milliseconds, when the edge path is
@@ -3604,6 +3684,7 @@ var DataModule = class DataModule {
3604
3684
  * disappear immediately, which is what makes this safe.
3605
3685
  */
3606
3686
  noteWriteSettled(recordId, mutationType) {
3687
+ this.invalidatePendingIds();
3607
3688
  const until = Date.now() + DataModule.SETTLED_WRITE_GRACE_MS;
3608
3689
  if (mutationType === "delete") this.settledDeletes.set(recordId, until);
3609
3690
  else this.settledWrites.set(recordId, until);
@@ -3989,6 +4070,24 @@ var DataModule = class DataModule {
3989
4070
  * briefly hide an optimistic write but never resurrects a deleted row.
3990
4071
  */
3991
4072
  async getPendingRecordIds() {
4073
+ const now = Date.now();
4074
+ const cached = this.pendingIds;
4075
+ if (cached && now - this.pendingIdsAt < DataModule.PENDING_IDS_TTL_MS) return {
4076
+ writes: new Set(cached.writes),
4077
+ deletes: new Set(cached.deletes)
4078
+ };
4079
+ if (!this.pendingIdsInflight) this.pendingIdsInflight = this.readPendingRecordIds().finally(() => {
4080
+ this.pendingIdsInflight = null;
4081
+ });
4082
+ const fresh = await this.pendingIdsInflight;
4083
+ return {
4084
+ writes: new Set(fresh.writes),
4085
+ deletes: new Set(fresh.deletes)
4086
+ };
4087
+ }
4088
+ /** The uncached read. Also the reload path after an invalidation, so the ids
4089
+ * still survive a reload exactly as before. */
4090
+ async readPendingRecordIds() {
3992
4091
  const writes = /* @__PURE__ */ new Set();
3993
4092
  const deletes = /* @__PURE__ */ new Set();
3994
4093
  try {
@@ -4004,7 +4103,16 @@ var DataModule = class DataModule {
4004
4103
  err,
4005
4104
  Category: "sp00ky-client::DataModule::getPendingRecordIds"
4006
4105
  }, "Failed to read pending mutations; optimistic writes may be briefly hidden");
4106
+ return {
4107
+ writes,
4108
+ deletes
4109
+ };
4007
4110
  }
4111
+ this.pendingIds = {
4112
+ writes,
4113
+ deletes
4114
+ };
4115
+ this.pendingIdsAt = Date.now();
4008
4116
  return {
4009
4117
  writes,
4010
4118
  deletes
@@ -4321,6 +4429,7 @@ var DataModule = class DataModule {
4321
4429
  record: target,
4322
4430
  tableName
4323
4431
  };
4432
+ this.invalidatePendingIds();
4324
4433
  for (const callback of this.mutationCallbacks) callback([mutationEvent]);
4325
4434
  this.logger.debug({
4326
4435
  id,
@@ -4374,6 +4483,7 @@ var DataModule = class DataModule {
4374
4483
  beforeRecord: beforeRecord || void 0,
4375
4484
  options: pushEventOptions
4376
4485
  };
4486
+ this.invalidatePendingIds();
4377
4487
  for (const callback of this.mutationCallbacks) callback([mutationEvent]);
4378
4488
  this.logger.debug({
4379
4489
  id,
@@ -4419,6 +4529,7 @@ var DataModule = class DataModule {
4419
4529
  mutation_id: mutationId,
4420
4530
  record_id: rid
4421
4531
  };
4532
+ this.invalidatePendingIds();
4422
4533
  for (const callback of this.mutationCallbacks) callback([mutationEvent]);
4423
4534
  this.logger.debug({
4424
4535
  id,
@@ -5071,9 +5182,43 @@ var DownQueue = class {
5071
5182
  }
5072
5183
  async next(fn) {
5073
5184
  const event = this.queue.shift();
5074
- if (event) try {
5185
+ if (!event) return;
5186
+ const error = await this.run(event, fn);
5187
+ if (error !== void 0) throw error;
5188
+ }
5189
+ /**
5190
+ * The next event whose hash is NOT already being processed, or `undefined`
5191
+ * when every remaining event is blocked on a busy hash.
5192
+ *
5193
+ * Ordering only ever mattered PER HASH — a `cleanup` must not overtake the
5194
+ * `register` for the same query — but the queue enforced it globally, so one
5195
+ * registration RPC at a time was the ceiling for the whole client. An event
5196
+ * for a busy hash keeps its place here (it is skipped, not reordered), so
5197
+ * per-hash ordering is preserved exactly while independent hashes proceed
5198
+ * concurrently.
5199
+ */
5200
+ takeNext(busy) {
5201
+ for (let i = 0; i < this.queue.length; i++) {
5202
+ const event = this.queue[i];
5203
+ if (busy.has(event.payload.hash)) continue;
5204
+ this.queue.splice(i, 1);
5205
+ return event;
5206
+ }
5207
+ }
5208
+ /**
5209
+ * Process one event, applying the re-head / rotate failure policy.
5210
+ *
5211
+ * NEVER rejects: it RETURNS the error instead (`undefined` on success). A
5212
+ * concurrent drain has other work in flight when one event fails, and a
5213
+ * rejection would either take that work down with it or have to be caught at
5214
+ * every call site. `next` reinstates the throwing contract for the serial
5215
+ * callers that still want it.
5216
+ */
5217
+ async run(event, fn) {
5218
+ try {
5075
5219
  await fn(event);
5076
5220
  this.failures.delete(event);
5221
+ return;
5077
5222
  } catch (error) {
5078
5223
  const attempts = (this.failures.get(event) ?? 0) + 1;
5079
5224
  this.failures.set(event, attempts);
@@ -5087,7 +5232,7 @@ var DownQueue = class {
5087
5232
  rotated: starvingOthers,
5088
5233
  Category: "sp00ky-client::DownQueue::next"
5089
5234
  }, "Failed to process query");
5090
- throw error;
5235
+ return error;
5091
5236
  }
5092
5237
  }
5093
5238
  };
@@ -5317,6 +5462,8 @@ function recordVersionArraysEqual(a, b) {
5317
5462
 
5318
5463
  //#endregion
5319
5464
  //#region src/modules/sync/engine.ts
5465
+ /** Ids per remote `SELECT * FROM $ids` round trip. */
5466
+ const FETCH_CHUNK = 500;
5320
5467
  /**
5321
5468
  * SyncEngine handles the core sync operations: fetching remote records,
5322
5469
  * caching them locally, and ingesting into DBSP.
@@ -5347,51 +5494,59 @@ var SyncEngine = class {
5347
5494
  }, "SyncEngine.syncRecords diff");
5348
5495
  let stillRemoteIds = [];
5349
5496
  if (removed.length > 0) stillRemoteIds = await this.handleRemovedRecords(removed);
5350
- const toFetch = [...added, ...updated];
5351
- const idsToFetch = toFetch.map((x) => x.id);
5352
- if (idsToFetch.length === 0) return {
5497
+ const toFetch = [...added, ...updated].filter((item) => {
5498
+ const localVersion = this.cache.lookup(encodeRecordId(item.id));
5499
+ return !(localVersion && item.version <= localVersion);
5500
+ });
5501
+ if (toFetch.length === 0) return {
5353
5502
  remoteFetchMs: 0,
5354
5503
  stillRemoteIds
5355
5504
  };
5356
5505
  const versionMap = /* @__PURE__ */ new Map();
5357
5506
  for (const item of toFetch) versionMap.set(encodeRecordId(item.id), item.version);
5358
- const remoteFetchStart = performance.now();
5359
- const [remoteResults] = await this.remote.query("SELECT * FROM $idsToFetch", { idsToFetch });
5360
- const remoteFetchMs = performance.now() - remoteFetchStart;
5361
- const cacheBatch = [];
5362
- for (const record of remoteResults) {
5363
- if (!record?.id) {
5364
- this.logger.warn({
5365
- record,
5366
- idsToFetch,
5367
- Category: "sp00ky-client::SyncEngine::syncRecords"
5368
- }, "Remote record has no id (possibly deleted). Skipping record");
5369
- continue;
5370
- }
5371
- const fullId = encodeRecordId(record.id);
5372
- const table = record.id.table.toString();
5373
- const isAdded = added.some((item) => encodeRecordId(item.id) === fullId);
5374
- const version = versionMap.get(fullId) ?? 0;
5375
- const localVersion = this.cache.lookup(fullId);
5376
- if (localVersion && version <= localVersion) {
5377
- this.logger.info({
5378
- recordId: fullId,
5379
- version,
5380
- localVersion,
5381
- Category: "sp00ky-client::SyncEngine::syncRecords"
5382
- }, "Local version is higher than remote version. Skipping record");
5383
- continue;
5507
+ const addedIds = new Set(added.map((item) => encodeRecordId(item.id)));
5508
+ let remoteFetchMs = 0;
5509
+ const remoteResults = [];
5510
+ for (let i = 0; i < toFetch.length; i += FETCH_CHUNK) {
5511
+ const idsToFetch = toFetch.slice(i, i + FETCH_CHUNK).map((x) => x.id);
5512
+ const remoteFetchStart = performance.now();
5513
+ const [chunkResults] = await this.remote.query("SELECT * FROM $idsToFetch", { idsToFetch });
5514
+ remoteFetchMs += performance.now() - remoteFetchStart;
5515
+ const cacheBatch = [];
5516
+ for (const record of chunkResults ?? []) {
5517
+ if (!record?.id) {
5518
+ this.logger.warn({
5519
+ record,
5520
+ idsToFetch,
5521
+ Category: "sp00ky-client::SyncEngine::syncRecords"
5522
+ }, "Remote record has no id (possibly deleted). Skipping record");
5523
+ continue;
5524
+ }
5525
+ remoteResults.push(record);
5526
+ const fullId = encodeRecordId(record.id);
5527
+ const table = record.id.table.toString();
5528
+ const version = versionMap.get(fullId) ?? 0;
5529
+ const localVersion = this.cache.lookup(fullId);
5530
+ if (localVersion && version <= localVersion) {
5531
+ this.logger.info({
5532
+ recordId: fullId,
5533
+ version,
5534
+ localVersion,
5535
+ Category: "sp00ky-client::SyncEngine::syncRecords"
5536
+ }, "Local version is higher than remote version. Skipping record");
5537
+ continue;
5538
+ }
5539
+ const tableSchema = this.schema.tables.find((t) => t.name === table);
5540
+ const cleanedRecord = tableSchema ? cleanRecord(tableSchema.columns, record) : record;
5541
+ cacheBatch.push({
5542
+ table,
5543
+ op: addedIds.has(fullId) ? "CREATE" : "UPDATE",
5544
+ record: cleanedRecord,
5545
+ version
5546
+ });
5384
5547
  }
5385
- const tableSchema = this.schema.tables.find((t) => t.name === table);
5386
- const cleanedRecord = tableSchema ? cleanRecord(tableSchema.columns, record) : record;
5387
- cacheBatch.push({
5388
- table,
5389
- op: isAdded ? "CREATE" : "UPDATE",
5390
- record: cleanedRecord,
5391
- version
5392
- });
5548
+ if (cacheBatch.length > 0) await this.cache.saveBatch(cacheBatch);
5393
5549
  }
5394
- if (cacheBatch.length > 0) await this.cache.saveBatch(cacheBatch);
5395
5550
  this.events.emit(SyncEventTypes.RemoteDataIngested, { records: remoteResults });
5396
5551
  return {
5397
5552
  remoteFetchMs,
@@ -5452,6 +5607,17 @@ var SyncEngine = class {
5452
5607
  */
5453
5608
  /** Backoff for re-draining a queue that halted on an error. */
5454
5609
  const RETRY_BASE_MS = 500;
5610
+ /**
5611
+ * How many down events may be in flight at once.
5612
+ *
5613
+ * The down queue was strictly serial: one `register`/`sync`/`cleanup` RPC at a
5614
+ * time for the WHOLE client. Measured in production that drained at roughly one
5615
+ * event per 8.8s, so a list that registers a query per scrolled-to window took
5616
+ * minutes to fill — the rows were already cached, only the registration lagged.
5617
+ * Ordering is a per-hash requirement, not a global one (see `takeNext`), so
5618
+ * independent hashes can go in parallel. Bounded to stay polite to the SSP.
5619
+ */
5620
+ const MAX_CONCURRENT_DOWN = 4;
5455
5621
  const RETRY_MAX_MS = 15e3;
5456
5622
  var SyncScheduler = class {
5457
5623
  isSyncingUp = false;
@@ -5593,14 +5759,35 @@ var SyncScheduler = class {
5593
5759
  }
5594
5760
  this.isSyncingDown = true;
5595
5761
  let processedAny = false;
5762
+ const busy = /* @__PURE__ */ new Set();
5763
+ const inFlight = /* @__PURE__ */ new Set();
5764
+ let failure;
5596
5765
  try {
5597
- while (this.downQueue.size > 0 && !this.paused) {
5598
- if (this.upQueue.size > 0) break;
5599
- await this.downQueue.next(this.onProcessDown);
5600
- processedAny = true;
5766
+ for (;;) {
5767
+ if (this.paused) break;
5768
+ const yieldToUp = this.upQueue.size > 0;
5769
+ while (failure === void 0 && !yieldToUp && inFlight.size < MAX_CONCURRENT_DOWN) {
5770
+ const event = this.downQueue.takeNext(busy);
5771
+ if (!event) break;
5772
+ const hash = event.payload.hash;
5773
+ busy.add(hash);
5774
+ processedAny = true;
5775
+ const task = this.downQueue.run(event, this.onProcessDown).then((error) => {
5776
+ if (error !== void 0 && failure === void 0) failure = error;
5777
+ }).finally(() => {
5778
+ busy.delete(hash);
5779
+ inFlight.delete(task);
5780
+ });
5781
+ inFlight.add(task);
5782
+ }
5783
+ if (inFlight.size === 0) break;
5784
+ await Promise.race(inFlight);
5785
+ if (yieldToUp && inFlight.size === 0) break;
5601
5786
  }
5787
+ if (failure !== void 0) throw failure;
5602
5788
  if (processedAny) this.onSyncOutcome?.(true);
5603
5789
  this.downRetryAttempt = 0;
5790
+ if (this.downQueue.size > 0) this.scheduleDownRetry(RETRY_BASE_MS);
5604
5791
  } catch (error) {
5605
5792
  this.onSyncOutcome?.(false, error);
5606
5793
  this.scheduleDownRetry();
@@ -5691,6 +5878,7 @@ var Sp00kySync = class Sp00kySync {
5691
5878
  degradeAfterFailures;
5692
5879
  /** Per-push RPC deadline; see {@link withPushTimeout}. */
5693
5880
  pushTimeoutMs;
5881
+ downTimeoutMs;
5694
5882
  consecutiveSyncFailures = 0;
5695
5883
  syncHealthStatus = "healthy";
5696
5884
  lastSyncErrorKind;
@@ -5862,6 +6050,7 @@ var Sp00kySync = class Sp00kySync {
5862
6050
  this.anonLiveEnabled = options?.anonymousLiveQueries ?? false;
5863
6051
  this.degradeAfterFailures = Math.max(0, options?.degradeAfterConsecutiveFailures ?? 3);
5864
6052
  this.pushTimeoutMs = Math.max(0, options?.pushTimeoutMs ?? 3e4);
6053
+ this.downTimeoutMs = Math.max(0, options?.downTimeoutMs ?? 3e4);
5865
6054
  this.connectionSupervisor = options?.connectionSupervisor;
5866
6055
  }
5867
6056
  /**
@@ -5894,14 +6083,37 @@ var Sp00kySync = class Sp00kySync {
5894
6083
  this.tabRole = role;
5895
6084
  this.tabId = tabId;
5896
6085
  }
5897
- /** Leader duties: drain the shared outbox, own the single list_ref LIVE,
5898
- * relay LIVE events and rollbacks to followers via `hub`. Idempotent for a
5899
- * boot-time leader; a runtime promotion (failover) reloads the outbox,
5900
- * which now holds EVERY tab's rows, and restarts LIVE under this session. */
5901
- async promoteToLeader(hub) {
6086
+ /** In-flight {@link resumeLeaderDuties}, so a second call joins the first
6087
+ * instead of double-draining the outbox. */
6088
+ leaderDutiesInFlight = null;
6089
+ /** Resolves once the in-browser circuit has been primed from the local
6090
+ * store. Every sync diff waits on it: diffing against an empty circuit
6091
+ * classifies the whole working set as missing and re-downloads it. */
6092
+ primeGate = () => Promise.resolve();
6093
+ /** The prime we last waited on. `whenPrimed` hands out one promise per
6094
+ * prime, so a new identity means a new prime (boot, bucket switch) ran. */
6095
+ settledPrime = null;
6096
+ setPrimeGate(gate) {
6097
+ this.primeGate = gate;
6098
+ this.settledPrime = null;
6099
+ }
6100
+ /**
6101
+ * Leader WIRING only, and deliberately synchronous.
6102
+ *
6103
+ * The coordinator publishes the leader role and tells the broker
6104
+ * `leader-ready` the moment the store is adopted, and the broker can mint a
6105
+ * follower's ports on the very next tick. So the follower-message handler
6106
+ * has to be live before this returns, or a mutation forwarded in that window
6107
+ * is dropped. Everything that can block (outbox reload, LIVE restart) moved
6108
+ * to {@link resumeLeaderDuties}: a promotion that waits on the network holds
6109
+ * `leader-ready` back, and a broker whose leader never reports ready serves
6110
+ * no follower ports and re-elects no one, which wedges the whole namespace.
6111
+ */
6112
+ promoteToLeader(hub) {
5902
6113
  this.tabRole = "leader";
5903
6114
  this.hub = hub;
5904
6115
  this.forwarder = null;
6116
+ this.leaderDutiesInFlight = null;
5905
6117
  hub.onFollowerMessage = (tabId, msg) => {
5906
6118
  switch (msg.type) {
5907
6119
  case "sync-hello": break;
@@ -5913,7 +6125,17 @@ var Sp00kySync = class Sp00kySync {
5913
6125
  break;
5914
6126
  }
5915
6127
  };
5916
- if (this.isInit) {
6128
+ }
6129
+ /** Leader duties: drain the shared outbox, own the single list_ref LIVE,
6130
+ * relay LIVE events and rollbacks to followers. Idempotent for a boot-time
6131
+ * leader; a runtime promotion (failover) reloads the outbox, which now
6132
+ * holds EVERY tab's rows, and restarts LIVE under this session. Runs in the
6133
+ * background off the promotion path, so however long it takes (or if it
6134
+ * never finishes) the tab is already a working leader. */
6135
+ resumeLeaderDuties() {
6136
+ if (this.leaderDutiesInFlight) return this.leaderDutiesInFlight;
6137
+ const run = (async () => {
6138
+ if (!this.isInit || this.tabRole !== "leader") return;
5917
6139
  await this.upQueue.loadFromDatabase();
5918
6140
  this.scheduler.syncUp();
5919
6141
  if (this.currentUserId || this.anonLiveEnabled) {
@@ -5921,11 +6143,15 @@ var Sp00kySync = class Sp00kySync {
5921
6143
  await this.restartRefLiveQuery().catch((err) => {
5922
6144
  this.logger.warn({
5923
6145
  err,
5924
- Category: "sp00ky-client::Sp00kySync::promoteToLeader"
6146
+ Category: "sp00ky-client::Sp00kySync::resumeLeaderDuties"
5925
6147
  }, "LIVE restart failed on promotion; poll fallback covers it");
5926
6148
  });
5927
6149
  }
5928
- }
6150
+ })();
6151
+ this.leaderDutiesInFlight = run;
6152
+ return run.finally(() => {
6153
+ if (this.leaderDutiesInFlight === run) this.leaderDutiesInFlight = null;
6154
+ });
5929
6155
  }
5930
6156
  /** Follower duties: no outbox drain, no LIVE. Mutations forward to the
5931
6157
  * leader; everything else (registration, per-query sync, poll) runs
@@ -5934,6 +6160,7 @@ var Sp00kySync = class Sp00kySync {
5934
6160
  this.tabRole = "follower";
5935
6161
  this.hub = null;
5936
6162
  this.forwarder = forwarder;
6163
+ this.leaderDutiesInFlight = null;
5937
6164
  this.killRefLiveQuery();
5938
6165
  forwarder.onLeaderMessage = (msg) => {
5939
6166
  switch (msg.type) {
@@ -6544,6 +6771,12 @@ var Sp00kySync = class Sp00kySync {
6544
6771
  event,
6545
6772
  Category: "sp00ky-client::Sp00kySync::processDownEvent"
6546
6773
  }, "Processing down event");
6774
+ return this.withDownTimeout(this.runDownEvent(event), `${event.type} ${event.payload.hash}`);
6775
+ }
6776
+ withDownTimeout(promise, label) {
6777
+ return withTimeout(promise, this.downTimeoutMs, `Down event timed out after ${this.downTimeoutMs}ms (${label})`);
6778
+ }
6779
+ async runDownEvent(event) {
6547
6780
  switch (event.type) {
6548
6781
  case "register": return this.registerQuery(event.payload.hash);
6549
6782
  case "sync": return this.syncQuery(event.payload.hash);
@@ -6577,6 +6810,16 @@ var Sp00kySync = class Sp00kySync {
6577
6810
  * means the single resulting UI update lands after this completes.
6578
6811
  */
6579
6812
  async runSyncForQuery(hash, diff) {
6813
+ const prime = this.primeGate();
6814
+ if (prime !== this.settledPrime) {
6815
+ await prime;
6816
+ this.settledPrime = prime;
6817
+ const fresh = this.dataModule.getQueryByHash(hash);
6818
+ if (!fresh) return;
6819
+ const recomputed = new ArraySyncer(fresh.config.localArray, fresh.config.remoteArray).nextSet();
6820
+ if (!recomputed) return;
6821
+ diff = recomputed;
6822
+ }
6580
6823
  if (diff.added.length > 0 || diff.updated.length > 0) {
6581
6824
  const pendingDeletes = await this.getPendingDeleteIds();
6582
6825
  if (pendingDeletes.size > 0) diff = {
@@ -7094,8 +7337,8 @@ function selfAllowlistedVariant(flag, userId) {
7094
7337
 
7095
7338
  //#endregion
7096
7339
  //#region src/modules/devtools/index.ts
7097
- const CORE_VERSION = "0.0.1-canary.203";
7098
- const WASM_VERSION = "0.0.1-canary.203";
7340
+ const CORE_VERSION = "0.0.1-canary.205";
7341
+ const WASM_VERSION = "0.0.1-canary.205";
7099
7342
  const SURREAL_VERSION = "3.0.3";
7100
7343
  var DevToolsService = class DevToolsService {
7101
7344
  eventsHistory = [];
@@ -7289,7 +7532,7 @@ var DevToolsService = class DevToolsService {
7289
7532
  const info = this.unwrapInfo(res);
7290
7533
  this.localTablesAt = Date.now();
7291
7534
  if (info && info.tables) {
7292
- const names = Object.keys(info.tables);
7535
+ const names = Object.keys(info.tables).filter((n) => n !== "_00_circuit_snapshot");
7293
7536
  const changed = names.length !== this.localTables.length || names.some((n, i) => n !== this.localTables[i]);
7294
7537
  this.localTables = names;
7295
7538
  if (changed) this.notifyDevTools();
@@ -7819,13 +8062,29 @@ var AuthService = class {
7819
8062
 
7820
8063
  //#endregion
7821
8064
  //#region src/services/stream-processor/index.ts
7822
- /**
7823
- * Read a circuit snapshot out of the pre-`persistCircuit` persisted shape
7824
- * (`[[{ state }]]`, a raw SurrealDB result). Returns null for anything else.
7825
- */
7826
- function extractLegacyState(result) {
7827
- if (Array.isArray(result) && Array.isArray(result[0]) && typeof result[0][0]?.state === "string") return result[0][0].state;
7828
- return null;
8065
+ /** Storage key of the circuit snapshot inside the local store. */
8066
+ const CIRCUIT_SNAPSHOT_KEY = "circuit";
8067
+ /** Bump when the bytes `load_store_state` reads change shape. */
8068
+ const CIRCUIT_SNAPSHOT_FORMAT = 1;
8069
+ /** Rows per `ingest_many` call. Bounds the transient the wasm side allocates
8070
+ * to parse a batch (measured: one 7700-row call of 20 KB bodies peaked at
8071
+ * 500 MB, 128-row chunks at 335 MB, and under projection at 24 MB). */
8072
+ const INGEST_CHUNK = 128;
8073
+ /** Ids per `selectByIds` when priming bodies out of the local store. */
8074
+ const PRIME_CHUNK = 256;
8075
+ /** Rows the SurrealDB (main-thread, IndexedDB) engine is allowed to prime
8076
+ * through; above this the circuit boots empty as it always did. */
8077
+ const SURREAL_PRIME_ROW_CAP = 2e4;
8078
+ /** Below this many changed rows the checkpoint timer stays quiet. */
8079
+ const CHECKPOINT_MIN_ROWS = 50;
8080
+ /** Stable `table:id` for a row id that may already be a string. */
8081
+ function idString(id) {
8082
+ return typeof id === "string" ? id : encodeRecordId(id);
8083
+ }
8084
+ function chunks(items, size) {
8085
+ const out = [];
8086
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
8087
+ return out;
7829
8088
  }
7830
8089
  var StreamProcessorService = class {
7831
8090
  logger;
@@ -7838,18 +8097,23 @@ var StreamProcessorService = class {
7838
8097
  authId: "",
7839
8098
  access: ""
7840
8099
  };
7841
- stateKeySuffix = "";
7842
8100
  stateGeneration = 0;
7843
8101
  persistState = true;
7844
8102
  persistCircuit = false;
7845
8103
  checkpointMs = 3e4;
7846
8104
  checkpointTimer = null;
7847
8105
  snapshotDirty = false;
7848
- pagehideHandler = null;
7849
- constructor(events, db, persistenceClient, logger) {
8106
+ dirtyRows = 0;
8107
+ hideHandler = null;
8108
+ checkpointInFlight = null;
8109
+ projection = true;
8110
+ primed = Promise.resolve();
8111
+ schemaHash = null;
8112
+ widenQueue = Promise.resolve();
8113
+ widenPending = /* @__PURE__ */ new Map();
8114
+ constructor(events, db, logger) {
7850
8115
  this.events = events;
7851
8116
  this.db = db;
7852
- this.persistenceClient = persistenceClient;
7853
8117
  this.logger = logger.child({ name: "StreamProcessorService" });
7854
8118
  }
7855
8119
  /**
@@ -7881,31 +8145,41 @@ var StreamProcessorService = class {
7881
8145
  for (const update of updates) for (const receiver of this.receivers) receiver.onStreamUpdate(update);
7882
8146
  }
7883
8147
  /**
7884
- * Ingest a batch of record changes as a single bulk operation, firing only
7885
- * one coalesced `StreamUpdate` per affected query once every record has been
7886
- * ingested (instead of one update per record). Use this whenever multiple
7887
- * records land at once — e.g. sync fetching N missing rows — so a list query
7888
- * re-runs and the UI re-renders once for the whole batch rather than
7889
- * row-by-row.
8148
+ * Ingest a batch of record changes, firing one coalesced `StreamUpdate` per
8149
+ * affected query once every record has been ingested. Use this whenever
8150
+ * multiple records land at once (sync fetching N rows, the boot prime).
8151
+ *
8152
+ * The batch is fed to the wasm side in chunks of {@link INGEST_CHUNK}: one
8153
+ * circuit step per chunk (a step walks every registered view, so per-record
8154
+ * ingest paid that fixed cost N times), but never the whole batch at once,
8155
+ * because the wasm side has to hold every parsed row of a call at the same
8156
+ * time and wasm32 dlmalloc never returns that peak.
7890
8157
  *
7891
- * Internally opens a coalescing window, ingests each record, then flushes;
7892
- * processor state is persisted once for the whole batch. No-op for an empty
7893
- * batch.
8158
+ * Returns the records that were ingested. A chunk that fails is reported
8159
+ * and skipped, not retried (a retry would double-apply whatever the failed
8160
+ * step already committed), and the remaining chunks still run.
7894
8161
  */
7895
8162
  ingestMany(records) {
7896
- if (records.length === 0) return;
8163
+ if (records.length === 0) return [];
7897
8164
  if (!this.processor) {
7898
8165
  this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::ingestMany" }, "Not initialized, skipping ingest");
7899
- return;
8166
+ return [];
7900
8167
  }
7901
8168
  const bulkIngest = this.processor.ingest_many;
7902
- if (typeof bulkIngest === "function") {
7903
- this.logger.debug({
7904
- count: records.length,
7905
- Category: "sp00ky-client::StreamProcessorService::ingestMany"
7906
- }, "Ingesting batch into ssp");
8169
+ if (typeof bulkIngest !== "function") {
8170
+ this.beginCoalescing();
7907
8171
  try {
7908
- const items = records.map((record) => ({
8172
+ for (const record of records) this.ingest(record.table, record.op, record.id, record.record);
8173
+ } finally {
8174
+ this.flushCoalescing();
8175
+ }
8176
+ return records;
8177
+ }
8178
+ const ingested = [];
8179
+ this.beginCoalescing();
8180
+ try {
8181
+ for (const chunk of chunks(records, INGEST_CHUNK)) try {
8182
+ const items = chunk.map((record) => ({
7909
8183
  table: record.table,
7910
8184
  op: record.op,
7911
8185
  id: record.id,
@@ -7914,13 +8188,7 @@ var StreamProcessorService = class {
7914
8188
  const t0 = performance.now();
7915
8189
  const rawUpdates = bulkIngest.call(this.processor, items) ?? [];
7916
8190
  const materializationTimeMs = performance.now() - t0;
7917
- this.logger.debug({
7918
- count: records.length,
7919
- rawUpdates: rawUpdates.length,
7920
- materializationTimeMs,
7921
- Category: "sp00ky-client::StreamProcessorService::ingestMany"
7922
- }, "Ingesting batch into ssp done");
7923
- if (rawUpdates.length > 0) this.dispatchUpdates(rawUpdates.map((u) => ({
8191
+ if (rawUpdates.length > 0) this.notifyUpdates(rawUpdates.map((u) => ({
7924
8192
  queryHash: u.query_id,
7925
8193
  localArray: u.result_data,
7926
8194
  op: "CREATE",
@@ -7929,22 +8197,24 @@ var StreamProcessorService = class {
7929
8197
  circuitStepMs: u.timing_circuit_step_ms,
7930
8198
  transformMs: u.timing_transform_ms
7931
8199
  })));
8200
+ ingested.push(...chunk);
7932
8201
  } catch (e) {
7933
8202
  this.logger.error({
7934
8203
  error: e,
7935
- count: records.length,
8204
+ count: chunk.length,
7936
8205
  Category: "sp00ky-client::StreamProcessorService::ingestMany"
7937
- }, "Ingesting batch into ssp failed");
8206
+ }, "Ingesting chunk into ssp failed");
7938
8207
  }
7939
- this.markSnapshotDirty();
7940
- return;
7941
- }
7942
- this.beginCoalescing();
7943
- try {
7944
- for (const record of records) this.ingest(record.table, record.op, record.id, record.record);
7945
8208
  } finally {
7946
8209
  this.flushCoalescing();
7947
8210
  }
8211
+ this.logger.debug({
8212
+ count: records.length,
8213
+ ingested: ingested.length,
8214
+ Category: "sp00ky-client::StreamProcessorService::ingestMany"
8215
+ }, "Ingested batch into ssp");
8216
+ this.markSnapshotDirty(ingested.length);
8217
+ return ingested;
7948
8218
  }
7949
8219
  /**
7950
8220
  * Open a coalescing window. While open, the per-record stream updates
@@ -7971,7 +8241,6 @@ var StreamProcessorService = class {
7971
8241
  const buffered = Array.from(this.batchBuffer.values());
7972
8242
  this.batchBuffer.clear();
7973
8243
  if (buffered.length > 0) this.dispatchUpdates(buffered);
7974
- this.markSnapshotDirty();
7975
8244
  }
7976
8245
  /**
7977
8246
  * Initialize the WASM module and processor.
@@ -7983,7 +8252,7 @@ var StreamProcessorService = class {
7983
8252
  try {
7984
8253
  await init();
7985
8254
  this.processor = new Sp00kyProcessor();
7986
- await this.loadState();
8255
+ this.applyProjection();
7987
8256
  this.isInitialized = true;
7988
8257
  this.logger.info({ Category: "sp00ky-client::StreamProcessorService::init" }, "Initialized successfully");
7989
8258
  } catch (e) {
@@ -7994,21 +8263,16 @@ var StreamProcessorService = class {
7994
8263
  throw e;
7995
8264
  }
7996
8265
  }
7997
- /** Route the persisted circuit snapshot to a per-bucket key. */
7998
- setStateKeySuffix(bucketId) {
7999
- this.stateKeySuffix = bucketId;
8000
- }
8001
- stateKey() {
8002
- return this.stateKeySuffix ? `_00_stream_processor_state:${this.stateKeySuffix}` : "_00_stream_processor_state";
8003
- }
8004
8266
  /**
8005
8267
  * Drop the current WASM processor and start a fresh, empty circuit. Used on
8006
8268
  * local-bucket switches: the old circuit holds the previous user's rows AND
8007
8269
  * views registered with the previous `$auth` context, so neither may survive.
8008
- * Deliberately does NOT `loadState()` — a persisted snapshot references views
8009
- * under a dead sessionId salt; the DataModule rebind re-registers every live
8010
- * view against this fresh processor. Caller must re-seed `setPermissions`
8011
- * afterwards (a fresh circuit default-denies every table).
8270
+ * Deliberately loads nothing: the snapshot in the store being swapped away
8271
+ * from belongs to the previous bucket; the caller primes the new bucket's
8272
+ * circuit (`primeFromLocal`) once its store is open, and the DataModule
8273
+ * rebind re-registers every live view against this fresh processor. Caller
8274
+ * must re-seed `setPermissions` afterwards (a fresh circuit default-denies
8275
+ * every table).
8012
8276
  */
8013
8277
  async reset() {
8014
8278
  if (!this.isInitialized) return;
@@ -8016,8 +8280,11 @@ var StreamProcessorService = class {
8016
8280
  this.batching = false;
8017
8281
  this.batchBuffer.clear();
8018
8282
  this.snapshotDirty = false;
8283
+ this.dirtyRows = 0;
8284
+ this.widenPending.clear();
8019
8285
  const previous = this.processor;
8020
8286
  this.processor = new Sp00kyProcessor();
8287
+ this.applyProjection();
8021
8288
  this.freeProcessor(previous);
8022
8289
  this.logger.info({ Category: "sp00ky-client::StreamProcessorService::reset" }, "Stream processor reset (fresh circuit)");
8023
8290
  }
@@ -8057,9 +8324,9 @@ var StreamProcessorService = class {
8057
8324
  if (!enabled) this.stopCheckpoints();
8058
8325
  }
8059
8326
  /**
8060
- * Opt into snapshot persistence (`persistCircuit`). Off by default: see the
8061
- * `persistCircuit` field comment for why per-ingest snapshots were removed.
8062
- * Must be called before `init()` for a snapshot to be restored at boot.
8327
+ * Snapshot persistence (`persistCircuit`). When on, the circuit's store is
8328
+ * written to the local store on a checkpoint interval and when the page
8329
+ * goes hidden, and restored by {@link primeFromLocal} on the next boot.
8063
8330
  */
8064
8331
  configureCircuitPersistence(enabled, checkpointMs) {
8065
8332
  this.persistCircuit = enabled;
@@ -8067,59 +8334,291 @@ var StreamProcessorService = class {
8067
8334
  if (!enabled) this.stopCheckpoints();
8068
8335
  }
8069
8336
  /**
8070
- * Record that the circuit changed. Cheap and O(1), the expensive snapshot is
8071
- * deferred to the checkpoint timer, and skipped entirely when
8072
- * `persistCircuit` is off (the default).
8337
+ * Field projection (`circuitProjection`, default on). Takes effect on the
8338
+ * next processor (`init`/`reset`) and on rows written after that.
8339
+ */
8340
+ configureProjection(enabled) {
8341
+ this.projection = enabled;
8342
+ this.applyProjection();
8343
+ }
8344
+ applyProjection() {
8345
+ if (!this.processor || typeof this.processor.set_projection !== "function") return;
8346
+ try {
8347
+ this.processor.set_projection(this.projection);
8348
+ } catch (e) {
8349
+ this.logger.warn({
8350
+ error: e,
8351
+ Category: "sp00ky-client::StreamProcessorService::applyProjection"
8352
+ }, "set_projection failed");
8353
+ }
8354
+ }
8355
+ /** Resolves once the boot-time prime has finished (or was skipped). */
8356
+ whenPrimed() {
8357
+ return this.primed;
8358
+ }
8359
+ /**
8360
+ * Fill the circuit from the LOCAL store, in the background.
8361
+ *
8362
+ * With a usable snapshot: install it under whatever views have registered
8363
+ * meanwhile (`load_store_state` re-primes them), then `reconcile` each table
8364
+ * against the store's `(id, rv)` list so rows deleted since the checkpoint
8365
+ * are stepped out and only rows added or changed since are read back and
8366
+ * ingested. Without one: read every row and ingest it, chunked.
8367
+ *
8368
+ * Either way the circuit ends up equal to the local store without touching
8369
+ * the network, so the first sync diff is a real delta rather than "fetch
8370
+ * everything". The returned promise never rejects; `whenPrimed` gates on it.
8371
+ */
8372
+ primeFromLocal(ctx) {
8373
+ this.schemaHash = ctx.schemaHash;
8374
+ const run = this.runPrime(ctx).catch((e) => {
8375
+ this.logger.warn({
8376
+ error: e,
8377
+ Category: "sp00ky-client::StreamProcessorService::primeFromLocal"
8378
+ }, "Circuit prime failed; the circuit fills from sync instead");
8379
+ });
8380
+ this.primed = run;
8381
+ return run;
8382
+ }
8383
+ async runPrime(ctx) {
8384
+ const processor = this.processor;
8385
+ if (!processor || typeof this.db.scanVersions !== "function") return;
8386
+ const generation = this.stateGeneration;
8387
+ const epoch = this.db.epoch;
8388
+ const alive = () => this.processor === processor && generation === this.stateGeneration && epoch === this.db.epoch;
8389
+ const t0 = performance.now();
8390
+ let restored = false;
8391
+ if (this.persistCircuit && typeof this.db.getSnapshot === "function" && typeof processor.load_store_state === "function") {
8392
+ const snapshot = await this.db.getSnapshot(CIRCUIT_SNAPSHOT_KEY);
8393
+ if (!alive()) return;
8394
+ if (snapshot) {
8395
+ const { meta } = snapshot;
8396
+ if (meta.formatVersion !== CIRCUIT_SNAPSHOT_FORMAT || meta.schemaHash !== ctx.schemaHash) this.logger.info({
8397
+ meta,
8398
+ Category: "sp00ky-client::StreamProcessorService::primeFromLocal"
8399
+ }, "Circuit snapshot is from another format or schema; priming from rows");
8400
+ else try {
8401
+ const tLoad = performance.now();
8402
+ const updates = processor.load_store_state(snapshot.bytes);
8403
+ this.dispatchWasmUpdates(updates);
8404
+ restored = true;
8405
+ this.logger.info({
8406
+ bytes: snapshot.bytes.byteLength,
8407
+ views: updates.length,
8408
+ loadMs: Math.round(performance.now() - tLoad),
8409
+ Category: "sp00ky-client::StreamProcessorService::primeFromLocal"
8410
+ }, "Circuit snapshot restored");
8411
+ } catch (e) {
8412
+ this.logger.warn({
8413
+ error: e,
8414
+ Category: "sp00ky-client::StreamProcessorService::primeFromLocal"
8415
+ }, "Circuit snapshot unreadable; priming from rows");
8416
+ }
8417
+ }
8418
+ }
8419
+ const versions = await this.db.scanVersions(ctx.tables);
8420
+ if (!alive()) return;
8421
+ const total = Object.values(versions).reduce((n, v) => n + v.length, 0);
8422
+ if (!restored && this.db.engineKind !== "sqlite" && total > SURREAL_PRIME_ROW_CAP) {
8423
+ this.logger.warn({
8424
+ total,
8425
+ cap: SURREAL_PRIME_ROW_CAP,
8426
+ Category: "sp00ky-client::StreamProcessorService::primeFromLocal"
8427
+ }, "Too many cached rows to prime through the main-thread engine; circuit fills from sync");
8428
+ return;
8429
+ }
8430
+ let ingested = 0;
8431
+ let fetched = 0;
8432
+ let deleted = 0;
8433
+ for (const table of ctx.tables) {
8434
+ const entries = versions[table] ?? [];
8435
+ let toFetch;
8436
+ if (restored && typeof processor.reconcile === "function") {
8437
+ const result = processor.reconcile(table, entries);
8438
+ this.dispatchWasmUpdates(result.updates);
8439
+ deleted += result.deleted;
8440
+ toFetch = result.fetch;
8441
+ } else toFetch = entries.map(([id]) => id);
8442
+ for (const chunk of chunks(toFetch, PRIME_CHUNK)) {
8443
+ const rows = await this.db.selectByIds(table, chunk);
8444
+ if (!alive()) return;
8445
+ fetched += rows.length;
8446
+ ingested += this.ingestMany(rows.map((row) => ({
8447
+ table,
8448
+ op: "CREATE",
8449
+ id: idString(row.id),
8450
+ record: row
8451
+ }))).length;
8452
+ }
8453
+ if (entries.length > 0) ctx.onVersions?.(table, entries.filter(([id]) => !ctx.pendingIds.has(id)));
8454
+ }
8455
+ if (!restored && ingested > 0) this.markSnapshotDirty(Math.max(ingested, CHECKPOINT_MIN_ROWS));
8456
+ this.logger.info({
8457
+ restored,
8458
+ rows: total,
8459
+ fetched,
8460
+ ingested,
8461
+ deleted,
8462
+ ms: Math.round(performance.now() - t0),
8463
+ Category: "sp00ky-client::StreamProcessorService::primeFromLocal"
8464
+ }, "Circuit primed from the local store");
8465
+ }
8466
+ /** Publish wasm updates produced outside an ingest (restore, reconcile). */
8467
+ dispatchWasmUpdates(updates) {
8468
+ if (!updates || updates.length === 0) return;
8469
+ this.dispatchUpdates(updates.map((u) => ({
8470
+ queryHash: u.query_id,
8471
+ localArray: u.result_data,
8472
+ op: "CREATE"
8473
+ })));
8474
+ }
8475
+ /**
8476
+ * Record that the circuit changed by `rows` rows. Cheap; the snapshot is
8477
+ * deferred to the checkpoint timer and skipped entirely when `persistCircuit`
8478
+ * is off.
8073
8479
  */
8074
- markSnapshotDirty() {
8480
+ markSnapshotDirty(rows = 1) {
8075
8481
  if (!this.persistCircuit || !this.persistState) return;
8076
8482
  this.snapshotDirty = true;
8483
+ this.dirtyRows += rows;
8077
8484
  this.startCheckpoints();
8078
8485
  }
8079
8486
  startCheckpoints() {
8080
8487
  if (this.checkpointTimer) return;
8081
8488
  this.checkpointTimer = setInterval(() => {
8082
- if (!this.snapshotDirty) return;
8083
- this.snapshotDirty = false;
8084
- this.saveState();
8489
+ if (!this.snapshotDirty || this.dirtyRows < CHECKPOINT_MIN_ROWS) return;
8490
+ this.checkpoint("interval");
8085
8491
  }, this.checkpointMs);
8086
- if (typeof window !== "undefined" && !this.pagehideHandler) {
8087
- this.pagehideHandler = () => {
8492
+ if (typeof window !== "undefined" && !this.hideHandler) {
8493
+ this.hideHandler = () => {
8088
8494
  if (!this.snapshotDirty) return;
8089
- this.snapshotDirty = false;
8090
- this.saveState();
8495
+ if (typeof document !== "undefined" && document.visibilityState === "visible") return;
8496
+ this.checkpoint("hidden");
8091
8497
  };
8092
- window.addEventListener("pagehide", this.pagehideHandler);
8498
+ window.addEventListener("visibilitychange", this.hideHandler);
8499
+ window.addEventListener("pagehide", this.hideHandler);
8093
8500
  }
8094
8501
  }
8095
- /** Stop checkpointing and drop the `pagehide` listener. */
8502
+ /** Stop checkpointing and drop the visibility listeners. */
8096
8503
  stopCheckpoints() {
8097
8504
  if (this.checkpointTimer) {
8098
8505
  clearInterval(this.checkpointTimer);
8099
8506
  this.checkpointTimer = null;
8100
8507
  }
8101
- if (this.pagehideHandler && typeof window !== "undefined") window.removeEventListener("pagehide", this.pagehideHandler);
8102
- this.pagehideHandler = null;
8508
+ if (this.hideHandler && typeof window !== "undefined") {
8509
+ window.removeEventListener("visibilitychange", this.hideHandler);
8510
+ window.removeEventListener("pagehide", this.hideHandler);
8511
+ }
8512
+ this.hideHandler = null;
8103
8513
  this.snapshotDirty = false;
8514
+ this.dirtyRows = 0;
8515
+ }
8516
+ /**
8517
+ * Write the circuit's store to the local store as a snapshot. Compacts the
8518
+ * row arena first when dead bytes outweigh live ones. Serialised: a second
8519
+ * call while one is in flight joins it. No-op unless persistence is on, this
8520
+ * tab owns the store, and the engine can hold a snapshot.
8521
+ */
8522
+ checkpoint(reason = "manual") {
8523
+ if (this.checkpointInFlight) return this.checkpointInFlight;
8524
+ const run = this.runCheckpoint(reason).finally(() => {
8525
+ this.checkpointInFlight = null;
8526
+ });
8527
+ this.checkpointInFlight = run;
8528
+ return run;
8104
8529
  }
8105
- async loadState() {
8106
- if (!this.processor || !this.persistState || !this.persistCircuit) return;
8530
+ async runCheckpoint(reason) {
8531
+ const processor = this.processor;
8532
+ if (!processor || !this.persistState || !this.persistCircuit || !this.schemaHash) return;
8533
+ if (typeof this.db.putSnapshot !== "function" || typeof processor.save_store_state !== "function") return;
8534
+ const generation = this.stateGeneration;
8535
+ const epoch = this.db.epoch;
8536
+ const rows = this.dirtyRows;
8537
+ this.snapshotDirty = false;
8538
+ this.dirtyRows = 0;
8107
8539
  try {
8108
- const result = await this.persistenceClient.get(this.stateKey());
8109
- const state = typeof result === "string" ? result : extractLegacyState(result);
8110
- if (state) {
8111
- this.logger.info({
8112
- stateLength: state.length,
8113
- Category: "sp00ky-client::StreamProcessorService::loadState"
8114
- }, "Loading state from DB");
8115
- if (typeof this.processor.load_state === "function") this.processor.load_state(state);
8116
- else this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::loadState" }, "load_state method not found on processor");
8117
- } else this.logger.info({ Category: "sp00ky-client::StreamProcessorService::loadState" }, "No saved state found");
8540
+ const t0 = performance.now();
8541
+ let reclaimed = 0;
8542
+ if (typeof processor.compact === "function" && typeof processor.dead_bytes === "function" && typeof processor.live_bytes === "function") {
8543
+ const dead = processor.dead_bytes();
8544
+ if (dead > 1e6 && dead > processor.live_bytes()) reclaimed = processor.compact();
8545
+ }
8546
+ const bytes = processor.save_store_state();
8547
+ const meta = {
8548
+ formatVersion: CIRCUIT_SNAPSHOT_FORMAT,
8549
+ schemaHash: this.schemaHash,
8550
+ savedAt: Date.now(),
8551
+ maxRv: typeof processor.max_row_versions === "function" ? processor.max_row_versions() : void 0
8552
+ };
8553
+ if (generation !== this.stateGeneration || epoch !== this.db.epoch) return;
8554
+ await this.db.putSnapshot(CIRCUIT_SNAPSHOT_KEY, bytes, meta);
8555
+ this.logger.info({
8556
+ reason,
8557
+ rows,
8558
+ bytes: bytes.byteLength,
8559
+ reclaimed,
8560
+ ms: Math.round(performance.now() - t0),
8561
+ Category: "sp00ky-client::StreamProcessorService::checkpoint"
8562
+ }, "Circuit snapshot written");
8118
8563
  } catch (e) {
8119
- this.logger.error({
8564
+ this.logger.warn({
8120
8565
  error: e,
8121
- Category: "sp00ky-client::StreamProcessorService::loadState"
8122
- }, "Failed to load state");
8566
+ reason,
8567
+ Category: "sp00ky-client::StreamProcessorService::checkpoint"
8568
+ }, "Circuit snapshot failed");
8569
+ }
8570
+ }
8571
+ /**
8572
+ * Projection widening: a newly registered view evaluates fields that rows
8573
+ * already in the circuit were stored without. Merge just those fields in,
8574
+ * table by table, from the local store. The view registered against what
8575
+ * was present and converges as the merges step through.
8576
+ */
8577
+ scheduleWiden(missing) {
8578
+ for (const [table, fields] of Object.entries(missing)) {
8579
+ const set = this.widenPending.get(table) ?? /* @__PURE__ */ new Set();
8580
+ for (const f of fields) set.add(f);
8581
+ this.widenPending.set(table, set);
8582
+ }
8583
+ this.widenQueue = this.widenQueue.then(() => this.runWiden()).catch((e) => {
8584
+ this.logger.warn({
8585
+ error: e,
8586
+ Category: "sp00ky-client::StreamProcessorService::widen"
8587
+ }, "Projection widening failed");
8588
+ });
8589
+ }
8590
+ async runWiden() {
8591
+ const processor = this.processor;
8592
+ if (!processor || typeof this.db.scanVersions !== "function") {
8593
+ this.widenPending.clear();
8594
+ return;
8595
+ }
8596
+ const generation = this.stateGeneration;
8597
+ const epoch = this.db.epoch;
8598
+ while (this.widenPending.size > 0) {
8599
+ const [table, fields] = this.widenPending.entries().next().value;
8600
+ this.widenPending.delete(table);
8601
+ const select = Array.from(fields);
8602
+ const versions = await this.db.scanVersions([table]);
8603
+ if (this.processor !== processor || generation !== this.stateGeneration || epoch !== this.db.epoch) return;
8604
+ const ids = (versions[table] ?? []).map(([id]) => id);
8605
+ let merged = 0;
8606
+ for (const chunk of chunks(ids, PRIME_CHUNK)) {
8607
+ const rows = await this.db.selectByIds(table, chunk, { select });
8608
+ if (this.processor !== processor || generation !== this.stateGeneration || epoch !== this.db.epoch) return;
8609
+ merged += this.ingestMany(rows.map((row) => ({
8610
+ table,
8611
+ op: "MERGE",
8612
+ id: idString(row.id),
8613
+ record: row
8614
+ }))).length;
8615
+ }
8616
+ this.logger.info({
8617
+ table,
8618
+ fields: select,
8619
+ merged,
8620
+ Category: "sp00ky-client::StreamProcessorService::widen"
8621
+ }, "Widened projected rows with newly evaluated fields");
8123
8622
  }
8124
8623
  }
8125
8624
  /**
@@ -8160,25 +8659,6 @@ var StreamProcessorService = class {
8160
8659
  Category: "sp00ky-client::StreamProcessorService::setSessionAuth"
8161
8660
  }, "Session auth context updated");
8162
8661
  }
8163
- async saveState() {
8164
- if (!this.processor || !this.persistState || !this.persistCircuit) return;
8165
- const generation = this.stateGeneration;
8166
- try {
8167
- if (typeof this.processor.save_state === "function") {
8168
- const state = this.processor.save_state();
8169
- if (generation !== this.stateGeneration) return;
8170
- if (state) {
8171
- await this.persistenceClient.set(this.stateKey(), state);
8172
- this.logger.trace({ Category: "sp00ky-client::StreamProcessorService::saveState" }, "State saved");
8173
- }
8174
- }
8175
- } catch (e) {
8176
- this.logger.error({
8177
- error: e,
8178
- Category: "sp00ky-client::StreamProcessorService::saveState"
8179
- }, "Failed to save state");
8180
- }
8181
- }
8182
8662
  /**
8183
8663
  * Ingest a record change into the processor.
8184
8664
  * Emits 'stream_update' event if materialized views are affected.
@@ -8212,7 +8692,7 @@ var StreamProcessorService = class {
8212
8692
  const updates = rawUpdates.map((u) => ({
8213
8693
  queryHash: u.query_id,
8214
8694
  localArray: u.result_data,
8215
- op,
8695
+ op: op === "MERGE" ? "UPDATE" : op,
8216
8696
  materializationTimeMs,
8217
8697
  storeApplyMs: u.timing_store_apply_ms,
8218
8698
  circuitStepMs: u.timing_circuit_step_ms,
@@ -8273,7 +8753,7 @@ var StreamProcessorService = class {
8273
8753
  snapshotMs: initialUpdate.timing_snapshot_ms ?? 0
8274
8754
  }
8275
8755
  };
8276
- this.markSnapshotDirty();
8756
+ if (initialUpdate.missing_fields && Object.keys(initialUpdate.missing_fields).length > 0) this.scheduleWiden(initialUpdate.missing_fields);
8277
8757
  this.logger.debug({
8278
8758
  queryHash: queryPlan.queryHash,
8279
8759
  surql: queryPlan.surql,
@@ -8296,7 +8776,6 @@ var StreamProcessorService = class {
8296
8776
  if (!this.processor) return;
8297
8777
  try {
8298
8778
  this.processor.unregister_view(queryHash);
8299
- this.markSnapshotDirty();
8300
8779
  } catch (e) {
8301
8780
  this.logger.error({
8302
8781
  error: e,
@@ -8427,6 +8906,16 @@ var CacheModule = class {
8427
8906
  lookup(recordId) {
8428
8907
  return this.versionLookups[recordId] ?? 0;
8429
8908
  }
8909
+ /**
8910
+ * Seed the version memo from rows the circuit was primed with out of the
8911
+ * local store, so the first post-reload sync diff does not re-download
8912
+ * bodies the browser already has. Only rows the prime actually put into the
8913
+ * circuit belong here: a memo entry with no circuit row would make the diff
8914
+ * flag the id forever while nothing ever fetches it.
8915
+ */
8916
+ primeVersions(entries) {
8917
+ for (const [id, rv] of entries) if (rv > (this.versionLookups[id] ?? 0)) this.versionLookups[id] = rv;
8918
+ }
8430
8919
  /** Drop the version cache on a bucket switch — a stale version would make
8431
8920
  * the sync diff skip fetching a body the new bucket legitimately needs. */
8432
8921
  clearVersionLookups() {
@@ -8475,9 +8964,10 @@ var CacheModule = class {
8475
8964
  await this.local.execute(query, params, { epoch });
8476
8965
  }
8477
8966
  if (this.local.epoch !== epoch) throw new StaleEpochError();
8967
+ const versionOf = /* @__PURE__ */ new Map();
8478
8968
  const bulk = populatedRecords.map((record) => {
8479
8969
  const recordId = encodeRecordId(record.record.id);
8480
- this.versionLookups[recordId] = record.version;
8970
+ versionOf.set(recordId, record.version);
8481
8971
  return {
8482
8972
  table: record.table,
8483
8973
  op: record.op,
@@ -8485,8 +8975,9 @@ var CacheModule = class {
8485
8975
  record: record.record
8486
8976
  };
8487
8977
  });
8488
- this.streamProcessor.ingestMany(bulk);
8489
- this.ingestRelay?.(bulk);
8978
+ const ingested = this.streamProcessor.ingestMany(bulk);
8979
+ for (const t of ingested) this.versionLookups[t.id] = versionOf.get(t.id) ?? 0;
8980
+ if (ingested.length > 0) this.ingestRelay?.(ingested);
8490
8981
  this.logger.debug({
8491
8982
  count: records.length,
8492
8983
  Category: "sp00ky-client::CacheModule::saveBatch"
@@ -8521,7 +9012,12 @@ var CacheModule = class {
8521
9012
  if (!skipDbDelete) await this.local.query("DELETE $id", { id: parseRecordIdString(id) }, { epoch });
8522
9013
  if (this.local.epoch !== epoch) throw new StaleEpochError();
8523
9014
  delete this.versionLookups[id];
8524
- this.streamProcessor.ingest(table, "DELETE", id, recordData);
9015
+ this.streamProcessor.ingestMany([{
9016
+ table,
9017
+ op: "DELETE",
9018
+ id,
9019
+ record: recordData
9020
+ }]);
8525
9021
  this.ingestRelay?.([{
8526
9022
  table,
8527
9023
  op: "DELETE",
@@ -10111,6 +10607,10 @@ var TabsCoordinator = class {
10111
10607
  leadershipId = 0;
10112
10608
  leaderTabId = null;
10113
10609
  brokerRole = "pending";
10610
+ /** Wall time from `become-leader` to `leader-ready` on the last promotion.
10611
+ * Reported in the DevTools role-change event: a slow store adopt (OPFS pool
10612
+ * contention after the previous leader died) shows up here as seconds. */
10613
+ lastPromotionMs = null;
10114
10614
  fingerprint;
10115
10615
  bucketId;
10116
10616
  broker;
@@ -10161,6 +10661,7 @@ var TabsCoordinator = class {
10161
10661
  this.deps.logger.info({
10162
10662
  role,
10163
10663
  leadershipId: this.leadershipId,
10664
+ promotionMs: role === "leader" ? this.lastPromotionMs : void 0,
10164
10665
  Category: "sp00ky-client::TabsCoordinator"
10165
10666
  }, "Tab role changed");
10166
10667
  for (const cb of this.roleListeners) cb(role);
@@ -10237,6 +10738,7 @@ var TabsCoordinator = class {
10237
10738
  if (this.closed) return;
10238
10739
  if (msg.leadershipId <= this.leadershipId && !msg.resumeHeld) return;
10239
10740
  const previousRole = this.role;
10741
+ const startedAt = Date.now();
10240
10742
  try {
10241
10743
  if (previousRole === "follower") this.teardownFollower("promoted");
10242
10744
  this.leadershipId = msg.leadershipId;
@@ -10258,7 +10760,8 @@ var TabsCoordinator = class {
10258
10760
  resumeHeld: msg.resumeHeld
10259
10761
  });
10260
10762
  this.hub = new LeaderSyncHub(msg.leadershipId, this.deps.logger);
10261
- await this.deps.hooks.becomeSyncLeader(this.hub);
10763
+ this.deps.hooks.becomeSyncLeader(this.hub);
10764
+ this.lastPromotionMs = Date.now() - startedAt;
10262
10765
  this.setRole("leader");
10263
10766
  this.broker.send({
10264
10767
  type: "leader-ready",
@@ -10266,6 +10769,12 @@ var TabsCoordinator = class {
10266
10769
  bucketId: this.bucketId,
10267
10770
  leadershipId: msg.leadershipId
10268
10771
  });
10772
+ this.deps.hooks.resumeSyncLeaderDuties().catch((e) => {
10773
+ this.deps.logger.error({
10774
+ err: e,
10775
+ Category: "sp00ky-client::TabsCoordinator"
10776
+ }, "Leader duties failed after promotion");
10777
+ });
10269
10778
  } catch (e) {
10270
10779
  const reason = e instanceof Error ? e.message : String(e);
10271
10780
  this.deps.logger.error({
@@ -11704,8 +12213,9 @@ var Sp00kyClient = class {
11704
12213
  else if (config.persistenceClient === "localstorage" || !config.persistenceClient) this.persistenceClient = new LocalStoragePersistenceClient(logger);
11705
12214
  else this.persistenceClient = config.persistenceClient;
11706
12215
  this.persistenceClient = new ResilientPersistenceClient(this.persistenceClient, logger);
11707
- this.streamProcessor = new StreamProcessorService(new EventSystem(["stream_update"]), this.local, this.persistenceClient, logger);
11708
- this.streamProcessor.configureCircuitPersistence(config.persistCircuit ?? false, config.circuitCheckpointMs);
12216
+ this.streamProcessor = new StreamProcessorService(new EventSystem(["stream_update"]), this.local, logger);
12217
+ this.streamProcessor.configureCircuitPersistence(config.persistCircuit ?? config.localEngine === "sqlite", config.circuitCheckpointMs);
12218
+ this.streamProcessor.configureProjection(config.circuitProjection ?? true);
11709
12219
  this.migrator = new LocalMigrator(this.local, logger);
11710
12220
  this.cache = new CacheModule(this.local, this.streamProcessor, (update) => {
11711
12221
  this.dataModule.onStreamUpdate(update);
@@ -11718,8 +12228,10 @@ var Sp00kyClient = class {
11718
12228
  anonymousLiveQueries: this.config.enableAnonymousLiveQueries,
11719
12229
  degradeAfterConsecutiveFailures: this.config.syncHealth === false ? 0 : this.config.syncHealth?.degradeAfterConsecutiveFailures ?? 3,
11720
12230
  pushTimeoutMs: this.config.pushTimeoutMs,
12231
+ downTimeoutMs: this.config.downTimeoutMs,
11721
12232
  connectionSupervisor: this.connectionSupervisor
11722
12233
  });
12234
+ this.sync.setPrimeGate(() => this.streamProcessor.whenPrimed());
11723
12235
  this.featureFlags = new FeatureFlagModule({
11724
12236
  dataModule: this.dataModule,
11725
12237
  sync: this.sync,
@@ -11772,7 +12284,7 @@ var Sp00kyClient = class {
11772
12284
  return new TabsCoordinator({
11773
12285
  tabId,
11774
12286
  fingerprint: computeTabsFingerprint({
11775
- coreVersion: "0.0.1-canary.203",
12287
+ coreVersion: "0.0.1-canary.205",
11776
12288
  schemaHash: hash53(this.config.schemaSurql),
11777
12289
  endpoint: this.config.database.endpoint ?? "",
11778
12290
  namespace: this.config.database.namespace,
@@ -11789,13 +12301,14 @@ var Sp00kyClient = class {
11789
12301
  onLeaderLost: (reason) => engine.onLeaderLost(reason),
11790
12302
  exposeClientPort: (clientId, port) => engine.exposeClientPort(clientId, port),
11791
12303
  removeClientPort: (clientId) => engine.removeClientPort(clientId),
11792
- becomeSyncLeader: async (hub) => {
12304
+ becomeSyncLeader: (hub) => {
11793
12305
  this.streamProcessor.setPersistenceEnabled(true);
11794
12306
  this.cache.setIngestRelay((tuples) => hub.relayIngest(tuples));
11795
12307
  this.sync.setTabContext("leader", tabId);
11796
12308
  this.dataModule.setTabId(tabId);
11797
- await this.sync.promoteToLeader(hub);
12309
+ this.sync.promoteToLeader(hub);
11798
12310
  },
12311
+ resumeSyncLeaderDuties: () => this.sync.resumeLeaderDuties(),
11799
12312
  becomeSyncFollower: (forwarder) => {
11800
12313
  this.streamProcessor.setPersistenceEnabled(false);
11801
12314
  this.cache.setIngestRelay(null);
@@ -11889,6 +12402,13 @@ var Sp00kyClient = class {
11889
12402
  if (this.tabsCoordinator) {
11890
12403
  this.tabsCoordinator.onRoleChange((role) => {
11891
12404
  this.sharedActive = role !== "solo";
12405
+ this.devTools.logEvent("TABS_ROLE_CHANGED", {
12406
+ role,
12407
+ tabId: this.tabsCoordinator?.tabId,
12408
+ leadershipId: this.tabsCoordinator?.leadershipId,
12409
+ leaderTabId: this.tabsCoordinator?.leaderTabId,
12410
+ promotionMs: this.tabsCoordinator?.lastPromotionMs
12411
+ });
11892
12412
  });
11893
12413
  try {
11894
12414
  const role = await this.tabsCoordinator.start(bootBucket);
@@ -11926,9 +12446,9 @@ var Sp00kyClient = class {
11926
12446
  }, "Blob cache failed to start; bucket files will not be cached locally");
11927
12447
  }
11928
12448
  })();
11929
- this.streamProcessor.setStateKeySuffix(bootBucket);
11930
12449
  await this.streamProcessor.init();
11931
12450
  this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
12451
+ this.primeCircuit();
11932
12452
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "StreamProcessor initialized");
11933
12453
  const restoredUserId = await this.auth.restoreSessionFromToken();
11934
12454
  const sessionId = this.mintSessionSalt();
@@ -12097,10 +12617,10 @@ var Sp00kyClient = class {
12097
12617
  Category: "sp00ky-client::Sp00kyClient::doSwitchBucket"
12098
12618
  }, "Blob cache bucket switch failed");
12099
12619
  }
12100
- this.streamProcessor.setStateKeySuffix(target);
12101
12620
  await this.streamProcessor.reset();
12102
12621
  this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
12103
12622
  this.cache.clearVersionLookups();
12623
+ this.primeCircuit();
12104
12624
  this.preloadedHashes.clear();
12105
12625
  } finally {
12106
12626
  reopen();
@@ -12132,6 +12652,7 @@ var Sp00kyClient = class {
12132
12652
  this.crdtManager.closeAll();
12133
12653
  this.crdtManager.dispose();
12134
12654
  await this.blobs.close().catch(() => {});
12655
+ await this.streamProcessor.checkpoint("close").catch(() => {});
12135
12656
  if (this.tabsCoordinator) await this.tabsCoordinator.stop();
12136
12657
  await this.local.close();
12137
12658
  await this.remote.close();
@@ -12419,6 +12940,31 @@ var Sp00kyClient = class {
12419
12940
  * the former to `encodeRecordId` reads `.table` off a string and throws
12420
12941
  * during boot.
12421
12942
  */
12943
+ /**
12944
+ * Prime the in-browser circuit from the local store. Builds the context the
12945
+ * stream processor needs: every synced table (the app schema plus the
12946
+ * server-written meta tables that sync down), a schema hash so a snapshot
12947
+ * projected under another schema is not trusted, and the ids whose local
12948
+ * `_00_rv` was bumped by an unsettled mutation.
12949
+ */
12950
+ async primeCircuit() {
12951
+ const tables = [
12952
+ ...this.config.schema.tables.map((t) => t.name),
12953
+ "_00_user_feature",
12954
+ "_00_app_release"
12955
+ ];
12956
+ let pendingIds = /* @__PURE__ */ new Set();
12957
+ try {
12958
+ const pending = await this.dataModule.getPendingRecordIds();
12959
+ pendingIds = new Set([...pending.writes, ...pending.deletes]);
12960
+ } catch {}
12961
+ await this.streamProcessor.primeFromLocal({
12962
+ tables,
12963
+ schemaHash: String(hash53(this.config.schemaSurql)),
12964
+ pendingIds,
12965
+ onVersions: (_table, entries) => this.cache.primeVersions(entries)
12966
+ });
12967
+ }
12422
12968
  sessionAuthId() {
12423
12969
  const id = this.auth.currentUser?.id;
12424
12970
  if (!id) return null;