@spooky-sync/core 0.0.1-canary.352 → 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 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.352";
2290
- const WASM_VERSION = "0.0.1-canary.352";
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
- /** Trailing coalesce for materialization after a state change. */
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
- /** Coalesce window for LIVE membership dirt before the batched edge re-read. */
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
- * Nor for a query with a `LIMIT`: a row joining it pushes another out, and
5043
- * rendering the joiner before the read removes the leaver would show one row
5044
- * too many (the id-set render drops the `LIMIT`).
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)) continue;
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 (!armed) yield fx.timer.set("membership", MEMBERSHIP_COALESCE_MS, { type: "ReadDirtyMembership" });
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.adapters.timers.set(`mat:${hash}`, this.env.materializeDebounceMs, () => {
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.352",
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
- * Debounce time in milliseconds for stream updates (the client-side SSP
1624
- * aggregation throttle — coalesces the in-browser StreamProcessor's
1625
- * per-record updates per query before notifying readers).
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.352",
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.352",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.352",
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` — Debounce ms for stream updates (default: 100)
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
- /** Debounce time in ms for stream updates (default: 100) */
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.
@@ -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.adapters.timers.set(`mat:${hash}`, this.env.materializeDebounceMs, () => {
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
  }
@@ -4,10 +4,24 @@
4
4
  * test can pin the schedule by asserting the effect log.
5
5
  */
6
6
 
7
- /** Trailing coalesce for materialization after a state change. */
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
- /** Coalesce window for LIVE membership dirt before the batched edge re-read. */
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: 50, event: { type: 'ReadDirtyMembership' } });
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
- * Nor for a query with a `LIMIT`: a row joining it pushes another out, and
163
- * rendering the joiner before the read removes the leaver would show one row
164
- * too many (the id-set render drops the `LIMIT`).
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
- if (entry.def.plan ? entry.def.plan.limit != null : /\bLIMIT\b/i.test(entry.def.surql)) continue;
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) => s.membershipDirty.size > 0)) as boolean;
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 (!armed) yield fx.timer.set('membership', MEMBERSHIP_COALESCE_MS, { type: 'ReadDirtyMembership' });
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: 50, event: { type: 'ReadDirtyMembership' } });
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: 50, event: { type: 'ReadDirtyMembership' } });
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: 50, event: { type: 'ReadDirtyMembership' } });
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
- * Debounce time in milliseconds for stream updates (the client-side SSP
255
- * aggregation throttle — coalesces the in-browser StreamProcessor's
256
- * per-record updates per query before notifying readers).
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
  /**