@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.d.ts +107 -5
- package/dist/index.js +437 -81
- package/dist/otel/index.d.ts +1 -1
- package/dist/types.d.ts +40 -27
- package/package.json +3 -3
- package/src/modules/data/data.authority.test.ts +122 -0
- package/src/modules/data/data.heartbeat-wake.test.ts +115 -0
- package/src/modules/data/data.membership.test.ts +168 -68
- package/src/modules/data/index.ts +201 -83
- package/src/modules/devtools/index.ts +3 -2
- package/src/modules/sync/engine.ts +12 -7
- package/src/modules/sync/sync.health.test.ts +13 -3
- package/src/modules/sync/sync.list-ref-meta.test.ts +193 -0
- package/src/modules/sync/sync.live-removal.test.ts +137 -23
- package/src/modules/sync/sync.ts +326 -60
- package/src/modules/sync/utils.test.ts +1 -1
- package/src/modules/sync/utils.ts +10 -2
- package/src/sp00ky.ts +49 -1
- package/src/types.ts +41 -26
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
|
-
|
|
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
|
-
|
|
4513
|
-
if (
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
}
|
|
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
|
-
|
|
4591
|
+
this.setMembershipKnown(hash, true);
|
|
4534
4592
|
queryState.serverMembership = true;
|
|
4535
|
-
if (
|
|
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
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
7093
|
+
const snapshot = out.get(hash);
|
|
7094
|
+
snapshot.meta = metaFromRow(count);
|
|
7095
|
+
snapshot.rowCount = snapshot.meta.rowCount;
|
|
6919
7096
|
}
|
|
6920
|
-
if (
|
|
6921
|
-
|
|
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
|
|
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,
|
|
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:
|
|
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
|
|
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 (
|
|
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
|
|
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 =
|
|
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.
|
|
7235
|
-
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
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.
|
|
8023
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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
|
-
|
|
8136
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|