@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/AGENTS.md +1 -1
- package/dist/index.d.ts +134 -5
- package/dist/index.js +498 -83
- 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.activity.test.ts +109 -0
- 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 +248 -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 +64 -1
- package/src/types.ts +41 -26
- package/src/utils/error-classification.test.ts +10 -0
- package/src/utils/error-classification.ts +18 -0
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)
|
|
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
|
-
|
|
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
|
-
|
|
4513
|
-
if (
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
}
|
|
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
|
-
|
|
4635
|
+
this.setMembershipKnown(hash, true);
|
|
4534
4636
|
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);
|
|
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
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
7139
|
+
const snapshot = out.get(hash);
|
|
7140
|
+
snapshot.meta = metaFromRow(count);
|
|
7141
|
+
snapshot.rowCount = snapshot.meta.rowCount;
|
|
6919
7142
|
}
|
|
6920
|
-
if (
|
|
6921
|
-
|
|
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
|
|
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,
|
|
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:
|
|
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
|
|
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 (
|
|
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
|
|
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 =
|
|
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.
|
|
7235
|
-
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
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.
|
|
8023
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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
|
-
|
|
8136
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|