@spooky-sync/core 0.0.1-canary.353 → 0.0.1-canary.354
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 +2 -0
- package/dist/index.js +51 -12
- package/dist/types.d.ts +4 -4
- package/package.json +3 -3
- package/skills/sp00ky-core/SKILL.md +1 -1
- package/skills/sp00ky-core/references/config.md +1 -1
- package/src/client/runtime.test.ts +20 -0
- package/src/client/runtime.ts +12 -1
- package/src/kernel/constants.ts +16 -2
- package/src/query/membership.saga.test.ts +33 -1
- package/src/query/membership.saga.ts +30 -6
- package/src/query/register.saga.test.ts +1 -1
- package/src/sync/live.saga.test.ts +2 -2
- package/src/types.ts +4 -4
package/dist/index.d.ts
CHANGED
|
@@ -1558,6 +1558,8 @@ declare class Runtime {
|
|
|
1558
1558
|
private readonly lastStatus;
|
|
1559
1559
|
private readonly listeners;
|
|
1560
1560
|
private readonly scheduledMaterialize;
|
|
1561
|
+
/** When each query last materialized, for the leading-edge throttle in `setState`. */
|
|
1562
|
+
private readonly materializedAt;
|
|
1561
1563
|
private readonly timerKeys;
|
|
1562
1564
|
private lastActivity;
|
|
1563
1565
|
private lastHealth;
|
package/dist/index.js
CHANGED
|
@@ -2286,8 +2286,8 @@ function trimHistory(history, cap) {
|
|
|
2286
2286
|
|
|
2287
2287
|
//#endregion
|
|
2288
2288
|
//#region src/modules/devtools/index.ts
|
|
2289
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
2290
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
2289
|
+
const CORE_VERSION = "0.0.1-canary.354";
|
|
2290
|
+
const WASM_VERSION = "0.0.1-canary.354";
|
|
2291
2291
|
const SURREAL_VERSION = "3.1.0";
|
|
2292
2292
|
var DevToolsService = class DevToolsService {
|
|
2293
2293
|
version = CORE_VERSION;
|
|
@@ -3102,10 +3102,24 @@ const isForegroundFetching = (l) => l.fetchDepth > l.backgroundDepth;
|
|
|
3102
3102
|
* `setTimeout`; they yield `timer.set` effects with one of these delays, so a
|
|
3103
3103
|
* test can pin the schedule by asserting the effect log.
|
|
3104
3104
|
*/
|
|
3105
|
-
/**
|
|
3105
|
+
/**
|
|
3106
|
+
* Minimum gap between two materializations of one query. The first change
|
|
3107
|
+
* after a quiet spell materializes on the next tick; changes inside the window
|
|
3108
|
+
* fold into one trailing run at its end.
|
|
3109
|
+
*/
|
|
3106
3110
|
const MATERIALIZE_DEBOUNCE_MS = 50;
|
|
3107
|
-
/**
|
|
3111
|
+
/**
|
|
3112
|
+
* Minimum gap between two batched edge re-reads driven by LIVE dirt. Dirt
|
|
3113
|
+
* after a quiet spell is read after {@link MEMBERSHIP_LEADING_MS}; dirt inside
|
|
3114
|
+
* the window waits for its end.
|
|
3115
|
+
*/
|
|
3108
3116
|
const MEMBERSHIP_COALESCE_MS = 50;
|
|
3117
|
+
/**
|
|
3118
|
+
* Leading wait before the first re-read of a burst. One edge transaction fans
|
|
3119
|
+
* out as a run of notifications landing within a few milliseconds; this folds
|
|
3120
|
+
* the run into the first read instead of paying a second round trip for it.
|
|
3121
|
+
*/
|
|
3122
|
+
const MEMBERSHIP_LEADING_MS = 5;
|
|
3109
3123
|
/** Re-read ladder while the server reports a view as `materializing`. */
|
|
3110
3124
|
const MATERIALIZING_REREAD_LADDER_MS = [
|
|
3111
3125
|
150,
|
|
@@ -5039,9 +5053,14 @@ const stmt$1 = (results, i) => {
|
|
|
5039
5053
|
* materializing, no view-lost recovery pending): before that the read decides
|
|
5040
5054
|
* what the set is. Removals are never applied here, so a view whose edges
|
|
5041
5055
|
* are being rewritten never loses (and garbage-collects) bodies mid-rewrite.
|
|
5042
|
-
*
|
|
5043
|
-
*
|
|
5044
|
-
*
|
|
5056
|
+
* A query with a `LIMIT` takes it only when every delta is a version bump of
|
|
5057
|
+
* a row it already holds. A row JOINING it pushes another out, and rendering
|
|
5058
|
+
* the joiner before the read removes the leaver would show one row too many
|
|
5059
|
+
* (the id-set render drops the `LIMIT`); a bump cannot change the count. A row
|
|
5060
|
+
* that an edit moves out of the window arrives as its own `DELETE`, which still
|
|
5061
|
+
* marks the query dirty. Windowed lists are most lists, so this spares the
|
|
5062
|
+
* coalesce window and a full round trip on the common case of an edit to a
|
|
5063
|
+
* row already on screen.
|
|
5045
5064
|
*/
|
|
5046
5065
|
function* applyEdgeDeltas(deltas) {
|
|
5047
5066
|
const settled = /* @__PURE__ */ new Set();
|
|
@@ -5050,7 +5069,10 @@ function* applyEdgeDeltas(deltas) {
|
|
|
5050
5069
|
for (const [hash, list] of byHash) {
|
|
5051
5070
|
const entry = yield fx.state.read((s) => s.queries.get(hash));
|
|
5052
5071
|
if (!entry || entry.lifecycle.phase !== "live" || entry.serverState === "materializing" || entry.viewLostRetryAt !== null) continue;
|
|
5053
|
-
if (entry.def.plan ? entry.def.plan.limit != null : /\bLIMIT\b/i.test(entry.def.surql))
|
|
5072
|
+
if (entry.def.plan ? entry.def.plan.limit != null : /\bLIMIT\b/i.test(entry.def.surql)) {
|
|
5073
|
+
const held = new Set(entry.remoteArray.map(([id]) => id));
|
|
5074
|
+
if (!list.every((d) => held.has(d.id))) continue;
|
|
5075
|
+
}
|
|
5054
5076
|
const next = withDeltas(entry.remoteArray, list);
|
|
5055
5077
|
if (next !== entry.remoteArray) yield* applyMembership(hash, next);
|
|
5056
5078
|
settled.add(hash);
|
|
@@ -5163,11 +5185,22 @@ function* readMembership(env, hashes, opts = {}) {
|
|
|
5163
5185
|
* `_00_list_ref` table (registrations, an import, another tab syncing) the
|
|
5164
5186
|
* events never stop for a whole 50 ms and the read simply never ran, leaving
|
|
5165
5187
|
* membership frozen until a poll tick happened to cover it.
|
|
5188
|
+
*
|
|
5189
|
+
* Leading edge: when none of these queries was read within the last
|
|
5190
|
+
* {@link MEMBERSHIP_COALESCE_MS}, the read goes out after the short
|
|
5191
|
+
* {@link MEMBERSHIP_LEADING_MS} instead of waiting the whole window. A lone
|
|
5192
|
+
* remote edit is the common case, and the full window sat in front of every
|
|
5193
|
+
* one of them. Dirt arriving while that read is in flight still gets its own
|
|
5194
|
+
* trailing read from {@link readDirtyMembership}, so the cap of one read per
|
|
5195
|
+
* window holds.
|
|
5166
5196
|
*/
|
|
5167
5197
|
function* markMembershipDirty(hashes) {
|
|
5168
|
-
const armed = yield fx.state.read((s) => s.membershipDirty.size > 0);
|
|
5198
|
+
const [armed, lastRead] = yield fx.state.read((s) => [s.membershipDirty.size > 0, hashes.reduce((at, h) => Math.max(at, s.queries.get(h)?.lastPolledAt ?? 0), 0)]);
|
|
5169
5199
|
yield fx.state.update(markMembershipDirty$1(hashes));
|
|
5170
|
-
if (
|
|
5200
|
+
if (armed) return;
|
|
5201
|
+
const now = yield fx.now();
|
|
5202
|
+
const delay = Math.max(MEMBERSHIP_LEADING_MS, lastRead + MEMBERSHIP_COALESCE_MS - now);
|
|
5203
|
+
yield fx.timer.set("membership", delay, { type: "ReadDirtyMembership" });
|
|
5171
5204
|
}
|
|
5172
5205
|
function* readDirtyMembership(env) {
|
|
5173
5206
|
const dirty = yield fx.state.read((s) => [...s.membershipDirty]);
|
|
@@ -7721,6 +7754,8 @@ var Runtime = class {
|
|
|
7721
7754
|
lastStatus = /* @__PURE__ */ new Map();
|
|
7722
7755
|
listeners = /* @__PURE__ */ new Map();
|
|
7723
7756
|
scheduledMaterialize = /* @__PURE__ */ new Set();
|
|
7757
|
+
/** When each query last materialized, for the leading-edge throttle in `setState`. */
|
|
7758
|
+
materializedAt = /* @__PURE__ */ new Map();
|
|
7724
7759
|
timerKeys = /* @__PURE__ */ new Set();
|
|
7725
7760
|
lastActivity = {
|
|
7726
7761
|
fetching: 0,
|
|
@@ -7812,11 +7847,15 @@ var Runtime = class {
|
|
|
7812
7847
|
w.resolve();
|
|
7813
7848
|
}
|
|
7814
7849
|
}
|
|
7850
|
+
const now = this.adapters.clock.now();
|
|
7815
7851
|
for (const hash of next.dirty) {
|
|
7816
7852
|
if (this.scheduledMaterialize.has(hash)) continue;
|
|
7817
7853
|
this.scheduledMaterialize.add(hash);
|
|
7818
|
-
this.
|
|
7854
|
+
const last = this.materializedAt.get(hash);
|
|
7855
|
+
const delay = last === void 0 ? 0 : Math.max(0, last + this.env.materializeDebounceMs - now);
|
|
7856
|
+
this.adapters.timers.set(`mat:${hash}`, delay, () => {
|
|
7819
7857
|
this.scheduledMaterialize.delete(hash);
|
|
7858
|
+
this.materializedAt.set(hash, this.adapters.clock.now());
|
|
7820
7859
|
this.dispatch({
|
|
7821
7860
|
type: "Materialize",
|
|
7822
7861
|
hash
|
|
@@ -16030,7 +16069,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
16030
16069
|
return new TabsCoordinator({
|
|
16031
16070
|
tabId: s.tabId,
|
|
16032
16071
|
fingerprint: computeTabsFingerprint({
|
|
16033
|
-
coreVersion: "0.0.1-canary.
|
|
16072
|
+
coreVersion: "0.0.1-canary.354",
|
|
16034
16073
|
schemaHash: hash53(config.schemaSurql),
|
|
16035
16074
|
endpoint: config.database.endpoint ?? "",
|
|
16036
16075
|
namespace: config.database.namespace,
|
package/dist/types.d.ts
CHANGED
|
@@ -1620,10 +1620,10 @@ interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
1620
1620
|
/** A pino browser transmit object for forwarding logs (e.g. via @spooky-sync/core/otel). */
|
|
1621
1621
|
otelTransmit?: PinoTransmit;
|
|
1622
1622
|
/**
|
|
1623
|
-
*
|
|
1624
|
-
*
|
|
1625
|
-
*
|
|
1626
|
-
* Defaults to 50ms.
|
|
1623
|
+
* Minimum gap in milliseconds between two materializations of one query
|
|
1624
|
+
* (coalesces per-record updates before notifying readers). The first change
|
|
1625
|
+
* after a quiet spell is delivered on the next tick; later ones inside the
|
|
1626
|
+
* window fold into one update at its end. Defaults to 50ms.
|
|
1627
1627
|
*/
|
|
1628
1628
|
streamDebounceTime?: number;
|
|
1629
1629
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.354",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -75,8 +75,8 @@
|
|
|
75
75
|
}
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
79
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
78
|
+
"@spooky-sync/query-builder": "0.0.1-canary.354",
|
|
79
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.354",
|
|
80
80
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
81
81
|
"@surrealdb/wasm": "^3.1.0",
|
|
82
82
|
"@surrealdb/wasm-native": "^3.3.4",
|
|
@@ -52,7 +52,7 @@ Key fields:
|
|
|
52
52
|
- `schemaSurql` — The compiled SURQL schema string for local DB provisioning
|
|
53
53
|
- `logLevel` — `'debug' | 'info' | 'warn' | 'error'`
|
|
54
54
|
- `persistenceClient` — `'surrealdb'` (default), `'localstorage'`, or a custom `PersistenceClient`
|
|
55
|
-
- `streamDebounceTime` —
|
|
55
|
+
- `streamDebounceTime` — Minimum gap in ms between re-materializations of one query; the first change after a quiet spell is immediate (default: 50)
|
|
56
56
|
|
|
57
57
|
## Querying
|
|
58
58
|
|
|
@@ -31,7 +31,7 @@ interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
31
31
|
persistenceClient?: PersistenceClient | 'surrealdb' | 'localstorage';
|
|
32
32
|
/** A pino browser transmit object for forwarding logs (e.g. via @spooky-sync/core/otel) */
|
|
33
33
|
otelTransmit?: PinoTransmit;
|
|
34
|
-
/**
|
|
34
|
+
/** Minimum gap in ms between re-materializations of one query; first change is immediate (default: 50) */
|
|
35
35
|
streamDebounceTime?: number;
|
|
36
36
|
}
|
|
37
37
|
```
|
|
@@ -90,6 +90,26 @@ describe('Runtime state hooks', () => {
|
|
|
90
90
|
expect(seen).toEqual([[{ id: 'thing:1' }]]);
|
|
91
91
|
expect(rt.state.dirty.has('q')).toBe(false);
|
|
92
92
|
});
|
|
93
|
+
it('materializes the first change at once, and holds the next one to the window', async () => {
|
|
94
|
+
const { a, rt } = make(
|
|
95
|
+
{ local: { select: async () => [{ id: 'thing:1' }] } },
|
|
96
|
+
buildState([buildEntry({ def: { hash: 'q', plan: { table: 'thing' } as any } })], R.reloadViews(new Map([['v', ['thing:1']]])))
|
|
97
|
+
);
|
|
98
|
+
rt.update(R.markDirty(['q']));
|
|
99
|
+
expect(a.timers.pending.get('mat:q')?.ms).toBe(0);
|
|
100
|
+
a.timers.fire('mat:q');
|
|
101
|
+
await tick();
|
|
102
|
+
await tick();
|
|
103
|
+
a.advance(2);
|
|
104
|
+
rt.update(R.markDirty(['q']));
|
|
105
|
+
expect(a.timers.pending.get('mat:q')?.ms).toBe(3);
|
|
106
|
+
a.timers.fire('mat:q');
|
|
107
|
+
await tick();
|
|
108
|
+
await tick();
|
|
109
|
+
a.advance(50);
|
|
110
|
+
rt.update(R.markDirty(['q']));
|
|
111
|
+
expect(a.timers.pending.get('mat:q')?.ms).toBe(0);
|
|
112
|
+
});
|
|
93
113
|
it('the bucket-switch window: a re-seeded query never reads "known and idle" before its rows are painted', async () => {
|
|
94
114
|
// A sign-in: the query was registered in the anon bucket (cold, nothing
|
|
95
115
|
// painted), then the switch re-seeds it from the user's durable view.
|
package/src/client/runtime.ts
CHANGED
|
@@ -47,6 +47,8 @@ export class Runtime {
|
|
|
47
47
|
private readonly lastStatus = new Map<QueryHash, QueryStatus>();
|
|
48
48
|
private readonly listeners = new Map<string, Set<(event: OutEvent) => void>>();
|
|
49
49
|
private readonly scheduledMaterialize = new Set<QueryHash>();
|
|
50
|
+
/** When each query last materialized, for the leading-edge throttle in `setState`. */
|
|
51
|
+
private readonly materializedAt = new Map<QueryHash, number>();
|
|
50
52
|
private readonly timerKeys = new Set<string>();
|
|
51
53
|
private lastActivity = { fetching: 0, pending: 0 };
|
|
52
54
|
private lastHealth: ClientState['sync']['health'];
|
|
@@ -141,11 +143,20 @@ export class Runtime {
|
|
|
141
143
|
w.resolve();
|
|
142
144
|
}
|
|
143
145
|
}
|
|
146
|
+
// Throttle with a leading edge: the first change after a quiet spell
|
|
147
|
+
// materializes on the next tick (a zero delay still folds every state
|
|
148
|
+
// update of the current turn into it), later ones inside the window fold
|
|
149
|
+
// into one run at its end. A trailing-only debounce put the whole window
|
|
150
|
+
// in front of every lone remote edit.
|
|
151
|
+
const now = this.adapters.clock.now();
|
|
144
152
|
for (const hash of next.dirty) {
|
|
145
153
|
if (this.scheduledMaterialize.has(hash)) continue;
|
|
146
154
|
this.scheduledMaterialize.add(hash);
|
|
147
|
-
this.
|
|
155
|
+
const last = this.materializedAt.get(hash);
|
|
156
|
+
const delay = last === undefined ? 0 : Math.max(0, last + this.env.materializeDebounceMs - now);
|
|
157
|
+
this.adapters.timers.set(`mat:${hash}`, delay, () => {
|
|
148
158
|
this.scheduledMaterialize.delete(hash);
|
|
159
|
+
this.materializedAt.set(hash, this.adapters.clock.now());
|
|
149
160
|
void this.dispatch({ type: 'Materialize', hash });
|
|
150
161
|
});
|
|
151
162
|
}
|
package/src/kernel/constants.ts
CHANGED
|
@@ -4,10 +4,24 @@
|
|
|
4
4
|
* test can pin the schedule by asserting the effect log.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Minimum gap between two materializations of one query. The first change
|
|
9
|
+
* after a quiet spell materializes on the next tick; changes inside the window
|
|
10
|
+
* fold into one trailing run at its end.
|
|
11
|
+
*/
|
|
8
12
|
export const MATERIALIZE_DEBOUNCE_MS = 50;
|
|
9
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Minimum gap between two batched edge re-reads driven by LIVE dirt. Dirt
|
|
15
|
+
* after a quiet spell is read after {@link MEMBERSHIP_LEADING_MS}; dirt inside
|
|
16
|
+
* the window waits for its end.
|
|
17
|
+
*/
|
|
10
18
|
export const MEMBERSHIP_COALESCE_MS = 50;
|
|
19
|
+
/**
|
|
20
|
+
* Leading wait before the first re-read of a burst. One edge transaction fans
|
|
21
|
+
* out as a run of notifications landing within a few milliseconds; this folds
|
|
22
|
+
* the run into the first read instead of paying a second round trip for it.
|
|
23
|
+
*/
|
|
24
|
+
export const MEMBERSHIP_LEADING_MS = 5;
|
|
11
25
|
/** Re-read ladder while the server reports a view as `materializing`. */
|
|
12
26
|
export const MATERIALIZING_REREAD_LADDER_MS: readonly number[] = [150, 400, 1000];
|
|
13
27
|
/** How long an acked outbox item stays in the overlay when membership never names it. */
|
|
@@ -379,7 +379,7 @@ describe('dirty helpers', () => {
|
|
|
379
379
|
const s = buildState([buildEntry({ def: { hash: 'a' } })]);
|
|
380
380
|
const out = await runPure(markMembershipDirty(['a']), { state: s });
|
|
381
381
|
expect(out.state.membershipDirty.has('a')).toBe(true);
|
|
382
|
-
expect(out.timers.get('membership')).toEqual({ ms:
|
|
382
|
+
expect(out.timers.get('membership')).toEqual({ ms: 5, event: { type: 'ReadDirtyMembership' } });
|
|
383
383
|
const clean = await runPure(readDirtyMembership(env), { state: s });
|
|
384
384
|
expect(clean.log.filter((e) => e.kind === 'remote.query')).toHaveLength(0);
|
|
385
385
|
const dirty = await runPure(readDirtyMembership(env), {
|
|
@@ -389,6 +389,17 @@ describe('dirty helpers', () => {
|
|
|
389
389
|
expect(dirty.log.filter((e) => e.kind === 'remote.query')).toHaveLength(1);
|
|
390
390
|
});
|
|
391
391
|
|
|
392
|
+
it('reads at once after a quiet spell, and waits out the window after a recent read', async () => {
|
|
393
|
+
const now = 1_000_000;
|
|
394
|
+
const quiet = await runPure(markMembershipDirty(['a']), { state: buildState([buildEntry({ def: { hash: 'a' }, lastPolledAt: now - 5_000 })]), now });
|
|
395
|
+
expect(quiet.timers.get('membership')?.ms).toBe(5);
|
|
396
|
+
const recent = await runPure(markMembershipDirty(['a', 'b']), {
|
|
397
|
+
state: buildState([buildEntry({ def: { hash: 'a' }, lastPolledAt: now - 30 }), buildEntry({ def: { hash: 'b' }, lastPolledAt: null })]),
|
|
398
|
+
now,
|
|
399
|
+
});
|
|
400
|
+
expect(recent.timers.get('membership')?.ms).toBe(20);
|
|
401
|
+
});
|
|
402
|
+
|
|
392
403
|
it('arms the window once per burst, and again for dirt the read left behind', async () => {
|
|
393
404
|
const s = buildState([buildEntry({ def: { hash: 'a' } }), buildEntry({ def: { hash: 'b' } })]);
|
|
394
405
|
const first = await runPure(markMembershipDirty(['a']), { state: s });
|
|
@@ -459,6 +470,27 @@ describe('applyEdgeDeltas', () => {
|
|
|
459
470
|
}
|
|
460
471
|
expect((await runPure(applyEdgeDeltas([{ hash: 'zz', id: 't:2', version: 1 }]), { state: buildState([live()]) })).result.size).toBe(0);
|
|
461
472
|
});
|
|
473
|
+
|
|
474
|
+
it('commits a version bump of a held row in a bounded view without reading the edges', async () => {
|
|
475
|
+
for (const def of [
|
|
476
|
+
{ hash: 'a', viewKey: 'vk', surql: 'SELECT * FROM t ORDER BY at DESC LIMIT 10' },
|
|
477
|
+
{ hash: 'a', viewKey: 'vk', plan: { table: 't', limit: 10 } },
|
|
478
|
+
]) {
|
|
479
|
+
const out = await runPure(applyEdgeDeltas([{ hash: 'a', id: 't:1', version: 4 }]), { state: buildState([live({ def })]), handlers });
|
|
480
|
+
expect([...out.result]).toEqual(['a']);
|
|
481
|
+
expect(out.state.queries.get('a')!.remoteArray).toEqual([['t:1', 4]]);
|
|
482
|
+
expect(out.log.some((e) => e.kind === 'remote.query')).toBe(false);
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('leaves a bounded view to the read when any delta in the batch is a joiner', async () => {
|
|
487
|
+
const entry = live({ def: { hash: 'a', plan: { table: 't', limit: 10 } } });
|
|
488
|
+
const out = await runPure(applyEdgeDeltas([{ hash: 'a', id: 't:1', version: 4 }, { hash: 'a', id: 't:2', version: 1 }]), {
|
|
489
|
+
state: buildState([entry]),
|
|
490
|
+
});
|
|
491
|
+
expect(out.result.size).toBe(0);
|
|
492
|
+
expect(out.state.queries.get('a')!.remoteArray).toEqual([['t:1', 1]]);
|
|
493
|
+
});
|
|
462
494
|
});
|
|
463
495
|
|
|
464
496
|
describe('readMembership after a LIVE delta', () => {
|
|
@@ -5,6 +5,7 @@ import { fx } from '../kernel/effects';
|
|
|
5
5
|
import {
|
|
6
6
|
MATERIALIZING_REREAD_LADDER_MS,
|
|
7
7
|
MEMBERSHIP_COALESCE_MS,
|
|
8
|
+
MEMBERSHIP_LEADING_MS,
|
|
8
9
|
VIEW_LOST_RETRY_BASE_MS,
|
|
9
10
|
VIEW_LOST_RETRY_MAX_MS,
|
|
10
11
|
backoffMs,
|
|
@@ -159,9 +160,14 @@ const stmt = (results: unknown, i: number): unknown => {
|
|
|
159
160
|
* materializing, no view-lost recovery pending): before that the read decides
|
|
160
161
|
* what the set is. Removals are never applied here, so a view whose edges
|
|
161
162
|
* are being rewritten never loses (and garbage-collects) bodies mid-rewrite.
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
163
|
+
* A query with a `LIMIT` takes it only when every delta is a version bump of
|
|
164
|
+
* a row it already holds. A row JOINING it pushes another out, and rendering
|
|
165
|
+
* the joiner before the read removes the leaver would show one row too many
|
|
166
|
+
* (the id-set render drops the `LIMIT`); a bump cannot change the count. A row
|
|
167
|
+
* that an edit moves out of the window arrives as its own `DELETE`, which still
|
|
168
|
+
* marks the query dirty. Windowed lists are most lists, so this spares the
|
|
169
|
+
* coalesce window and a full round trip on the common case of an edit to a
|
|
170
|
+
* row already on screen.
|
|
165
171
|
*/
|
|
166
172
|
export function* applyEdgeDeltas(deltas: EdgeDelta[]): Saga<Set<QueryHash>> {
|
|
167
173
|
const settled = new Set<QueryHash>();
|
|
@@ -170,7 +176,11 @@ export function* applyEdgeDeltas(deltas: EdgeDelta[]): Saga<Set<QueryHash>> {
|
|
|
170
176
|
for (const [hash, list] of byHash) {
|
|
171
177
|
const entry = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
|
|
172
178
|
if (!entry || entry.lifecycle.phase !== 'live' || entry.serverState === 'materializing' || entry.viewLostRetryAt !== null) continue;
|
|
173
|
-
|
|
179
|
+
const limited = entry.def.plan ? entry.def.plan.limit != null : /\bLIMIT\b/i.test(entry.def.surql);
|
|
180
|
+
if (limited) {
|
|
181
|
+
const held = new Set(entry.remoteArray.map(([id]) => id));
|
|
182
|
+
if (!list.every((d) => held.has(d.id))) continue;
|
|
183
|
+
}
|
|
174
184
|
const next = withDeltas(entry.remoteArray, list);
|
|
175
185
|
// Never empty, so always applied.
|
|
176
186
|
if (next !== entry.remoteArray) yield* applyMembership(hash, next);
|
|
@@ -296,11 +306,25 @@ export function* readMembership(
|
|
|
296
306
|
* `_00_list_ref` table (registrations, an import, another tab syncing) the
|
|
297
307
|
* events never stop for a whole 50 ms and the read simply never ran, leaving
|
|
298
308
|
* membership frozen until a poll tick happened to cover it.
|
|
309
|
+
*
|
|
310
|
+
* Leading edge: when none of these queries was read within the last
|
|
311
|
+
* {@link MEMBERSHIP_COALESCE_MS}, the read goes out after the short
|
|
312
|
+
* {@link MEMBERSHIP_LEADING_MS} instead of waiting the whole window. A lone
|
|
313
|
+
* remote edit is the common case, and the full window sat in front of every
|
|
314
|
+
* one of them. Dirt arriving while that read is in flight still gets its own
|
|
315
|
+
* trailing read from {@link readDirtyMembership}, so the cap of one read per
|
|
316
|
+
* window holds.
|
|
299
317
|
*/
|
|
300
318
|
export function* markMembershipDirty(hashes: QueryHash[]): Saga<void> {
|
|
301
|
-
const armed = (yield fx.state.read((s) =>
|
|
319
|
+
const [armed, lastRead] = (yield fx.state.read((s) => [
|
|
320
|
+
s.membershipDirty.size > 0,
|
|
321
|
+
hashes.reduce((at, h) => Math.max(at, s.queries.get(h)?.lastPolledAt ?? 0), 0),
|
|
322
|
+
])) as [boolean, number];
|
|
302
323
|
yield fx.state.update(R.markMembershipDirty(hashes));
|
|
303
|
-
if (
|
|
324
|
+
if (armed) return;
|
|
325
|
+
const now = (yield fx.now()) as number;
|
|
326
|
+
const delay = Math.max(MEMBERSHIP_LEADING_MS, lastRead + MEMBERSHIP_COALESCE_MS - now);
|
|
327
|
+
yield fx.timer.set('membership', delay, { type: 'ReadDirtyMembership' });
|
|
304
328
|
}
|
|
305
329
|
|
|
306
330
|
export function* readDirtyMembership(env: SagaEnv): Saga<void> {
|
|
@@ -225,7 +225,7 @@ describe('registerRemote', () => {
|
|
|
225
225
|
expect(e.registerAttempts).toBe(0);
|
|
226
226
|
expect(e.lifecycle).toMatchObject({ remote: 'registered', fetchDepth: 0 });
|
|
227
227
|
expect(out.state.membershipDirty.has('a')).toBe(true);
|
|
228
|
-
expect(out.timers.get('membership')).toEqual({ ms:
|
|
228
|
+
expect(out.timers.get('membership')).toEqual({ ms: 5, event: { type: 'ReadDirtyMembership' } });
|
|
229
229
|
expect(out.dispatched).toContainEqual({ type: 'SyncOutcome', ok: false, error: 'registration read-back failed' });
|
|
230
230
|
}
|
|
231
231
|
const empty = await runPure(registerRemote(env, 'a'), { state: buildState([entry]), handlers: { 'remote.query': () => [] } });
|
|
@@ -62,7 +62,7 @@ describe('liveChange', () => {
|
|
|
62
62
|
const out = await runPure(liveChange(env, ['a', 'zz']), { state: { ...s, tabRole: 'leader' } });
|
|
63
63
|
expect([...out.state.membershipDirty]).toEqual(['a']);
|
|
64
64
|
expect(out.state.sync.pollIdleStreak).toBe(0);
|
|
65
|
-
expect(out.timers.get('membership')).toEqual({ ms:
|
|
65
|
+
expect(out.timers.get('membership')).toEqual({ ms: 5, event: { type: 'ReadDirtyMembership' } });
|
|
66
66
|
expect(out.emitted).toEqual([{ type: 'tabs:broadcast', message: { type: 'membership-dirty', hashes: ['a'] } }]);
|
|
67
67
|
const solo = await runPure(liveChange(env, ['a']), { state: s });
|
|
68
68
|
expect(solo.emitted).toEqual([]);
|
|
@@ -224,6 +224,6 @@ describe('liveChange with edge deltas', () => {
|
|
|
224
224
|
{ state: buildState([live('a'), cold]) }
|
|
225
225
|
);
|
|
226
226
|
expect([...out.state.membershipDirty].sort()).toEqual(['a', 'b']);
|
|
227
|
-
expect(out.timers.get('membership')).toEqual({ ms:
|
|
227
|
+
expect(out.timers.get('membership')).toEqual({ ms: 5, event: { type: 'ReadDirtyMembership' } });
|
|
228
228
|
});
|
|
229
229
|
});
|
package/src/types.ts
CHANGED
|
@@ -251,10 +251,10 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
251
251
|
/** A pino browser transmit object for forwarding logs (e.g. via @spooky-sync/core/otel). */
|
|
252
252
|
otelTransmit?: PinoTransmit;
|
|
253
253
|
/**
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
* Defaults to 50ms.
|
|
254
|
+
* Minimum gap in milliseconds between two materializations of one query
|
|
255
|
+
* (coalesces per-record updates before notifying readers). The first change
|
|
256
|
+
* after a quiet spell is delivered on the next tick; later ones inside the
|
|
257
|
+
* window fold into one update at its end. Defaults to 50ms.
|
|
258
258
|
*/
|
|
259
259
|
streamDebounceTime?: number;
|
|
260
260
|
/**
|