@spooky-sync/core 0.0.1-canary.237 → 0.0.1-canary.239

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -148,7 +148,12 @@ const NETWORK_ERROR_PATTERNS = [
148
148
  "econnreset",
149
149
  "enotfound",
150
150
  "epipe",
151
- "abort"
151
+ "abort",
152
+ "transaction conflict",
153
+ "resource busy",
154
+ "can be retried",
155
+ "specify a namespace",
156
+ "specify a database"
152
157
  ];
153
158
  function classifySyncError(error) {
154
159
  const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
@@ -3510,13 +3515,6 @@ function mutationOwnerTabId(mutationId) {
3510
3515
 
3511
3516
  //#endregion
3512
3517
  //#region src/modules/data/index.ts
3513
- /**
3514
- * How many consecutive empty `_00_list_ref` reads it takes to believe a query
3515
- * really is empty, before any non-empty set has been seen this session. One
3516
- * read is the registration race (the SSP flushes a view's initial edges
3517
- * asynchronously); the second comes from the poll a cycle later.
3518
- */
3519
- const EMPTY_MEMBERSHIP_CONFIRMATIONS = 2;
3520
3518
  /** Push a timing sample (ms) into a rolling window, capped at the sample window. */
3521
3519
  function pushSample(samples, ms) {
3522
3520
  samples.push(ms);
@@ -3549,6 +3547,8 @@ function phaseStatOf(samples, lastMs) {
3549
3547
  * is LIVE on those edges, so a swept-but-still-watched view empties the UI.
3550
3548
  */
3551
3549
  const TTL_HEARTBEAT_FRACTION = .5;
3550
+ /** A query beaten this recently is skipped by `heartbeatOnWake`. */
3551
+ const WAKE_HEARTBEAT_MIN_GAP_MS = 3e4;
3552
3552
  var DataModule = class DataModule {
3553
3553
  /** Tab identity baked into mutation ids (shared-tabs rollback routing);
3554
3554
  * undefined in solo mode, where mutation-id falls back to a session id. */
@@ -3557,10 +3557,18 @@ var DataModule = class DataModule {
3557
3557
  pendingQueries = /* @__PURE__ */ new Map();
3558
3558
  subscriptions = /* @__PURE__ */ new Map();
3559
3559
  statusSubscriptions = /* @__PURE__ */ new Map();
3560
+ /** Observers of a query's authority (`membershipKnown`) flips, keyed like `statusSubscriptions`. */
3561
+ authoritySubscriptions = /* @__PURE__ */ new Map();
3560
3562
  mutationCallbacks = /* @__PURE__ */ new Set();
3561
3563
  debounceTimers = /* @__PURE__ */ new Map();
3562
3564
  pendingStreamUpdates = /* @__PURE__ */ new Map();
3563
3565
  fetchDepth = /* @__PURE__ */ new Map();
3566
+ /**
3567
+ * Observers of the AGGREGATE fetch activity: how many queries are mid-fetch
3568
+ * right now. What a "downloading" indicator in the app chrome hangs off,
3569
+ * instead of subscribing to every query's status one by one.
3570
+ */
3571
+ activitySubscriptions = /* @__PURE__ */ new Set();
3564
3572
  logger;
3565
3573
  /**
3566
3574
  * Optional observer notified whenever a query's fetch status changes.
@@ -3579,6 +3587,15 @@ var DataModule = class DataModule {
3579
3587
  */
3580
3588
  onHeartbeat;
3581
3589
  /**
3590
+ * Fired once per episode when the server's `_00_query` row for a query we
3591
+ * hold membership for is found missing (see `updateQueryRemoteArray`). Wired
3592
+ * by Sp00kyClient to enqueue a `register` down-event so the SSP rebuilds the
3593
+ * view; the client keeps rendering what it has meanwhile.
3594
+ */
3595
+ onViewLost;
3596
+ /** DevTools observer for authority flips; mirrors `onQueryStatusChange`. */
3597
+ onQueryAuthorityChange;
3598
+ /**
3582
3599
  * Optional hook fired by {@link deregisterQuery} when an opt-in query (e.g. a
3583
3600
  * viewport-windowed list cancelling an off-screen window) loses its last
3584
3601
  * subscriber. Wired by Sp00kyClient to enqueue a `cleanup` down-event, which
@@ -3712,6 +3729,42 @@ var DataModule = class DataModule {
3712
3729
  };
3713
3730
  }
3714
3731
  /**
3732
+ * Subscribe to a query's authority flips: whether server membership is known
3733
+ * for it. With `{ immediate: true }` the callback fires synchronously with
3734
+ * the current value (`false` for an unknown query).
3735
+ */
3736
+ subscribeAuthority(queryHash, callback, options = {}) {
3737
+ if (!this.authoritySubscriptions.has(queryHash)) this.authoritySubscriptions.set(queryHash, /* @__PURE__ */ new Set());
3738
+ this.authoritySubscriptions.get(queryHash)?.add(callback);
3739
+ if (options.immediate) callback(this.activeQueries.get(queryHash)?.config.membershipKnown === true);
3740
+ return () => {
3741
+ const subs = this.authoritySubscriptions.get(queryHash);
3742
+ if (subs) {
3743
+ subs.delete(callback);
3744
+ if (subs.size === 0) this.authoritySubscriptions.delete(queryHash);
3745
+ }
3746
+ };
3747
+ }
3748
+ /** Whether server membership is known for `queryHash` (see `QueryConfig.membershipKnown`). */
3749
+ isAuthoritative(queryHash) {
3750
+ return this.activeQueries.get(queryHash)?.config.membershipKnown === true;
3751
+ }
3752
+ /**
3753
+ * The single writer of `config.membershipKnown` for a live query. Fans out
3754
+ * to `subscribeAuthority` listeners and DevTools on a change; no-op when
3755
+ * unchanged or the query is unknown.
3756
+ */
3757
+ setMembershipKnown(queryHash, known) {
3758
+ const queryState = this.activeQueries.get(queryHash);
3759
+ if (!queryState) return;
3760
+ const was = queryState.config.membershipKnown === true;
3761
+ queryState.config.membershipKnown = known;
3762
+ if (was === known) return;
3763
+ this.onQueryAuthorityChange?.(queryHash, known);
3764
+ const subs = this.authoritySubscriptions.get(queryHash);
3765
+ if (subs) for (const callback of subs) callback(known);
3766
+ }
3767
+ /**
3715
3768
  * Set a query's fetch status and notify status observers (DevTools +
3716
3769
  * `subscribeStatus` listeners). No-op when the status is unchanged or the
3717
3770
  * query is unknown.
@@ -3733,7 +3786,10 @@ var DataModule = class DataModule {
3733
3786
  beginFetching(queryHash) {
3734
3787
  const depth = this.fetchDepth.get(queryHash) ?? 0;
3735
3788
  this.fetchDepth.set(queryHash, depth + 1);
3736
- if (depth === 0) this.setQueryStatus(queryHash, "fetching");
3789
+ if (depth === 0) {
3790
+ this.setQueryStatus(queryHash, "fetching");
3791
+ this.notifyActivity();
3792
+ }
3737
3793
  }
3738
3794
  /** Leave a fetch cycle started with {@link beginFetching}; emits `idle` on the last exit. */
3739
3795
  endFetching(queryHash) {
@@ -3741,10 +3797,40 @@ var DataModule = class DataModule {
3741
3797
  if (depth <= 1) {
3742
3798
  this.fetchDepth.delete(queryHash);
3743
3799
  this.setQueryStatus(queryHash, "idle");
3800
+ this.notifyActivity();
3744
3801
  return;
3745
3802
  }
3746
3803
  this.fetchDepth.set(queryHash, depth - 1);
3747
3804
  }
3805
+ /** How many queries are mid-fetch right now (see {@link subscribeActivity}). */
3806
+ get fetchingQueryCount() {
3807
+ return this.fetchDepth.size;
3808
+ }
3809
+ /**
3810
+ * Observe the number of queries currently fetching. Fires on every change of
3811
+ * that number (a query entering or leaving its outermost fetch cycle), and
3812
+ * synchronously with the current value when `immediate` is set. One
3813
+ * subscription per indicator, however many queries the page holds.
3814
+ */
3815
+ subscribeActivity(callback, options = {}) {
3816
+ this.activitySubscriptions.add(callback);
3817
+ if (options.immediate) callback(this.fetchDepth.size);
3818
+ return () => {
3819
+ this.activitySubscriptions.delete(callback);
3820
+ };
3821
+ }
3822
+ notifyActivity() {
3823
+ if (this.activitySubscriptions.size === 0) return;
3824
+ const n = this.fetchDepth.size;
3825
+ for (const cb of this.activitySubscriptions) try {
3826
+ cb(n);
3827
+ } catch (err) {
3828
+ this.logger.warn({
3829
+ err,
3830
+ Category: "sp00ky-client::DataModule::notifyActivity"
3831
+ }, "Activity subscriber threw");
3832
+ }
3833
+ }
3748
3834
  /**
3749
3835
  * Subscribe to mutations (for sync)
3750
3836
  */
@@ -4236,7 +4322,7 @@ var DataModule = class DataModule {
4236
4322
  if (epoch !== this.local.epoch) return;
4237
4323
  if (queryState.serverMembership) return;
4238
4324
  queryState.config.remoteArray = rows.map((r) => [encodeRecordId(r.id), r._00_rv || 1]);
4239
- queryState.config.membershipKnown = true;
4325
+ this.setMembershipKnown(hash, true);
4240
4326
  queryState.records = await this.materializeRecords(queryState);
4241
4327
  const subscribers = this.subscriptions.get(hash);
4242
4328
  if (subscribers) for (const cb of subscribers) cb(queryState.records);
@@ -4463,6 +4549,7 @@ var DataModule = class DataModule {
4463
4549
  this.cache.unregisterQuery(hash);
4464
4550
  this.activeQueries.delete(hash);
4465
4551
  this.subscriptions.delete(hash);
4552
+ this.authoritySubscriptions.delete(hash);
4466
4553
  }
4467
4554
  /**
4468
4555
  * Get query state by id (for sync and devtools)
@@ -4507,45 +4594,57 @@ var DataModule = class DataModule {
4507
4594
  hash,
4508
4595
  Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
4509
4596
  }, "Query to update remote array not found");
4510
- return;
4511
- }
4512
- let confirmed = remoteArray.length > 0 || queryState.config.remoteSeen === true;
4513
- if (remoteArray.length === 0 && !queryState.config.remoteSeen) {
4514
- const serverRowCount = opts?.serverRowCount;
4515
- const knownEmpty = serverRowCount === 0;
4516
- confirmed = knownEmpty;
4517
- if (!knownEmpty) {
4518
- const emptyReads = (queryState.config.emptyReads ?? 0) + 1;
4519
- queryState.config.emptyReads = emptyReads;
4520
- if (!(serverRowCount === null || serverRowCount === void 0 ? emptyReads >= EMPTY_MEMBERSHIP_CONFIRMATIONS : false)) {
4521
- this.logger.debug({
4522
- hash,
4523
- emptyReads,
4524
- serverRowCount,
4525
- Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
4526
- }, "Ignoring empty membership: the server still reports rows for this query");
4527
- return;
4597
+ return "ignored";
4598
+ }
4599
+ const meta = opts?.meta;
4600
+ if (meta) queryState.serverState = meta.present ? meta.state : null;
4601
+ const held = queryState.config.remoteArray ?? [];
4602
+ if (remoteArray.length === 0 && !opts?.verifiedRemoval) {
4603
+ if (!meta || !meta.present) {
4604
+ if (held.length > 0 || queryState.config.membershipKnown) {
4605
+ if (!queryState.viewLost) {
4606
+ queryState.viewLost = true;
4607
+ this.logger.warn({
4608
+ hash,
4609
+ held: held.length,
4610
+ Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
4611
+ }, "Server has no _00_query row for a query we hold membership for; keeping local state and re-registering");
4612
+ this.onViewLost?.(hash);
4613
+ }
4614
+ return "view-lost";
4528
4615
  }
4616
+ this.logger.debug({
4617
+ hash,
4618
+ Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
4619
+ }, "Ignoring empty membership: the _00_query row is not readable yet");
4620
+ return "ignored";
4621
+ }
4622
+ if (!(meta.rowCount === 0 && meta.state !== "materializing")) {
4623
+ this.logger.debug({
4624
+ hash,
4625
+ state: meta.state,
4626
+ rowCount: meta.rowCount,
4627
+ Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
4628
+ }, "Ignoring empty membership: the server has not confirmed this query empty");
4629
+ return "ignored";
4529
4630
  }
4530
4631
  }
4632
+ if (meta?.present) queryState.viewLost = false;
4531
4633
  const epoch = this.local.epoch;
4532
4634
  queryState.config.remoteArray = remoteArray;
4533
- queryState.config.membershipKnown = true;
4635
+ this.setMembershipKnown(hash, true);
4534
4636
  queryState.serverMembership = true;
4535
- if (remoteArray.length > 0) {
4536
- queryState.config.remoteSeen = true;
4537
- queryState.config.emptyReads = 0;
4538
- }
4539
- if (queryState.config.membershipKey) await this.writeWindowMembership(queryState.config.membershipKey, remoteArray, confirmed);
4637
+ if (queryState.config.membershipKey) await this.writeWindowMembership(queryState.config.membershipKey, remoteArray, true);
4540
4638
  try {
4541
4639
  await this.local.query(surql.seal(surql.updateSet("id", ["remoteArray"])), {
4542
4640
  id: queryState.config.id,
4543
4641
  remoteArray
4544
4642
  }, { epoch });
4545
4643
  } catch (err) {
4546
- if (err instanceof StaleEpochError) return;
4644
+ if (err instanceof StaleEpochError) return "applied";
4547
4645
  throw err;
4548
4646
  }
4647
+ return "applied";
4549
4648
  }
4550
4649
  /**
4551
4650
  * Cancel every armed timer ahead of a local-bucket switch: stream-update
@@ -4557,7 +4656,9 @@ var DataModule = class DataModule {
4557
4656
  for (const timer of this.debounceTimers.values()) clearTimeout(timer);
4558
4657
  this.debounceTimers.clear();
4559
4658
  this.pendingStreamUpdates.clear();
4659
+ const wasFetching = this.fetchDepth.size > 0;
4560
4660
  this.fetchDepth.clear();
4661
+ if (wasFetching) this.notifyActivity();
4561
4662
  for (const queryState of this.activeQueries.values()) if (queryState.ttlTimer) {
4562
4663
  clearTimeout(queryState.ttlTimer);
4563
4664
  queryState.ttlTimer = null;
@@ -4584,14 +4685,14 @@ var DataModule = class DataModule {
4584
4685
  config.localArray = [];
4585
4686
  config.remoteArray = [];
4586
4687
  config.subqueryRemoteArray = void 0;
4587
- config.membershipKnown = false;
4588
- config.remoteSeen = false;
4589
- config.emptyReads = 0;
4688
+ this.setMembershipKnown(hash, false);
4689
+ queryState.viewLost = false;
4690
+ queryState.serverState = null;
4590
4691
  if (config.membershipKey) {
4591
4692
  const durable = await this.getWindowMembership(config.membershipKey);
4592
4693
  if (durable && (durable.ids.length > 0 || durable.confirmed)) {
4593
4694
  config.remoteArray = durable.ids;
4594
- config.membershipKnown = true;
4695
+ this.setMembershipKnown(hash, true);
4595
4696
  }
4596
4697
  }
4597
4698
  queryState.hydrated = false;
@@ -5130,6 +5231,7 @@ var DataModule = class DataModule {
5130
5231
  }, "TTL heartbeat: no subscribers, stopping");
5131
5232
  return;
5132
5233
  }
5234
+ queryState.lastHeartbeatAt = Date.now();
5133
5235
  this.onHeartbeat?.(hash);
5134
5236
  this.logger.debug({
5135
5237
  hash,
@@ -5139,6 +5241,46 @@ var DataModule = class DataModule {
5139
5241
  this.startTTLHeartbeat(queryState, hash);
5140
5242
  }, heartbeatTime);
5141
5243
  }
5244
+ /**
5245
+ * Heartbeat every watched query NOW and re-anchor its timer.
5246
+ *
5247
+ * The TTL timer is the only thing keeping a view alive on the server, and
5248
+ * browsers throttle timers in hidden tabs (Chrome aligns them to one-minute
5249
+ * ticks after five minutes hidden, and suspends them outright on some
5250
+ * mobile platforms). A tab that comes back after a long stretch may have
5251
+ * missed its beat by more than the server's slack, and the sweep will have
5252
+ * taken the row and its edges. The client does not blank on that any more
5253
+ * (`updateQueryRemoteArray` treats a missing row as "view lost"), but the
5254
+ * sooner it finds out, the sooner the view is rebuilt: `heartbeatQuery`
5255
+ * re-registers a query whose row is gone. Called on `visibilitychange` (to
5256
+ * visible) and `online`.
5257
+ *
5258
+ * Queries beaten within the last `minGapMs` are skipped, so a flurry of
5259
+ * wake events costs one beat per query. There is deliberately no local
5260
+ * expiry anywhere in this module: the TTL is the server's, and the client's
5261
+ * only duty is to keep asserting liveness while something is watching.
5262
+ */
5263
+ heartbeatOnWake(minGapMs = WAKE_HEARTBEAT_MIN_GAP_MS) {
5264
+ const now = Date.now();
5265
+ let beaten = 0;
5266
+ for (const [hash, queryState] of this.activeQueries.entries()) {
5267
+ if ((this.subscriptions.get(hash)?.size ?? 0) === 0) continue;
5268
+ if (queryState.lastHeartbeatAt !== void 0 && now - queryState.lastHeartbeatAt < minGapMs) continue;
5269
+ if (queryState.ttlTimer) {
5270
+ clearTimeout(queryState.ttlTimer);
5271
+ queryState.ttlTimer = null;
5272
+ }
5273
+ queryState.lastHeartbeatAt = now;
5274
+ this.onHeartbeat?.(hash);
5275
+ this.startTTLHeartbeat(queryState, hash);
5276
+ beaten++;
5277
+ }
5278
+ if (beaten > 0) this.logger.debug({
5279
+ beaten,
5280
+ Category: "sp00ky-client::DataModule::heartbeatOnWake"
5281
+ }, "Wake heartbeat sent for watched queries");
5282
+ return beaten;
5283
+ }
5142
5284
  async replaceRecordInQueries(record) {
5143
5285
  for (const [queryHash, queryState] of this.activeQueries.entries()) {
5144
5286
  const index = queryState.records.findIndex((r) => r.id === record.id);
@@ -5730,11 +5872,19 @@ function buildListRefSelect(table) {
5730
5872
  * no matter how long it waits, which is why this is read alongside them rather
5731
5873
  * than a retry counter.
5732
5874
  *
5875
+ * `state` rides along: the SSP writes `materializing` when it hands a full
5876
+ * publish to its edge flusher and flips it to `ready` INSIDE the transaction
5877
+ * that writes the edges. So `ready` + no edges + `rowCount 0` is a real empty
5878
+ * result, `materializing` + no edges is "still publishing", and a missing row
5879
+ * (the whole object is `NONE`) is "the server no longer has this view". A
5880
+ * server that predates the marker returns `state: NONE` and callers fall back
5881
+ * to `rowCount` alone.
5882
+ *
5733
5883
  * Returns `NONE` (→ null) when the row is not readable or does not exist yet;
5734
5884
  * callers must treat that as "unknown", not as zero.
5735
5885
  */
5736
5886
  function buildQueryRowCountSelect() {
5737
- return "SELECT VALUE rowCount FROM ONLY $in";
5887
+ return "SELECT VALUE { rowCount: rowCount, state: state } FROM ONLY $in";
5738
5888
  }
5739
5889
  /**
5740
5890
  * Build the SurrealQL select for a query's SUBQUERY child edges — the
@@ -5767,7 +5917,7 @@ function buildListRefBatchSelect(table) {
5767
5917
  * without an id and must be treated as "unknown".
5768
5918
  */
5769
5919
  function buildQueryRowCountBatchSelect() {
5770
- return "SELECT VALUE { id: id, rowCount: rowCount } FROM $ins";
5920
+ return "SELECT VALUE { id: id, rowCount: rowCount, state: state } FROM $ins";
5771
5921
  }
5772
5922
  /** Known edges one poll round trip may carry before the cycle is split. */
5773
5923
  const LIST_REF_POLL_ROW_BUDGET = 1500;
@@ -5897,14 +6047,16 @@ var SyncEngine = class {
5897
6047
  Category: "sp00ky-client::SyncEngine::syncRecords"
5898
6048
  }, "SyncEngine.syncRecords diff");
5899
6049
  let stillRemoteIds = [];
5900
- if (removed.length > 0) stillRemoteIds = await this.handleRemovedRecords(removed);
6050
+ let confirmedRemovedIds = [];
6051
+ if (removed.length > 0) ({stillRemoteIds, confirmedRemovedIds} = await this.handleRemovedRecords(removed));
5901
6052
  const toFetch = [...added, ...updated].filter((item) => {
5902
6053
  const localVersion = this.cache.lookup(encodeRecordId(item.id));
5903
6054
  return !(localVersion && item.version <= localVersion);
5904
6055
  });
5905
6056
  if (toFetch.length === 0) return {
5906
6057
  remoteFetchMs: 0,
5907
- stillRemoteIds
6058
+ stillRemoteIds,
6059
+ confirmedRemovedIds
5908
6060
  };
5909
6061
  const versionMap = /* @__PURE__ */ new Map();
5910
6062
  for (const item of toFetch) versionMap.set(encodeRecordId(item.id), item.version);
@@ -5954,7 +6106,8 @@ var SyncEngine = class {
5954
6106
  this.events.emit(SyncEventTypes.RemoteDataIngested, { records: remoteResults });
5955
6107
  return {
5956
6108
  remoteFetchMs,
5957
- stillRemoteIds
6109
+ stillRemoteIds,
6110
+ confirmedRemovedIds
5958
6111
  };
5959
6112
  }
5960
6113
  /**
@@ -5986,9 +6139,13 @@ var SyncEngine = class {
5986
6139
  removed: removed.map((r) => r.toString()),
5987
6140
  Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
5988
6141
  }, "Remote existence check failed, skipping deletion to avoid clobbering fresh data");
5989
- return [];
6142
+ return {
6143
+ stillRemoteIds: [],
6144
+ confirmedRemovedIds: []
6145
+ };
5990
6146
  }
5991
6147
  const stillRemoteIds = [];
6148
+ const confirmedRemovedIds = [];
5992
6149
  for (const recordId of removed) {
5993
6150
  const recordIdStr = encodeRecordId(recordId);
5994
6151
  if (!existingRemoteIds.has(recordIdStr)) {
@@ -5997,9 +6154,13 @@ var SyncEngine = class {
5997
6154
  Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
5998
6155
  }, "Deleting confirmed removed record");
5999
6156
  await this.cache.delete(recordId.table.name, recordIdStr);
6157
+ confirmedRemovedIds.push(recordIdStr);
6000
6158
  } else stillRemoteIds.push(recordIdStr);
6001
6159
  }
6002
- return stillRemoteIds;
6160
+ return {
6161
+ stillRemoteIds,
6162
+ confirmedRemovedIds
6163
+ };
6003
6164
  }
6004
6165
  };
6005
6166
 
@@ -6211,6 +6372,55 @@ var SyncScheduler = class {
6211
6372
 
6212
6373
  //#endregion
6213
6374
  //#region src/modules/sync/sync.ts
6375
+ /** Fold a row-count select result into {@link ServerViewMeta}. */
6376
+ function metaFromRow(row) {
6377
+ if (!row || typeof row !== "object") return {
6378
+ present: false,
6379
+ rowCount: null,
6380
+ state: null
6381
+ };
6382
+ const state = row.state === "materializing" || row.state === "ready" ? row.state : null;
6383
+ return {
6384
+ present: true,
6385
+ rowCount: typeof row.rowCount === "number" ? row.rowCount : null,
6386
+ state
6387
+ };
6388
+ }
6389
+ /**
6390
+ * Re-read ladder for a registration that answered while the SSP was still
6391
+ * publishing (`state = 'materializing'`): the flusher window is ~100ms, so the
6392
+ * first rung usually lands it; the rest cover a slow write without waiting
6393
+ * for a backed-off poll tick.
6394
+ */
6395
+ const MATERIALIZING_REFETCH_LADDER_MS = [
6396
+ 150,
6397
+ 400,
6398
+ 1e3
6399
+ ];
6400
+ /**
6401
+ * How long LIVE `_00_list_ref` DELETEs for one query are held before they are
6402
+ * verified upstream as a batch. A view teardown (sweep, reset, republish)
6403
+ * arrives as one DELETE per edge within a few ms; a single peer delete costs
6404
+ * this plus one round trip.
6405
+ */
6406
+ const LIVE_REMOVAL_COALESCE_MS = 100;
6407
+ /**
6408
+ * Collapse duplicate `(id, version)` pairs to one per id, keeping the highest
6409
+ * version. `_00_list_ref` has no unique index on `(in, out)`, and a full
6410
+ * republish over edges that were never cleaned up (a scheduler restart
6411
+ * orphans them) used to leave two rows per record; the client must render
6412
+ * each record once regardless.
6413
+ */
6414
+ function dedupeRecordVersions(pairs) {
6415
+ if (pairs.length < 2) return pairs;
6416
+ const best = /* @__PURE__ */ new Map();
6417
+ for (const [id, version] of pairs) {
6418
+ const prev = best.get(id);
6419
+ if (prev === void 0 || version > prev) best.set(id, version);
6420
+ }
6421
+ if (best.size === pairs.length) return pairs;
6422
+ return Array.from(best.entries());
6423
+ }
6214
6424
  /**
6215
6425
  * The main synchronization engine for Sp00ky.
6216
6426
  * Handles the bidirectional synchronization between the local database and the remote backend.
@@ -6676,6 +6886,8 @@ var Sp00kySync = class Sp00kySync {
6676
6886
  async prepareBucketSwitch() {
6677
6887
  this.stopSelfHeal();
6678
6888
  this.stopListRefPoll();
6889
+ this.clearListRefRefetches();
6890
+ this.clearLiveRemovals();
6679
6891
  if (this.listRefPollInFlight) await this.listRefPollInFlight;
6680
6892
  await this.killRefLiveQuery();
6681
6893
  this.upQueue.clearDebounceTimers();
@@ -6849,7 +7061,7 @@ var Sp00kySync = class Sp00kySync {
6849
7061
  if (!snapshot) continue;
6850
7062
  this.listRefPolledAt.set(hash, now);
6851
7063
  try {
6852
- if (await this.applyListRefSnapshot(hash, snapshot)) anyChanged = true;
7064
+ if ((await this.applyListRefSnapshot(hash, snapshot)).changed) anyChanged = true;
6853
7065
  } catch (err) {
6854
7066
  this.logger.debug({
6855
7067
  err: err?.message ?? err,
@@ -6901,7 +7113,12 @@ var Sp00kySync = class Sp00kySync {
6901
7113
  for (const hash of hashById.values()) out.set(hash, {
6902
7114
  primary: [],
6903
7115
  subquery: [],
6904
- rowCount: null
7116
+ rowCount: null,
7117
+ meta: {
7118
+ present: false,
7119
+ rowCount: null,
7120
+ state: null
7121
+ }
6905
7122
  });
6906
7123
  for (const row of edges) {
6907
7124
  const hash = hashById.get(encodeRecordId(row.in));
@@ -6911,19 +7128,27 @@ var Sp00kySync = class Sp00kySync {
6911
7128
  if (row.parent == null) snapshot.primary.push(pair);
6912
7129
  else snapshot.subquery.push(pair);
6913
7130
  }
7131
+ for (const snapshot of out.values()) {
7132
+ snapshot.primary = dedupeRecordVersions(snapshot.primary);
7133
+ snapshot.subquery = dedupeRecordVersions(snapshot.subquery);
7134
+ }
6914
7135
  for (const count of Array.isArray(counts) ? counts : []) {
6915
7136
  if (!count || !count.id) continue;
6916
7137
  const hash = hashById.get(encodeRecordId(count.id));
6917
7138
  if (!hash) continue;
6918
- out.get(hash).rowCount = typeof count.rowCount === "number" ? count.rowCount : null;
7139
+ const snapshot = out.get(hash);
7140
+ snapshot.meta = metaFromRow(count);
7141
+ snapshot.rowCount = snapshot.meta.rowCount;
6919
7142
  }
6920
- if (edges.length === 0 && Array.from(out.entries()).some(([hash, snapshot]) => {
6921
- return (this.dataModule.getQueryByHash(hash)?.config.remoteArray?.length ?? 0) > 0 && (snapshot.rowCount ?? 0) > 0;
7143
+ if (Array.from(out.entries()).some(([hash, snapshot]) => {
7144
+ if ((this.dataModule.getQueryByHash(hash)?.config.remoteArray?.length ?? 0) === 0) return false;
7145
+ if (!snapshot.meta.present) return true;
7146
+ return edges.length === 0 && (snapshot.rowCount ?? 0) > 0;
6922
7147
  })) {
6923
7148
  this.logger.warn({
6924
7149
  hashes,
6925
7150
  Category: "sp00ky-client::Sp00kySync::fetchListRefSnapshots"
6926
- }, "Batched list_ref select returned no edges for queries that hold rows; re-reading one query at a time");
7151
+ }, "Batched list_ref select is not trusted for queries that hold rows (no edges, or a missing _00_query row); re-reading one query at a time");
6927
7152
  for (const hash of Array.from(out.keys())) {
6928
7153
  const queryState = this.dataModule.getQueryByHash(hash);
6929
7154
  if (!queryState) {
@@ -6944,13 +7169,15 @@ var Sp00kySync = class Sp00kySync {
6944
7169
  */
6945
7170
  async fetchListRefSnapshot(id) {
6946
7171
  const listRefTbl = this.listRefTable();
6947
- const [items, serverRowCount, children] = await this.remote.query(`${buildListRefSelect(listRefTbl)};\n${buildQueryRowCountSelect()};\n${buildSubqueryListRefSelect(listRefTbl)}`, { in: id });
7172
+ const [items, metaRow, children] = await this.remote.query(`${buildListRefSelect(listRefTbl)};\n${buildQueryRowCountSelect()};\n${buildSubqueryListRefSelect(listRefTbl)}`, { in: id });
6948
7173
  if (!Array.isArray(items)) return null;
6949
- const toPairs = (rows) => Array.isArray(rows) ? rows.map((item) => [encodeRecordId(item.out), item.version]) : [];
7174
+ const toPairs = (rows) => dedupeRecordVersions(Array.isArray(rows) ? rows.map((item) => [encodeRecordId(item.out), item.version]) : []);
7175
+ const meta = metaFromRow(metaRow);
6950
7176
  return {
6951
7177
  primary: toPairs(items),
6952
7178
  subquery: toPairs(children),
6953
- rowCount: typeof serverRowCount === "number" ? serverRowCount : null
7179
+ rowCount: meta.rowCount,
7180
+ meta
6954
7181
  };
6955
7182
  }
6956
7183
  /**
@@ -6964,14 +7191,18 @@ var Sp00kySync = class Sp00kySync {
6964
7191
  */
6965
7192
  async applyListRefSnapshot(queryHash, snapshot) {
6966
7193
  const queryState = this.dataModule.getQueryByHash(queryHash);
6967
- if (!queryState) return false;
7194
+ if (!queryState) return {
7195
+ changed: false,
7196
+ outcome: "unchanged"
7197
+ };
6968
7198
  const fresh = snapshot.primary;
6969
- const serverRowCount = snapshot.rowCount;
6970
7199
  const prevRemote = queryState.config.remoteArray ?? [];
7200
+ const differs = !recordVersionArraysEqual(fresh, queryState.config.remoteArray);
7201
+ let outcome = "unchanged";
7202
+ if (differs) outcome = await this.dataModule.updateQueryRemoteArray(queryHash, fresh, { meta: snapshot.meta });
7203
+ const applied = differs && outcome === "applied";
6971
7204
  const freshIds = new Set(fresh.map(([id]) => id));
6972
- const removedIds = prevRemote.filter(([id]) => !freshIds.has(id)).map(([id]) => id);
6973
- const changed = !recordVersionArraysEqual(fresh, queryState.config.remoteArray);
6974
- if (changed) await this.dataModule.updateQueryRemoteArray(queryHash, fresh, { serverRowCount });
7205
+ const removedIds = applied ? prevRemote.filter(([id]) => !freshIds.has(id)).map(([id]) => id) : [];
6975
7206
  try {
6976
7207
  await this.syncQuery(queryHash);
6977
7208
  } catch (err) {
@@ -6981,8 +7212,8 @@ var Sp00kySync = class Sp00kySync {
6981
7212
  Category: "sp00ky-client::Sp00kySync::applyListRefSnapshot"
6982
7213
  }, "syncQuery failed during poll");
6983
7214
  }
6984
- if (changed) this.dataModule.scheduleRematerialize(queryHash);
6985
- await this.applySubqueryChildren(queryHash, snapshot.subquery).catch((err) => {
7215
+ if (applied) this.dataModule.scheduleRematerialize(queryHash);
7216
+ if (outcome === "applied" || outcome === "unchanged") await this.applySubqueryChildren(queryHash, snapshot.subquery).catch((err) => {
6986
7217
  this.logger.info({
6987
7218
  err: err?.message ?? err,
6988
7219
  queryHash,
@@ -6998,7 +7229,66 @@ var Sp00kySync = class Sp00kySync {
6998
7229
  Category: "sp00ky-client::Sp00kySync::applyListRefSnapshot"
6999
7230
  }, "notifyQuerySynced failed during poll-removal re-render");
7000
7231
  }
7001
- return changed;
7232
+ return {
7233
+ changed: applied,
7234
+ outcome
7235
+ };
7236
+ }
7237
+ /**
7238
+ * Re-read ONE query's server state now (edges + `_00_query` row) and land
7239
+ * it through {@link applyListRefSnapshot}. The targeted form of the poll,
7240
+ * for the moments that cannot wait for the next tick: a registration that
7241
+ * answered `materializing`, and a LIVE removal that has to be checked
7242
+ * against the row before it may empty a painted list.
7243
+ */
7244
+ async refetchListRefForQuery(queryHash) {
7245
+ const queryState = this.dataModule.getQueryByHash(queryHash);
7246
+ if (!queryState) return null;
7247
+ const snapshot = await this.fetchListRefSnapshot(queryState.config.id);
7248
+ if (!snapshot) return null;
7249
+ return this.applyListRefSnapshot(queryHash, snapshot);
7250
+ }
7251
+ /** Per-query pending targeted re-reads: one timer and the ladder still to walk. */
7252
+ listRefRefetches = /* @__PURE__ */ new Map();
7253
+ /**
7254
+ * Schedule a targeted re-read of `queryHash` after `delays[0]` ms. If the
7255
+ * read still says the server is publishing (`materializing`), the next rung
7256
+ * of the ladder is armed; any other outcome ends it. Re-scheduling a hash
7257
+ * that already has a timer replaces it (latest wins), so bursts coalesce.
7258
+ */
7259
+ scheduleListRefRefetch(queryHash, delays) {
7260
+ const ladder = typeof delays === "number" ? [delays] : [...delays];
7261
+ if (ladder.length === 0) return;
7262
+ this.clearListRefRefetch(queryHash);
7263
+ const [first, ...rest] = ladder;
7264
+ const timer = setTimeout(() => {
7265
+ this.listRefRefetches.delete(queryHash);
7266
+ this.refetchListRefForQuery(queryHash).then((result) => {
7267
+ const queryState = this.dataModule.getQueryByHash(queryHash);
7268
+ if (result?.outcome === "ignored" && queryState?.serverState === "materializing" && rest.length > 0) this.scheduleListRefRefetch(queryHash, rest);
7269
+ }).catch((err) => {
7270
+ this.logger.info({
7271
+ err: err?.message ?? err,
7272
+ queryHash,
7273
+ Category: "sp00ky-client::Sp00kySync::scheduleListRefRefetch"
7274
+ }, "Targeted list_ref re-read failed; the poll will retry");
7275
+ });
7276
+ }, first);
7277
+ this.listRefRefetches.set(queryHash, {
7278
+ timer,
7279
+ ladder: rest
7280
+ });
7281
+ }
7282
+ clearListRefRefetch(queryHash) {
7283
+ const pending = this.listRefRefetches.get(queryHash);
7284
+ if (pending) {
7285
+ clearTimeout(pending.timer);
7286
+ this.listRefRefetches.delete(queryHash);
7287
+ }
7288
+ }
7289
+ clearListRefRefetches() {
7290
+ for (const { timer } of this.listRefRefetches.values()) clearTimeout(timer);
7291
+ this.listRefRefetches.clear();
7002
7292
  }
7003
7293
  /**
7004
7294
  * Resolve the current `_00_list_ref` table name for the active auth
@@ -7214,12 +7504,12 @@ var Sp00kySync = class Sp00kySync {
7214
7504
  }, "Live update is being processed");
7215
7505
  const diff = createDiffFromDbOp(action, recordId, version, localArray);
7216
7506
  const hash = extractIdPart(existing.config.id);
7507
+ if (action === "DELETE") {
7508
+ this.queueLiveRemoval(hash, recordId);
7509
+ return;
7510
+ }
7217
7511
  if (existing.config.membershipKnown) {
7218
- const membershipDiff = action === "DELETE" ? {
7219
- added: [],
7220
- updated: [],
7221
- removed: [recordId]
7222
- } : {
7512
+ const membershipDiff = {
7223
7513
  added: [{
7224
7514
  id: recordId,
7225
7515
  version
@@ -7231,8 +7521,69 @@ var Sp00kySync = class Sp00kySync {
7231
7521
  if (!recordVersionArraysEqual(next, existing.config.remoteArray ?? [])) await this.dataModule.updateQueryRemoteArray(hash, next);
7232
7522
  }
7233
7523
  await this.runSyncForQuery(hash, diff);
7234
- if (diff.removed.length > 0 && diff.added.length === 0 && diff.updated.length === 0) await this.dataModule.notifyQuerySynced(hash);
7235
- else if (diff.added.length === 0 && diff.updated.length === 0) this.dataModule.scheduleRematerialize(hash);
7524
+ if (diff.added.length === 0 && diff.updated.length === 0) this.dataModule.scheduleRematerialize(hash);
7525
+ }
7526
+ /** LIVE removals awaiting verification, per query, with their coalescing timer. */
7527
+ pendingLiveRemovals = /* @__PURE__ */ new Map();
7528
+ /**
7529
+ * Hold a LIVE `_00_list_ref` DELETE for {@link LIVE_REMOVAL_COALESCE_MS},
7530
+ * then verify the batch (see {@link flushLiveRemovals}). A sweep or a
7531
+ * republish delivers a view's whole set as one burst, so one verification
7532
+ * round covers it.
7533
+ */
7534
+ queueLiveRemoval(hash, recordId) {
7535
+ const key = encodeRecordId(recordId);
7536
+ const pending = this.pendingLiveRemovals.get(hash);
7537
+ if (pending) {
7538
+ pending.ids.set(key, recordId);
7539
+ return;
7540
+ }
7541
+ const ids = new Map([[key, recordId]]);
7542
+ const timer = setTimeout(() => {
7543
+ this.pendingLiveRemovals.delete(hash);
7544
+ this.flushLiveRemovals(hash, Array.from(ids.values())).catch((err) => {
7545
+ this.logger.info({
7546
+ err: err?.message ?? err,
7547
+ hash,
7548
+ Category: "sp00ky-client::Sp00kySync::flushLiveRemovals"
7549
+ }, "LIVE removal verification failed; the poll will converge");
7550
+ });
7551
+ }, LIVE_REMOVAL_COALESCE_MS);
7552
+ this.pendingLiveRemovals.set(hash, {
7553
+ ids,
7554
+ timer
7555
+ });
7556
+ }
7557
+ /**
7558
+ * Verify a coalesced batch of LIVE removals against the upstream records.
7559
+ * Ids that no longer exist upstream are dropped from membership (the body
7560
+ * delete already happened inside `syncRecords`) and the query re-renders.
7561
+ * Anything still present upstream - or a verification that could not run -
7562
+ * is settled by a targeted re-read of the server's set for this query,
7563
+ * where the `_00_query` row decides: gone = view lost (keep rows,
7564
+ * re-register), `materializing` = wait, `ready` = adopt the smaller set.
7565
+ */
7566
+ async flushLiveRemovals(hash, ids) {
7567
+ const existing = this.dataModule.getQueryByHash(hash);
7568
+ if (!existing || ids.length === 0) return;
7569
+ const { confirmedRemovedIds, stillRemoteIds } = await this.runSyncForQuery(hash, {
7570
+ added: [],
7571
+ updated: [],
7572
+ removed: ids
7573
+ });
7574
+ if (confirmedRemovedIds.length > 0 && existing.config.membershipKnown) {
7575
+ const gone = new Set(confirmedRemovedIds);
7576
+ const held = existing.config.remoteArray ?? [];
7577
+ const next = held.filter(([id]) => !gone.has(id));
7578
+ if (next.length !== held.length) await this.dataModule.updateQueryRemoteArray(hash, next, { verifiedRemoval: true });
7579
+ await this.dataModule.notifyQuerySynced(hash);
7580
+ }
7581
+ const unverified = confirmedRemovedIds.length + stillRemoteIds.length < ids.length;
7582
+ if (stillRemoteIds.length > 0 || unverified) this.scheduleListRefRefetch(hash, 0);
7583
+ }
7584
+ clearLiveRemovals() {
7585
+ for (const { timer } of this.pendingLiveRemovals.values()) clearTimeout(timer);
7586
+ this.pendingLiveRemovals.clear();
7236
7587
  }
7237
7588
  /**
7238
7589
  * Handle a LIVE change to a SUBQUERY child edge (a `_00_list_ref` row with
@@ -7433,7 +7784,7 @@ var Sp00kySync = class Sp00kySync {
7433
7784
  }
7434
7785
  const diff = new ArraySyncer(queryState.config.localArray, queryState.config.remoteArray).nextSet();
7435
7786
  if (!diff) return;
7436
- return this.runSyncForQuery(hash, diff);
7787
+ await this.runSyncForQuery(hash, diff);
7437
7788
  }
7438
7789
  /**
7439
7790
  * Run a sync for a single query while reflecting its fetch status. Marks the
@@ -7443,14 +7794,18 @@ var Sp00kySync = class Sp00kySync {
7443
7794
  * means the single resulting UI update lands after this completes.
7444
7795
  */
7445
7796
  async runSyncForQuery(hash, diff) {
7797
+ const nothing = {
7798
+ confirmedRemovedIds: [],
7799
+ stillRemoteIds: []
7800
+ };
7446
7801
  const prime = this.primeGate();
7447
7802
  if (prime !== this.settledPrime) {
7448
7803
  await prime;
7449
7804
  this.settledPrime = prime;
7450
7805
  const fresh = this.dataModule.getQueryByHash(hash);
7451
- if (!fresh) return;
7806
+ if (!fresh) return nothing;
7452
7807
  const recomputed = new ArraySyncer(fresh.config.localArray, fresh.config.remoteArray).nextSet();
7453
- if (!recomputed) return;
7808
+ if (!recomputed) return nothing;
7454
7809
  diff = recomputed;
7455
7810
  }
7456
7811
  if (diff.added.length > 0 || diff.updated.length > 0) {
@@ -7463,8 +7818,13 @@ var Sp00kySync = class Sp00kySync {
7463
7818
  }
7464
7819
  const fetching = diff.added.length + diff.updated.length > 0;
7465
7820
  if (fetching) this.dataModule.beginFetching(hash);
7821
+ let summary = nothing;
7466
7822
  try {
7467
- const { remoteFetchMs, stillRemoteIds } = await this.syncEngine.syncRecords(diff);
7823
+ const { remoteFetchMs, stillRemoteIds, confirmedRemovedIds } = await this.syncEngine.syncRecords(diff);
7824
+ summary = {
7825
+ confirmedRemovedIds,
7826
+ stillRemoteIds
7827
+ };
7468
7828
  if (fetching) this.dataModule.recordRemoteFetch(hash, remoteFetchMs);
7469
7829
  if (stillRemoteIds.length > 0) {
7470
7830
  const CONVERGE_AFTER = 3;
@@ -7503,6 +7863,7 @@ var Sp00kySync = class Sp00kySync {
7503
7863
  this.dataModule.endFetching(hash);
7504
7864
  }
7505
7865
  }
7866
+ return summary;
7506
7867
  }
7507
7868
  /**
7508
7869
  * Record ids with a pending local DELETE in the outbox (`_00_pending_mutations`).
@@ -7596,19 +7957,20 @@ var Sp00kySync = class Sp00kySync {
7596
7957
  ttl: queryState.config.ttl
7597
7958
  } });
7598
7959
  const listRefTbl = this.listRefTable();
7599
- const [items, serverRowCount] = await this.remote.query(`${buildListRefSelect(listRefTbl)};\n${buildQueryRowCountSelect()}`, { in: queryState.config.id });
7960
+ const [items, metaRow] = await this.remote.query(`${buildListRefSelect(listRefTbl)};\n${buildQueryRowCountSelect()}`, { in: queryState.config.id });
7961
+ const meta = metaFromRow(metaRow);
7600
7962
  this.logger.trace({
7601
7963
  queryId: encodeRecordId(queryState.config.id),
7602
7964
  items,
7603
7965
  Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
7604
7966
  }, "Got query record version array from remote");
7605
- const array = items.map((item) => [encodeRecordId(item.out), item.version]);
7967
+ const array = dedupeRecordVersions(items.map((item) => [encodeRecordId(item.out), item.version]));
7606
7968
  this.logger.debug({
7607
7969
  queryId: encodeRecordId(queryState.config.id),
7608
7970
  array,
7609
7971
  Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
7610
7972
  }, "createdRemoteQuery");
7611
- if (array) await this.dataModule.updateQueryRemoteArray(queryHash, array, { serverRowCount });
7973
+ if (await this.dataModule.updateQueryRemoteArray(queryHash, array, { meta }) === "ignored" && meta.present && meta.state === "materializing") this.scheduleListRefRefetch(queryHash, MATERIALIZING_REFETCH_LADDER_MS);
7612
7974
  await this.syncSubqueryChildren(queryHash).catch((err) => {
7613
7975
  this.logger.info({
7614
7976
  err: err?.message ?? err,
@@ -7700,6 +8062,12 @@ var Sp00kySync = class Sp00kySync {
7700
8062
  return;
7701
8063
  }
7702
8064
  }
8065
+ this.clearListRefRefetch(queryHash);
8066
+ const pendingRemoval = this.pendingLiveRemovals.get(queryHash);
8067
+ if (pendingRemoval) {
8068
+ clearTimeout(pendingRemoval.timer);
8069
+ this.pendingLiveRemovals.delete(queryHash);
8070
+ }
7703
8071
  this.dataModule.finalizeDeregister(queryHash);
7704
8072
  }
7705
8073
  };
@@ -8019,8 +8387,8 @@ function selfAllowlistedVariant(flag, userId) {
8019
8387
 
8020
8388
  //#endregion
8021
8389
  //#region src/modules/devtools/index.ts
8022
- const CORE_VERSION = "0.0.1-canary.237";
8023
- const WASM_VERSION = "0.0.1-canary.237";
8390
+ const CORE_VERSION = "0.0.1-canary.239";
8391
+ const WASM_VERSION = "0.0.1-canary.239";
8024
8392
  const SURREAL_VERSION = "3.0.3";
8025
8393
  var DevToolsService = class DevToolsService {
8026
8394
  eventsHistory = [];
@@ -8132,8 +8500,9 @@ var DevToolsService = class DevToolsService {
8132
8500
  remoteIds: remoteArray.slice(0, DevToolsService.STATE_IDS_CAP).map(([id]) => id),
8133
8501
  idsTruncated: localArray.length > DevToolsService.STATE_IDS_CAP || remoteArray.length > DevToolsService.STATE_IDS_CAP,
8134
8502
  membershipKnown: q.config.membershipKnown === true,
8135
- remoteSeen: q.config.remoteSeen === true,
8136
- emptyReads: q.config.emptyReads ?? 0,
8503
+ authoritative: q.config.membershipKnown === true,
8504
+ viewLost: q.viewLost === true,
8505
+ serverState: q.serverState ?? null,
8137
8506
  timings: this.dataManager.phaseTimings(q)
8138
8507
  });
8139
8508
  });
@@ -12909,6 +13278,19 @@ var Sp00kyClient = class {
12909
13278
  subscribeToPendingMutations(cb) {
12910
13279
  return this.sync.subscribeToPendingMutations(cb);
12911
13280
  }
13281
+ /** Number of queries mid-fetch right now. See {@link subscribeToFetchActivity}. */
13282
+ get fetchingQueryCount() {
13283
+ return this.dataModule.fetchingQueryCount;
13284
+ }
13285
+ /**
13286
+ * Observe how many queries are fetching from the server. Fires immediately
13287
+ * with the current count and again on every change, so one subscription can
13288
+ * drive a "downloading" indicator for the whole app. Pair with
13289
+ * {@link subscribeToPendingMutations} for the "uploading" half.
13290
+ */
13291
+ subscribeToFetchActivity(cb) {
13292
+ return this.dataModule.subscribeActivity(cb, { immediate: true });
13293
+ }
12912
13294
  /** Current sync-health snapshot. See {@link Sp00kyConfig.syncHealth}. */
12913
13295
  get syncHealth() {
12914
13296
  return this.sync.syncHealth;
@@ -13035,7 +13417,7 @@ var Sp00kyClient = class {
13035
13417
  return new TabsCoordinator({
13036
13418
  tabId,
13037
13419
  fingerprint: computeTabsFingerprint({
13038
- coreVersion: "0.0.1-canary.237",
13420
+ coreVersion: "0.0.1-canary.239",
13039
13421
  schemaHash: hash53(this.config.schemaSurql),
13040
13422
  endpoint: this.config.database.endpoint ?? "",
13041
13423
  namespace: this.config.database.namespace,
@@ -13102,6 +13484,19 @@ var Sp00kyClient = class {
13102
13484
  }, "TTL heartbeat failed");
13103
13485
  });
13104
13486
  };
13487
+ this.dataModule.onViewLost = (queryHash) => {
13488
+ this.devTools.logEvent("QUERY_VIEW_LOST", { queryHash });
13489
+ this.sync.enqueueDownEvent({
13490
+ type: "register",
13491
+ payload: { hash: queryHash }
13492
+ });
13493
+ };
13494
+ this.dataModule.onQueryAuthorityChange = (queryHash, known) => {
13495
+ this.devTools.logEvent("QUERY_AUTHORITY_CHANGED", {
13496
+ queryHash,
13497
+ known
13498
+ });
13499
+ };
13105
13500
  this.dataModule.onDeregister = (queryHash) => {
13106
13501
  this.sync.enqueueDownEvent({
13107
13502
  type: "cleanup",
@@ -13236,7 +13631,17 @@ var Sp00kyClient = class {
13236
13631
  this.sync.releaseViewsOnUnload();
13237
13632
  };
13238
13633
  window.addEventListener("pagehide", onPageHide);
13239
- this.detachUnloadRelease = () => window.removeEventListener("pagehide", onPageHide);
13634
+ const onWake = () => {
13635
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
13636
+ this.dataModule.heartbeatOnWake();
13637
+ };
13638
+ if (typeof document !== "undefined" && typeof document.addEventListener === "function") document.addEventListener("visibilitychange", onWake);
13639
+ window.addEventListener("online", onWake);
13640
+ this.detachUnloadRelease = () => {
13641
+ window.removeEventListener("pagehide", onPageHide);
13642
+ window.removeEventListener("online", onWake);
13643
+ if (typeof document !== "undefined" && typeof document.removeEventListener === "function") document.removeEventListener("visibilitychange", onWake);
13644
+ };
13240
13645
  }
13241
13646
  this.featureFlags.init();
13242
13647
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "FeatureFlagModule initialized");
@@ -13677,6 +14082,16 @@ var Sp00kyClient = class {
13677
14082
  return this.dataModule.subscribeStatus(queryHash, callback, options);
13678
14083
  }
13679
14084
  /**
14085
+ * Subscribe to a query's authority flips: `true` once server membership is
14086
+ * known for it (a registration or poll landed, or a durable `_00_window`
14087
+ * seed was read on boot), `false` when it is reset (bucket switch). With
14088
+ * `{ immediate: true }` the callback fires synchronously with the current
14089
+ * value. Powers the bindings' `isAuthoritative()` / cold-only `isLoading()`.
14090
+ */
14091
+ subscribeQueryAuthority(queryHash, callback, options) {
14092
+ return this.dataModule.subscribeAuthority(queryHash, callback, options);
14093
+ }
14094
+ /**
13680
14095
  * Report the frontend processing time (ms) a client framework spent applying
13681
14096
  * an update for a query (e.g. `useQuery`'s `reconcile()`), so DevTools/MCP can
13682
14097
  * surface the "frontend" phase of the per-query timing breakdown.