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

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
@@ -3510,13 +3510,6 @@ function mutationOwnerTabId(mutationId) {
3510
3510
 
3511
3511
  //#endregion
3512
3512
  //#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
3513
  /** Push a timing sample (ms) into a rolling window, capped at the sample window. */
3521
3514
  function pushSample(samples, ms) {
3522
3515
  samples.push(ms);
@@ -3549,6 +3542,8 @@ function phaseStatOf(samples, lastMs) {
3549
3542
  * is LIVE on those edges, so a swept-but-still-watched view empties the UI.
3550
3543
  */
3551
3544
  const TTL_HEARTBEAT_FRACTION = .5;
3545
+ /** A query beaten this recently is skipped by `heartbeatOnWake`. */
3546
+ const WAKE_HEARTBEAT_MIN_GAP_MS = 3e4;
3552
3547
  var DataModule = class DataModule {
3553
3548
  /** Tab identity baked into mutation ids (shared-tabs rollback routing);
3554
3549
  * undefined in solo mode, where mutation-id falls back to a session id. */
@@ -3557,6 +3552,8 @@ var DataModule = class DataModule {
3557
3552
  pendingQueries = /* @__PURE__ */ new Map();
3558
3553
  subscriptions = /* @__PURE__ */ new Map();
3559
3554
  statusSubscriptions = /* @__PURE__ */ new Map();
3555
+ /** Observers of a query's authority (`membershipKnown`) flips, keyed like `statusSubscriptions`. */
3556
+ authoritySubscriptions = /* @__PURE__ */ new Map();
3560
3557
  mutationCallbacks = /* @__PURE__ */ new Set();
3561
3558
  debounceTimers = /* @__PURE__ */ new Map();
3562
3559
  pendingStreamUpdates = /* @__PURE__ */ new Map();
@@ -3579,6 +3576,15 @@ var DataModule = class DataModule {
3579
3576
  */
3580
3577
  onHeartbeat;
3581
3578
  /**
3579
+ * Fired once per episode when the server's `_00_query` row for a query we
3580
+ * hold membership for is found missing (see `updateQueryRemoteArray`). Wired
3581
+ * by Sp00kyClient to enqueue a `register` down-event so the SSP rebuilds the
3582
+ * view; the client keeps rendering what it has meanwhile.
3583
+ */
3584
+ onViewLost;
3585
+ /** DevTools observer for authority flips; mirrors `onQueryStatusChange`. */
3586
+ onQueryAuthorityChange;
3587
+ /**
3582
3588
  * Optional hook fired by {@link deregisterQuery} when an opt-in query (e.g. a
3583
3589
  * viewport-windowed list cancelling an off-screen window) loses its last
3584
3590
  * subscriber. Wired by Sp00kyClient to enqueue a `cleanup` down-event, which
@@ -3712,6 +3718,42 @@ var DataModule = class DataModule {
3712
3718
  };
3713
3719
  }
3714
3720
  /**
3721
+ * Subscribe to a query's authority flips: whether server membership is known
3722
+ * for it. With `{ immediate: true }` the callback fires synchronously with
3723
+ * the current value (`false` for an unknown query).
3724
+ */
3725
+ subscribeAuthority(queryHash, callback, options = {}) {
3726
+ if (!this.authoritySubscriptions.has(queryHash)) this.authoritySubscriptions.set(queryHash, /* @__PURE__ */ new Set());
3727
+ this.authoritySubscriptions.get(queryHash)?.add(callback);
3728
+ if (options.immediate) callback(this.activeQueries.get(queryHash)?.config.membershipKnown === true);
3729
+ return () => {
3730
+ const subs = this.authoritySubscriptions.get(queryHash);
3731
+ if (subs) {
3732
+ subs.delete(callback);
3733
+ if (subs.size === 0) this.authoritySubscriptions.delete(queryHash);
3734
+ }
3735
+ };
3736
+ }
3737
+ /** Whether server membership is known for `queryHash` (see `QueryConfig.membershipKnown`). */
3738
+ isAuthoritative(queryHash) {
3739
+ return this.activeQueries.get(queryHash)?.config.membershipKnown === true;
3740
+ }
3741
+ /**
3742
+ * The single writer of `config.membershipKnown` for a live query. Fans out
3743
+ * to `subscribeAuthority` listeners and DevTools on a change; no-op when
3744
+ * unchanged or the query is unknown.
3745
+ */
3746
+ setMembershipKnown(queryHash, known) {
3747
+ const queryState = this.activeQueries.get(queryHash);
3748
+ if (!queryState) return;
3749
+ const was = queryState.config.membershipKnown === true;
3750
+ queryState.config.membershipKnown = known;
3751
+ if (was === known) return;
3752
+ this.onQueryAuthorityChange?.(queryHash, known);
3753
+ const subs = this.authoritySubscriptions.get(queryHash);
3754
+ if (subs) for (const callback of subs) callback(known);
3755
+ }
3756
+ /**
3715
3757
  * Set a query's fetch status and notify status observers (DevTools +
3716
3758
  * `subscribeStatus` listeners). No-op when the status is unchanged or the
3717
3759
  * query is unknown.
@@ -4236,7 +4278,7 @@ var DataModule = class DataModule {
4236
4278
  if (epoch !== this.local.epoch) return;
4237
4279
  if (queryState.serverMembership) return;
4238
4280
  queryState.config.remoteArray = rows.map((r) => [encodeRecordId(r.id), r._00_rv || 1]);
4239
- queryState.config.membershipKnown = true;
4281
+ this.setMembershipKnown(hash, true);
4240
4282
  queryState.records = await this.materializeRecords(queryState);
4241
4283
  const subscribers = this.subscriptions.get(hash);
4242
4284
  if (subscribers) for (const cb of subscribers) cb(queryState.records);
@@ -4463,6 +4505,7 @@ var DataModule = class DataModule {
4463
4505
  this.cache.unregisterQuery(hash);
4464
4506
  this.activeQueries.delete(hash);
4465
4507
  this.subscriptions.delete(hash);
4508
+ this.authoritySubscriptions.delete(hash);
4466
4509
  }
4467
4510
  /**
4468
4511
  * Get query state by id (for sync and devtools)
@@ -4507,45 +4550,57 @@ var DataModule = class DataModule {
4507
4550
  hash,
4508
4551
  Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
4509
4552
  }, "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;
4553
+ return "ignored";
4554
+ }
4555
+ const meta = opts?.meta;
4556
+ if (meta) queryState.serverState = meta.present ? meta.state : null;
4557
+ const held = queryState.config.remoteArray ?? [];
4558
+ if (remoteArray.length === 0 && !opts?.verifiedRemoval) {
4559
+ if (!meta || !meta.present) {
4560
+ if (held.length > 0 || queryState.config.membershipKnown) {
4561
+ if (!queryState.viewLost) {
4562
+ queryState.viewLost = true;
4563
+ this.logger.warn({
4564
+ hash,
4565
+ held: held.length,
4566
+ Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
4567
+ }, "Server has no _00_query row for a query we hold membership for; keeping local state and re-registering");
4568
+ this.onViewLost?.(hash);
4569
+ }
4570
+ return "view-lost";
4528
4571
  }
4572
+ this.logger.debug({
4573
+ hash,
4574
+ Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
4575
+ }, "Ignoring empty membership: the _00_query row is not readable yet");
4576
+ return "ignored";
4577
+ }
4578
+ if (!(meta.rowCount === 0 && meta.state !== "materializing")) {
4579
+ this.logger.debug({
4580
+ hash,
4581
+ state: meta.state,
4582
+ rowCount: meta.rowCount,
4583
+ Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
4584
+ }, "Ignoring empty membership: the server has not confirmed this query empty");
4585
+ return "ignored";
4529
4586
  }
4530
4587
  }
4588
+ if (meta?.present) queryState.viewLost = false;
4531
4589
  const epoch = this.local.epoch;
4532
4590
  queryState.config.remoteArray = remoteArray;
4533
- queryState.config.membershipKnown = true;
4591
+ this.setMembershipKnown(hash, true);
4534
4592
  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);
4593
+ if (queryState.config.membershipKey) await this.writeWindowMembership(queryState.config.membershipKey, remoteArray, true);
4540
4594
  try {
4541
4595
  await this.local.query(surql.seal(surql.updateSet("id", ["remoteArray"])), {
4542
4596
  id: queryState.config.id,
4543
4597
  remoteArray
4544
4598
  }, { epoch });
4545
4599
  } catch (err) {
4546
- if (err instanceof StaleEpochError) return;
4600
+ if (err instanceof StaleEpochError) return "applied";
4547
4601
  throw err;
4548
4602
  }
4603
+ return "applied";
4549
4604
  }
4550
4605
  /**
4551
4606
  * Cancel every armed timer ahead of a local-bucket switch: stream-update
@@ -4584,14 +4639,14 @@ var DataModule = class DataModule {
4584
4639
  config.localArray = [];
4585
4640
  config.remoteArray = [];
4586
4641
  config.subqueryRemoteArray = void 0;
4587
- config.membershipKnown = false;
4588
- config.remoteSeen = false;
4589
- config.emptyReads = 0;
4642
+ this.setMembershipKnown(hash, false);
4643
+ queryState.viewLost = false;
4644
+ queryState.serverState = null;
4590
4645
  if (config.membershipKey) {
4591
4646
  const durable = await this.getWindowMembership(config.membershipKey);
4592
4647
  if (durable && (durable.ids.length > 0 || durable.confirmed)) {
4593
4648
  config.remoteArray = durable.ids;
4594
- config.membershipKnown = true;
4649
+ this.setMembershipKnown(hash, true);
4595
4650
  }
4596
4651
  }
4597
4652
  queryState.hydrated = false;
@@ -5130,6 +5185,7 @@ var DataModule = class DataModule {
5130
5185
  }, "TTL heartbeat: no subscribers, stopping");
5131
5186
  return;
5132
5187
  }
5188
+ queryState.lastHeartbeatAt = Date.now();
5133
5189
  this.onHeartbeat?.(hash);
5134
5190
  this.logger.debug({
5135
5191
  hash,
@@ -5139,6 +5195,46 @@ var DataModule = class DataModule {
5139
5195
  this.startTTLHeartbeat(queryState, hash);
5140
5196
  }, heartbeatTime);
5141
5197
  }
5198
+ /**
5199
+ * Heartbeat every watched query NOW and re-anchor its timer.
5200
+ *
5201
+ * The TTL timer is the only thing keeping a view alive on the server, and
5202
+ * browsers throttle timers in hidden tabs (Chrome aligns them to one-minute
5203
+ * ticks after five minutes hidden, and suspends them outright on some
5204
+ * mobile platforms). A tab that comes back after a long stretch may have
5205
+ * missed its beat by more than the server's slack, and the sweep will have
5206
+ * taken the row and its edges. The client does not blank on that any more
5207
+ * (`updateQueryRemoteArray` treats a missing row as "view lost"), but the
5208
+ * sooner it finds out, the sooner the view is rebuilt: `heartbeatQuery`
5209
+ * re-registers a query whose row is gone. Called on `visibilitychange` (to
5210
+ * visible) and `online`.
5211
+ *
5212
+ * Queries beaten within the last `minGapMs` are skipped, so a flurry of
5213
+ * wake events costs one beat per query. There is deliberately no local
5214
+ * expiry anywhere in this module: the TTL is the server's, and the client's
5215
+ * only duty is to keep asserting liveness while something is watching.
5216
+ */
5217
+ heartbeatOnWake(minGapMs = WAKE_HEARTBEAT_MIN_GAP_MS) {
5218
+ const now = Date.now();
5219
+ let beaten = 0;
5220
+ for (const [hash, queryState] of this.activeQueries.entries()) {
5221
+ if ((this.subscriptions.get(hash)?.size ?? 0) === 0) continue;
5222
+ if (queryState.lastHeartbeatAt !== void 0 && now - queryState.lastHeartbeatAt < minGapMs) continue;
5223
+ if (queryState.ttlTimer) {
5224
+ clearTimeout(queryState.ttlTimer);
5225
+ queryState.ttlTimer = null;
5226
+ }
5227
+ queryState.lastHeartbeatAt = now;
5228
+ this.onHeartbeat?.(hash);
5229
+ this.startTTLHeartbeat(queryState, hash);
5230
+ beaten++;
5231
+ }
5232
+ if (beaten > 0) this.logger.debug({
5233
+ beaten,
5234
+ Category: "sp00ky-client::DataModule::heartbeatOnWake"
5235
+ }, "Wake heartbeat sent for watched queries");
5236
+ return beaten;
5237
+ }
5142
5238
  async replaceRecordInQueries(record) {
5143
5239
  for (const [queryHash, queryState] of this.activeQueries.entries()) {
5144
5240
  const index = queryState.records.findIndex((r) => r.id === record.id);
@@ -5730,11 +5826,19 @@ function buildListRefSelect(table) {
5730
5826
  * no matter how long it waits, which is why this is read alongside them rather
5731
5827
  * than a retry counter.
5732
5828
  *
5829
+ * `state` rides along: the SSP writes `materializing` when it hands a full
5830
+ * publish to its edge flusher and flips it to `ready` INSIDE the transaction
5831
+ * that writes the edges. So `ready` + no edges + `rowCount 0` is a real empty
5832
+ * result, `materializing` + no edges is "still publishing", and a missing row
5833
+ * (the whole object is `NONE`) is "the server no longer has this view". A
5834
+ * server that predates the marker returns `state: NONE` and callers fall back
5835
+ * to `rowCount` alone.
5836
+ *
5733
5837
  * Returns `NONE` (→ null) when the row is not readable or does not exist yet;
5734
5838
  * callers must treat that as "unknown", not as zero.
5735
5839
  */
5736
5840
  function buildQueryRowCountSelect() {
5737
- return "SELECT VALUE rowCount FROM ONLY $in";
5841
+ return "SELECT VALUE { rowCount: rowCount, state: state } FROM ONLY $in";
5738
5842
  }
5739
5843
  /**
5740
5844
  * Build the SurrealQL select for a query's SUBQUERY child edges — the
@@ -5767,7 +5871,7 @@ function buildListRefBatchSelect(table) {
5767
5871
  * without an id and must be treated as "unknown".
5768
5872
  */
5769
5873
  function buildQueryRowCountBatchSelect() {
5770
- return "SELECT VALUE { id: id, rowCount: rowCount } FROM $ins";
5874
+ return "SELECT VALUE { id: id, rowCount: rowCount, state: state } FROM $ins";
5771
5875
  }
5772
5876
  /** Known edges one poll round trip may carry before the cycle is split. */
5773
5877
  const LIST_REF_POLL_ROW_BUDGET = 1500;
@@ -5897,14 +6001,16 @@ var SyncEngine = class {
5897
6001
  Category: "sp00ky-client::SyncEngine::syncRecords"
5898
6002
  }, "SyncEngine.syncRecords diff");
5899
6003
  let stillRemoteIds = [];
5900
- if (removed.length > 0) stillRemoteIds = await this.handleRemovedRecords(removed);
6004
+ let confirmedRemovedIds = [];
6005
+ if (removed.length > 0) ({stillRemoteIds, confirmedRemovedIds} = await this.handleRemovedRecords(removed));
5901
6006
  const toFetch = [...added, ...updated].filter((item) => {
5902
6007
  const localVersion = this.cache.lookup(encodeRecordId(item.id));
5903
6008
  return !(localVersion && item.version <= localVersion);
5904
6009
  });
5905
6010
  if (toFetch.length === 0) return {
5906
6011
  remoteFetchMs: 0,
5907
- stillRemoteIds
6012
+ stillRemoteIds,
6013
+ confirmedRemovedIds
5908
6014
  };
5909
6015
  const versionMap = /* @__PURE__ */ new Map();
5910
6016
  for (const item of toFetch) versionMap.set(encodeRecordId(item.id), item.version);
@@ -5954,7 +6060,8 @@ var SyncEngine = class {
5954
6060
  this.events.emit(SyncEventTypes.RemoteDataIngested, { records: remoteResults });
5955
6061
  return {
5956
6062
  remoteFetchMs,
5957
- stillRemoteIds
6063
+ stillRemoteIds,
6064
+ confirmedRemovedIds
5958
6065
  };
5959
6066
  }
5960
6067
  /**
@@ -5986,9 +6093,13 @@ var SyncEngine = class {
5986
6093
  removed: removed.map((r) => r.toString()),
5987
6094
  Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
5988
6095
  }, "Remote existence check failed, skipping deletion to avoid clobbering fresh data");
5989
- return [];
6096
+ return {
6097
+ stillRemoteIds: [],
6098
+ confirmedRemovedIds: []
6099
+ };
5990
6100
  }
5991
6101
  const stillRemoteIds = [];
6102
+ const confirmedRemovedIds = [];
5992
6103
  for (const recordId of removed) {
5993
6104
  const recordIdStr = encodeRecordId(recordId);
5994
6105
  if (!existingRemoteIds.has(recordIdStr)) {
@@ -5997,9 +6108,13 @@ var SyncEngine = class {
5997
6108
  Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
5998
6109
  }, "Deleting confirmed removed record");
5999
6110
  await this.cache.delete(recordId.table.name, recordIdStr);
6111
+ confirmedRemovedIds.push(recordIdStr);
6000
6112
  } else stillRemoteIds.push(recordIdStr);
6001
6113
  }
6002
- return stillRemoteIds;
6114
+ return {
6115
+ stillRemoteIds,
6116
+ confirmedRemovedIds
6117
+ };
6003
6118
  }
6004
6119
  };
6005
6120
 
@@ -6211,6 +6326,55 @@ var SyncScheduler = class {
6211
6326
 
6212
6327
  //#endregion
6213
6328
  //#region src/modules/sync/sync.ts
6329
+ /** Fold a row-count select result into {@link ServerViewMeta}. */
6330
+ function metaFromRow(row) {
6331
+ if (!row || typeof row !== "object") return {
6332
+ present: false,
6333
+ rowCount: null,
6334
+ state: null
6335
+ };
6336
+ const state = row.state === "materializing" || row.state === "ready" ? row.state : null;
6337
+ return {
6338
+ present: true,
6339
+ rowCount: typeof row.rowCount === "number" ? row.rowCount : null,
6340
+ state
6341
+ };
6342
+ }
6343
+ /**
6344
+ * Re-read ladder for a registration that answered while the SSP was still
6345
+ * publishing (`state = 'materializing'`): the flusher window is ~100ms, so the
6346
+ * first rung usually lands it; the rest cover a slow write without waiting
6347
+ * for a backed-off poll tick.
6348
+ */
6349
+ const MATERIALIZING_REFETCH_LADDER_MS = [
6350
+ 150,
6351
+ 400,
6352
+ 1e3
6353
+ ];
6354
+ /**
6355
+ * How long LIVE `_00_list_ref` DELETEs for one query are held before they are
6356
+ * verified upstream as a batch. A view teardown (sweep, reset, republish)
6357
+ * arrives as one DELETE per edge within a few ms; a single peer delete costs
6358
+ * this plus one round trip.
6359
+ */
6360
+ const LIVE_REMOVAL_COALESCE_MS = 100;
6361
+ /**
6362
+ * Collapse duplicate `(id, version)` pairs to one per id, keeping the highest
6363
+ * version. `_00_list_ref` has no unique index on `(in, out)`, and a full
6364
+ * republish over edges that were never cleaned up (a scheduler restart
6365
+ * orphans them) used to leave two rows per record; the client must render
6366
+ * each record once regardless.
6367
+ */
6368
+ function dedupeRecordVersions(pairs) {
6369
+ if (pairs.length < 2) return pairs;
6370
+ const best = /* @__PURE__ */ new Map();
6371
+ for (const [id, version] of pairs) {
6372
+ const prev = best.get(id);
6373
+ if (prev === void 0 || version > prev) best.set(id, version);
6374
+ }
6375
+ if (best.size === pairs.length) return pairs;
6376
+ return Array.from(best.entries());
6377
+ }
6214
6378
  /**
6215
6379
  * The main synchronization engine for Sp00ky.
6216
6380
  * Handles the bidirectional synchronization between the local database and the remote backend.
@@ -6676,6 +6840,8 @@ var Sp00kySync = class Sp00kySync {
6676
6840
  async prepareBucketSwitch() {
6677
6841
  this.stopSelfHeal();
6678
6842
  this.stopListRefPoll();
6843
+ this.clearListRefRefetches();
6844
+ this.clearLiveRemovals();
6679
6845
  if (this.listRefPollInFlight) await this.listRefPollInFlight;
6680
6846
  await this.killRefLiveQuery();
6681
6847
  this.upQueue.clearDebounceTimers();
@@ -6849,7 +7015,7 @@ var Sp00kySync = class Sp00kySync {
6849
7015
  if (!snapshot) continue;
6850
7016
  this.listRefPolledAt.set(hash, now);
6851
7017
  try {
6852
- if (await this.applyListRefSnapshot(hash, snapshot)) anyChanged = true;
7018
+ if ((await this.applyListRefSnapshot(hash, snapshot)).changed) anyChanged = true;
6853
7019
  } catch (err) {
6854
7020
  this.logger.debug({
6855
7021
  err: err?.message ?? err,
@@ -6901,7 +7067,12 @@ var Sp00kySync = class Sp00kySync {
6901
7067
  for (const hash of hashById.values()) out.set(hash, {
6902
7068
  primary: [],
6903
7069
  subquery: [],
6904
- rowCount: null
7070
+ rowCount: null,
7071
+ meta: {
7072
+ present: false,
7073
+ rowCount: null,
7074
+ state: null
7075
+ }
6905
7076
  });
6906
7077
  for (const row of edges) {
6907
7078
  const hash = hashById.get(encodeRecordId(row.in));
@@ -6911,19 +7082,27 @@ var Sp00kySync = class Sp00kySync {
6911
7082
  if (row.parent == null) snapshot.primary.push(pair);
6912
7083
  else snapshot.subquery.push(pair);
6913
7084
  }
7085
+ for (const snapshot of out.values()) {
7086
+ snapshot.primary = dedupeRecordVersions(snapshot.primary);
7087
+ snapshot.subquery = dedupeRecordVersions(snapshot.subquery);
7088
+ }
6914
7089
  for (const count of Array.isArray(counts) ? counts : []) {
6915
7090
  if (!count || !count.id) continue;
6916
7091
  const hash = hashById.get(encodeRecordId(count.id));
6917
7092
  if (!hash) continue;
6918
- out.get(hash).rowCount = typeof count.rowCount === "number" ? count.rowCount : null;
7093
+ const snapshot = out.get(hash);
7094
+ snapshot.meta = metaFromRow(count);
7095
+ snapshot.rowCount = snapshot.meta.rowCount;
6919
7096
  }
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;
7097
+ if (Array.from(out.entries()).some(([hash, snapshot]) => {
7098
+ if ((this.dataModule.getQueryByHash(hash)?.config.remoteArray?.length ?? 0) === 0) return false;
7099
+ if (!snapshot.meta.present) return true;
7100
+ return edges.length === 0 && (snapshot.rowCount ?? 0) > 0;
6922
7101
  })) {
6923
7102
  this.logger.warn({
6924
7103
  hashes,
6925
7104
  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");
7105
+ }, "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
7106
  for (const hash of Array.from(out.keys())) {
6928
7107
  const queryState = this.dataModule.getQueryByHash(hash);
6929
7108
  if (!queryState) {
@@ -6944,13 +7123,15 @@ var Sp00kySync = class Sp00kySync {
6944
7123
  */
6945
7124
  async fetchListRefSnapshot(id) {
6946
7125
  const listRefTbl = this.listRefTable();
6947
- const [items, serverRowCount, children] = await this.remote.query(`${buildListRefSelect(listRefTbl)};\n${buildQueryRowCountSelect()};\n${buildSubqueryListRefSelect(listRefTbl)}`, { in: id });
7126
+ const [items, metaRow, children] = await this.remote.query(`${buildListRefSelect(listRefTbl)};\n${buildQueryRowCountSelect()};\n${buildSubqueryListRefSelect(listRefTbl)}`, { in: id });
6948
7127
  if (!Array.isArray(items)) return null;
6949
- const toPairs = (rows) => Array.isArray(rows) ? rows.map((item) => [encodeRecordId(item.out), item.version]) : [];
7128
+ const toPairs = (rows) => dedupeRecordVersions(Array.isArray(rows) ? rows.map((item) => [encodeRecordId(item.out), item.version]) : []);
7129
+ const meta = metaFromRow(metaRow);
6950
7130
  return {
6951
7131
  primary: toPairs(items),
6952
7132
  subquery: toPairs(children),
6953
- rowCount: typeof serverRowCount === "number" ? serverRowCount : null
7133
+ rowCount: meta.rowCount,
7134
+ meta
6954
7135
  };
6955
7136
  }
6956
7137
  /**
@@ -6964,14 +7145,18 @@ var Sp00kySync = class Sp00kySync {
6964
7145
  */
6965
7146
  async applyListRefSnapshot(queryHash, snapshot) {
6966
7147
  const queryState = this.dataModule.getQueryByHash(queryHash);
6967
- if (!queryState) return false;
7148
+ if (!queryState) return {
7149
+ changed: false,
7150
+ outcome: "unchanged"
7151
+ };
6968
7152
  const fresh = snapshot.primary;
6969
- const serverRowCount = snapshot.rowCount;
6970
7153
  const prevRemote = queryState.config.remoteArray ?? [];
7154
+ const differs = !recordVersionArraysEqual(fresh, queryState.config.remoteArray);
7155
+ let outcome = "unchanged";
7156
+ if (differs) outcome = await this.dataModule.updateQueryRemoteArray(queryHash, fresh, { meta: snapshot.meta });
7157
+ const applied = differs && outcome === "applied";
6971
7158
  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 });
7159
+ const removedIds = applied ? prevRemote.filter(([id]) => !freshIds.has(id)).map(([id]) => id) : [];
6975
7160
  try {
6976
7161
  await this.syncQuery(queryHash);
6977
7162
  } catch (err) {
@@ -6981,8 +7166,8 @@ var Sp00kySync = class Sp00kySync {
6981
7166
  Category: "sp00ky-client::Sp00kySync::applyListRefSnapshot"
6982
7167
  }, "syncQuery failed during poll");
6983
7168
  }
6984
- if (changed) this.dataModule.scheduleRematerialize(queryHash);
6985
- await this.applySubqueryChildren(queryHash, snapshot.subquery).catch((err) => {
7169
+ if (applied) this.dataModule.scheduleRematerialize(queryHash);
7170
+ if (outcome === "applied" || outcome === "unchanged") await this.applySubqueryChildren(queryHash, snapshot.subquery).catch((err) => {
6986
7171
  this.logger.info({
6987
7172
  err: err?.message ?? err,
6988
7173
  queryHash,
@@ -6998,7 +7183,66 @@ var Sp00kySync = class Sp00kySync {
6998
7183
  Category: "sp00ky-client::Sp00kySync::applyListRefSnapshot"
6999
7184
  }, "notifyQuerySynced failed during poll-removal re-render");
7000
7185
  }
7001
- return changed;
7186
+ return {
7187
+ changed: applied,
7188
+ outcome
7189
+ };
7190
+ }
7191
+ /**
7192
+ * Re-read ONE query's server state now (edges + `_00_query` row) and land
7193
+ * it through {@link applyListRefSnapshot}. The targeted form of the poll,
7194
+ * for the moments that cannot wait for the next tick: a registration that
7195
+ * answered `materializing`, and a LIVE removal that has to be checked
7196
+ * against the row before it may empty a painted list.
7197
+ */
7198
+ async refetchListRefForQuery(queryHash) {
7199
+ const queryState = this.dataModule.getQueryByHash(queryHash);
7200
+ if (!queryState) return null;
7201
+ const snapshot = await this.fetchListRefSnapshot(queryState.config.id);
7202
+ if (!snapshot) return null;
7203
+ return this.applyListRefSnapshot(queryHash, snapshot);
7204
+ }
7205
+ /** Per-query pending targeted re-reads: one timer and the ladder still to walk. */
7206
+ listRefRefetches = /* @__PURE__ */ new Map();
7207
+ /**
7208
+ * Schedule a targeted re-read of `queryHash` after `delays[0]` ms. If the
7209
+ * read still says the server is publishing (`materializing`), the next rung
7210
+ * of the ladder is armed; any other outcome ends it. Re-scheduling a hash
7211
+ * that already has a timer replaces it (latest wins), so bursts coalesce.
7212
+ */
7213
+ scheduleListRefRefetch(queryHash, delays) {
7214
+ const ladder = typeof delays === "number" ? [delays] : [...delays];
7215
+ if (ladder.length === 0) return;
7216
+ this.clearListRefRefetch(queryHash);
7217
+ const [first, ...rest] = ladder;
7218
+ const timer = setTimeout(() => {
7219
+ this.listRefRefetches.delete(queryHash);
7220
+ this.refetchListRefForQuery(queryHash).then((result) => {
7221
+ const queryState = this.dataModule.getQueryByHash(queryHash);
7222
+ if (result?.outcome === "ignored" && queryState?.serverState === "materializing" && rest.length > 0) this.scheduleListRefRefetch(queryHash, rest);
7223
+ }).catch((err) => {
7224
+ this.logger.info({
7225
+ err: err?.message ?? err,
7226
+ queryHash,
7227
+ Category: "sp00ky-client::Sp00kySync::scheduleListRefRefetch"
7228
+ }, "Targeted list_ref re-read failed; the poll will retry");
7229
+ });
7230
+ }, first);
7231
+ this.listRefRefetches.set(queryHash, {
7232
+ timer,
7233
+ ladder: rest
7234
+ });
7235
+ }
7236
+ clearListRefRefetch(queryHash) {
7237
+ const pending = this.listRefRefetches.get(queryHash);
7238
+ if (pending) {
7239
+ clearTimeout(pending.timer);
7240
+ this.listRefRefetches.delete(queryHash);
7241
+ }
7242
+ }
7243
+ clearListRefRefetches() {
7244
+ for (const { timer } of this.listRefRefetches.values()) clearTimeout(timer);
7245
+ this.listRefRefetches.clear();
7002
7246
  }
7003
7247
  /**
7004
7248
  * Resolve the current `_00_list_ref` table name for the active auth
@@ -7214,12 +7458,12 @@ var Sp00kySync = class Sp00kySync {
7214
7458
  }, "Live update is being processed");
7215
7459
  const diff = createDiffFromDbOp(action, recordId, version, localArray);
7216
7460
  const hash = extractIdPart(existing.config.id);
7461
+ if (action === "DELETE") {
7462
+ this.queueLiveRemoval(hash, recordId);
7463
+ return;
7464
+ }
7217
7465
  if (existing.config.membershipKnown) {
7218
- const membershipDiff = action === "DELETE" ? {
7219
- added: [],
7220
- updated: [],
7221
- removed: [recordId]
7222
- } : {
7466
+ const membershipDiff = {
7223
7467
  added: [{
7224
7468
  id: recordId,
7225
7469
  version
@@ -7231,8 +7475,69 @@ var Sp00kySync = class Sp00kySync {
7231
7475
  if (!recordVersionArraysEqual(next, existing.config.remoteArray ?? [])) await this.dataModule.updateQueryRemoteArray(hash, next);
7232
7476
  }
7233
7477
  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);
7478
+ if (diff.added.length === 0 && diff.updated.length === 0) this.dataModule.scheduleRematerialize(hash);
7479
+ }
7480
+ /** LIVE removals awaiting verification, per query, with their coalescing timer. */
7481
+ pendingLiveRemovals = /* @__PURE__ */ new Map();
7482
+ /**
7483
+ * Hold a LIVE `_00_list_ref` DELETE for {@link LIVE_REMOVAL_COALESCE_MS},
7484
+ * then verify the batch (see {@link flushLiveRemovals}). A sweep or a
7485
+ * republish delivers a view's whole set as one burst, so one verification
7486
+ * round covers it.
7487
+ */
7488
+ queueLiveRemoval(hash, recordId) {
7489
+ const key = encodeRecordId(recordId);
7490
+ const pending = this.pendingLiveRemovals.get(hash);
7491
+ if (pending) {
7492
+ pending.ids.set(key, recordId);
7493
+ return;
7494
+ }
7495
+ const ids = new Map([[key, recordId]]);
7496
+ const timer = setTimeout(() => {
7497
+ this.pendingLiveRemovals.delete(hash);
7498
+ this.flushLiveRemovals(hash, Array.from(ids.values())).catch((err) => {
7499
+ this.logger.info({
7500
+ err: err?.message ?? err,
7501
+ hash,
7502
+ Category: "sp00ky-client::Sp00kySync::flushLiveRemovals"
7503
+ }, "LIVE removal verification failed; the poll will converge");
7504
+ });
7505
+ }, LIVE_REMOVAL_COALESCE_MS);
7506
+ this.pendingLiveRemovals.set(hash, {
7507
+ ids,
7508
+ timer
7509
+ });
7510
+ }
7511
+ /**
7512
+ * Verify a coalesced batch of LIVE removals against the upstream records.
7513
+ * Ids that no longer exist upstream are dropped from membership (the body
7514
+ * delete already happened inside `syncRecords`) and the query re-renders.
7515
+ * Anything still present upstream - or a verification that could not run -
7516
+ * is settled by a targeted re-read of the server's set for this query,
7517
+ * where the `_00_query` row decides: gone = view lost (keep rows,
7518
+ * re-register), `materializing` = wait, `ready` = adopt the smaller set.
7519
+ */
7520
+ async flushLiveRemovals(hash, ids) {
7521
+ const existing = this.dataModule.getQueryByHash(hash);
7522
+ if (!existing || ids.length === 0) return;
7523
+ const { confirmedRemovedIds, stillRemoteIds } = await this.runSyncForQuery(hash, {
7524
+ added: [],
7525
+ updated: [],
7526
+ removed: ids
7527
+ });
7528
+ if (confirmedRemovedIds.length > 0 && existing.config.membershipKnown) {
7529
+ const gone = new Set(confirmedRemovedIds);
7530
+ const held = existing.config.remoteArray ?? [];
7531
+ const next = held.filter(([id]) => !gone.has(id));
7532
+ if (next.length !== held.length) await this.dataModule.updateQueryRemoteArray(hash, next, { verifiedRemoval: true });
7533
+ await this.dataModule.notifyQuerySynced(hash);
7534
+ }
7535
+ const unverified = confirmedRemovedIds.length + stillRemoteIds.length < ids.length;
7536
+ if (stillRemoteIds.length > 0 || unverified) this.scheduleListRefRefetch(hash, 0);
7537
+ }
7538
+ clearLiveRemovals() {
7539
+ for (const { timer } of this.pendingLiveRemovals.values()) clearTimeout(timer);
7540
+ this.pendingLiveRemovals.clear();
7236
7541
  }
7237
7542
  /**
7238
7543
  * Handle a LIVE change to a SUBQUERY child edge (a `_00_list_ref` row with
@@ -7433,7 +7738,7 @@ var Sp00kySync = class Sp00kySync {
7433
7738
  }
7434
7739
  const diff = new ArraySyncer(queryState.config.localArray, queryState.config.remoteArray).nextSet();
7435
7740
  if (!diff) return;
7436
- return this.runSyncForQuery(hash, diff);
7741
+ await this.runSyncForQuery(hash, diff);
7437
7742
  }
7438
7743
  /**
7439
7744
  * Run a sync for a single query while reflecting its fetch status. Marks the
@@ -7443,14 +7748,18 @@ var Sp00kySync = class Sp00kySync {
7443
7748
  * means the single resulting UI update lands after this completes.
7444
7749
  */
7445
7750
  async runSyncForQuery(hash, diff) {
7751
+ const nothing = {
7752
+ confirmedRemovedIds: [],
7753
+ stillRemoteIds: []
7754
+ };
7446
7755
  const prime = this.primeGate();
7447
7756
  if (prime !== this.settledPrime) {
7448
7757
  await prime;
7449
7758
  this.settledPrime = prime;
7450
7759
  const fresh = this.dataModule.getQueryByHash(hash);
7451
- if (!fresh) return;
7760
+ if (!fresh) return nothing;
7452
7761
  const recomputed = new ArraySyncer(fresh.config.localArray, fresh.config.remoteArray).nextSet();
7453
- if (!recomputed) return;
7762
+ if (!recomputed) return nothing;
7454
7763
  diff = recomputed;
7455
7764
  }
7456
7765
  if (diff.added.length > 0 || diff.updated.length > 0) {
@@ -7463,8 +7772,13 @@ var Sp00kySync = class Sp00kySync {
7463
7772
  }
7464
7773
  const fetching = diff.added.length + diff.updated.length > 0;
7465
7774
  if (fetching) this.dataModule.beginFetching(hash);
7775
+ let summary = nothing;
7466
7776
  try {
7467
- const { remoteFetchMs, stillRemoteIds } = await this.syncEngine.syncRecords(diff);
7777
+ const { remoteFetchMs, stillRemoteIds, confirmedRemovedIds } = await this.syncEngine.syncRecords(diff);
7778
+ summary = {
7779
+ confirmedRemovedIds,
7780
+ stillRemoteIds
7781
+ };
7468
7782
  if (fetching) this.dataModule.recordRemoteFetch(hash, remoteFetchMs);
7469
7783
  if (stillRemoteIds.length > 0) {
7470
7784
  const CONVERGE_AFTER = 3;
@@ -7503,6 +7817,7 @@ var Sp00kySync = class Sp00kySync {
7503
7817
  this.dataModule.endFetching(hash);
7504
7818
  }
7505
7819
  }
7820
+ return summary;
7506
7821
  }
7507
7822
  /**
7508
7823
  * Record ids with a pending local DELETE in the outbox (`_00_pending_mutations`).
@@ -7596,19 +7911,20 @@ var Sp00kySync = class Sp00kySync {
7596
7911
  ttl: queryState.config.ttl
7597
7912
  } });
7598
7913
  const listRefTbl = this.listRefTable();
7599
- const [items, serverRowCount] = await this.remote.query(`${buildListRefSelect(listRefTbl)};\n${buildQueryRowCountSelect()}`, { in: queryState.config.id });
7914
+ const [items, metaRow] = await this.remote.query(`${buildListRefSelect(listRefTbl)};\n${buildQueryRowCountSelect()}`, { in: queryState.config.id });
7915
+ const meta = metaFromRow(metaRow);
7600
7916
  this.logger.trace({
7601
7917
  queryId: encodeRecordId(queryState.config.id),
7602
7918
  items,
7603
7919
  Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
7604
7920
  }, "Got query record version array from remote");
7605
- const array = items.map((item) => [encodeRecordId(item.out), item.version]);
7921
+ const array = dedupeRecordVersions(items.map((item) => [encodeRecordId(item.out), item.version]));
7606
7922
  this.logger.debug({
7607
7923
  queryId: encodeRecordId(queryState.config.id),
7608
7924
  array,
7609
7925
  Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
7610
7926
  }, "createdRemoteQuery");
7611
- if (array) await this.dataModule.updateQueryRemoteArray(queryHash, array, { serverRowCount });
7927
+ if (await this.dataModule.updateQueryRemoteArray(queryHash, array, { meta }) === "ignored" && meta.present && meta.state === "materializing") this.scheduleListRefRefetch(queryHash, MATERIALIZING_REFETCH_LADDER_MS);
7612
7928
  await this.syncSubqueryChildren(queryHash).catch((err) => {
7613
7929
  this.logger.info({
7614
7930
  err: err?.message ?? err,
@@ -7700,6 +8016,12 @@ var Sp00kySync = class Sp00kySync {
7700
8016
  return;
7701
8017
  }
7702
8018
  }
8019
+ this.clearListRefRefetch(queryHash);
8020
+ const pendingRemoval = this.pendingLiveRemovals.get(queryHash);
8021
+ if (pendingRemoval) {
8022
+ clearTimeout(pendingRemoval.timer);
8023
+ this.pendingLiveRemovals.delete(queryHash);
8024
+ }
7703
8025
  this.dataModule.finalizeDeregister(queryHash);
7704
8026
  }
7705
8027
  };
@@ -8019,8 +8341,8 @@ function selfAllowlistedVariant(flag, userId) {
8019
8341
 
8020
8342
  //#endregion
8021
8343
  //#region src/modules/devtools/index.ts
8022
- const CORE_VERSION = "0.0.1-canary.237";
8023
- const WASM_VERSION = "0.0.1-canary.237";
8344
+ const CORE_VERSION = "0.0.1-canary.238";
8345
+ const WASM_VERSION = "0.0.1-canary.238";
8024
8346
  const SURREAL_VERSION = "3.0.3";
8025
8347
  var DevToolsService = class DevToolsService {
8026
8348
  eventsHistory = [];
@@ -8132,8 +8454,9 @@ var DevToolsService = class DevToolsService {
8132
8454
  remoteIds: remoteArray.slice(0, DevToolsService.STATE_IDS_CAP).map(([id]) => id),
8133
8455
  idsTruncated: localArray.length > DevToolsService.STATE_IDS_CAP || remoteArray.length > DevToolsService.STATE_IDS_CAP,
8134
8456
  membershipKnown: q.config.membershipKnown === true,
8135
- remoteSeen: q.config.remoteSeen === true,
8136
- emptyReads: q.config.emptyReads ?? 0,
8457
+ authoritative: q.config.membershipKnown === true,
8458
+ viewLost: q.viewLost === true,
8459
+ serverState: q.serverState ?? null,
8137
8460
  timings: this.dataManager.phaseTimings(q)
8138
8461
  });
8139
8462
  });
@@ -13035,7 +13358,7 @@ var Sp00kyClient = class {
13035
13358
  return new TabsCoordinator({
13036
13359
  tabId,
13037
13360
  fingerprint: computeTabsFingerprint({
13038
- coreVersion: "0.0.1-canary.237",
13361
+ coreVersion: "0.0.1-canary.238",
13039
13362
  schemaHash: hash53(this.config.schemaSurql),
13040
13363
  endpoint: this.config.database.endpoint ?? "",
13041
13364
  namespace: this.config.database.namespace,
@@ -13102,6 +13425,19 @@ var Sp00kyClient = class {
13102
13425
  }, "TTL heartbeat failed");
13103
13426
  });
13104
13427
  };
13428
+ this.dataModule.onViewLost = (queryHash) => {
13429
+ this.devTools.logEvent("QUERY_VIEW_LOST", { queryHash });
13430
+ this.sync.enqueueDownEvent({
13431
+ type: "register",
13432
+ payload: { hash: queryHash }
13433
+ });
13434
+ };
13435
+ this.dataModule.onQueryAuthorityChange = (queryHash, known) => {
13436
+ this.devTools.logEvent("QUERY_AUTHORITY_CHANGED", {
13437
+ queryHash,
13438
+ known
13439
+ });
13440
+ };
13105
13441
  this.dataModule.onDeregister = (queryHash) => {
13106
13442
  this.sync.enqueueDownEvent({
13107
13443
  type: "cleanup",
@@ -13236,7 +13572,17 @@ var Sp00kyClient = class {
13236
13572
  this.sync.releaseViewsOnUnload();
13237
13573
  };
13238
13574
  window.addEventListener("pagehide", onPageHide);
13239
- this.detachUnloadRelease = () => window.removeEventListener("pagehide", onPageHide);
13575
+ const onWake = () => {
13576
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
13577
+ this.dataModule.heartbeatOnWake();
13578
+ };
13579
+ if (typeof document !== "undefined" && typeof document.addEventListener === "function") document.addEventListener("visibilitychange", onWake);
13580
+ window.addEventListener("online", onWake);
13581
+ this.detachUnloadRelease = () => {
13582
+ window.removeEventListener("pagehide", onPageHide);
13583
+ window.removeEventListener("online", onWake);
13584
+ if (typeof document !== "undefined" && typeof document.removeEventListener === "function") document.removeEventListener("visibilitychange", onWake);
13585
+ };
13240
13586
  }
13241
13587
  this.featureFlags.init();
13242
13588
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "FeatureFlagModule initialized");
@@ -13677,6 +14023,16 @@ var Sp00kyClient = class {
13677
14023
  return this.dataModule.subscribeStatus(queryHash, callback, options);
13678
14024
  }
13679
14025
  /**
14026
+ * Subscribe to a query's authority flips: `true` once server membership is
14027
+ * known for it (a registration or poll landed, or a durable `_00_window`
14028
+ * seed was read on boot), `false` when it is reset (bucket switch). With
14029
+ * `{ immediate: true }` the callback fires synchronously with the current
14030
+ * value. Powers the bindings' `isAuthoritative()` / cold-only `isLoading()`.
14031
+ */
14032
+ subscribeQueryAuthority(queryHash, callback, options) {
14033
+ return this.dataModule.subscribeAuthority(queryHash, callback, options);
14034
+ }
14035
+ /**
13680
14036
  * Report the frontend processing time (ms) a client framework spent applying
13681
14037
  * an update for a query (e.g. `useQuery`'s `reconcile()`), so DevTools/MCP can
13682
14038
  * surface the "frontend" phase of the per-query timing breakdown.