@syncular/client 0.29.1 → 0.30.1

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/src/multi-tab.ts CHANGED
@@ -11,11 +11,13 @@
11
11
  * survive `postMessage` on a BroadcastChannel):
12
12
  *
13
13
  * follower → leader
14
- * hello {t,epoch?,fromId} — "who's the leader?" on join,
14
+ * hello {t,epoch?,fromId,protocol,schemaVersion}
15
+ * — "who's the leader?" on join,
15
16
  * and the follower's liveness probe
16
- * req {t,epoch,fromId,reqId,method,args}
17
+ * req {t,epoch,fromId,reqId,method,args,protocol,schemaVersion}
17
18
  * leader → all
18
- * announce {t,epoch,clientId} — "I am the leader, this epoch";
19
+ * announce {t,epoch,clientId,protocol,schemaVersion}
20
+ * — "I am the leader, this epoch";
19
21
  * the answer to every hello
20
22
  * res {t,epoch,reqId,ok,value|error} — reply to one req
21
23
  * event {t,epoch,event} — fan-out (invalidate/presence/…)
@@ -30,6 +32,14 @@
30
32
  * promoter reads the highest epoch it has seen and adds one, so successive
31
33
  * leaders always strictly increase it even across the lock-handover gap.
32
34
  *
35
+ * Identity: every hello, req, and announce carries the sender's
36
+ * {@link MULTI_TAB_PROTOCOL_VERSION} and application schema version. A leader
37
+ * serves only requests with its own identity. A follower whose leader has a
38
+ * different identity goes `blocked` with `leader-incompatible` and sends it
39
+ * nothing. A leader that hears a hello from a newer tab closes its core and
40
+ * releases the lock, so the newer tab takes over. Tabs from before protocol 1
41
+ * send no identity and count as older.
42
+ *
33
43
  * Liveness is follower-driven: the leader runs no timer. Browsers throttle
34
44
  * the timers of a hidden tab (Chrome's intensive throttling wakes them about
35
45
  * once a minute) but still dispatch its BroadcastChannel messages, so a leader
@@ -39,6 +49,9 @@
39
49
  * stays unanswered for the rest of the timeout. A leader tab whose main thread
40
50
  * processes no messages (hung or frozen) therefore still blocks its followers
41
51
  * within `callTimeoutMs`, and a closed leader hands over through the lock.
52
+ * A forwarded call has no deadline of its own: a live leader may run it behind
53
+ * a long sync round, exactly as it runs its own tab's calls. It rejects when
54
+ * the link blocks, when another leader announces, or when the link closes.
42
55
  *
43
56
  * Presence identity: all tabs share the leader's one connection, so a device
44
57
  * is exactly ONE presence peer collectively — `(actorId, leaderClientId)`.
@@ -52,7 +65,43 @@ import { WORKER_FAILED_CODE } from './worker-protocol';
52
65
  /** Client-local: a follower call could not reach a leader before its deadline. */
53
66
  export const FOLLOWER_TIMEOUT_CODE = 'client.follower_timeout';
54
67
 
55
- /** Default deadline for a follower request (covers a leader-handover gap). */
68
+ /** Client-local: the leader runs a different protocol or schema version. */
69
+ export const LEADER_INCOMPATIBLE_CODE = 'client.leader_incompatible';
70
+
71
+ /** Client-local: the leader that received a call stopped leading before it answered. */
72
+ export const LEADER_HANDOVER_CODE = 'client.leader_handover';
73
+
74
+ /**
75
+ * Version of the leader/follower wire and the forwarded handle API. Bump it
76
+ * with every change to a message shape or to a forwarded method's arguments
77
+ * or result, so tabs of different builds never serve each other.
78
+ */
79
+ export const MULTI_TAB_PROTOCOL_VERSION = 1;
80
+
81
+ /** What a tab must share with its leader to be served by it. */
82
+ export interface TabIdentity {
83
+ readonly protocol: number;
84
+ readonly schemaVersion: number;
85
+ }
86
+
87
+ /**
88
+ * Order a peer's identity against our own: -1 older, 0 same, 1 newer. A peer
89
+ * from before protocol 1 sends no identity and is older.
90
+ */
91
+ export function compareTabIdentity(
92
+ peer: { readonly protocol?: number; readonly schemaVersion?: number },
93
+ own: TabIdentity,
94
+ ): -1 | 0 | 1 {
95
+ if (peer.protocol === undefined || peer.schemaVersion === undefined)
96
+ return -1;
97
+ if (peer.protocol !== own.protocol)
98
+ return peer.protocol < own.protocol ? -1 : 1;
99
+ if (peer.schemaVersion !== own.schemaVersion)
100
+ return peer.schemaVersion < own.schemaVersion ? -1 : 1;
101
+ return 0;
102
+ }
103
+
104
+ /** Default deadline for a call queued while no leader is bound (the handover gap). */
56
105
  export const DEFAULT_FOLLOWER_CALL_TIMEOUT_MS = 10_000;
57
106
  /** Max follower calls queued across a handover before we fail loudly. */
58
107
  export const DEFAULT_FOLLOWER_QUEUE_LIMIT = 256;
@@ -73,13 +122,27 @@ export type LeadershipState =
73
122
  readonly reason: 'leader-unreachable';
74
123
  readonly code: typeof FOLLOWER_TIMEOUT_CODE;
75
124
  readonly retryable: true;
125
+ }
126
+ | {
127
+ readonly state: 'blocked';
128
+ readonly reason: 'leader-incompatible';
129
+ readonly code: typeof LEADER_INCOMPATIBLE_CODE;
130
+ /** Whether the tab holding the database runs an older or a newer build. */
131
+ readonly leader: 'older' | 'newer';
132
+ readonly retryable: true;
76
133
  };
77
134
 
78
135
  // ---------------------------------------------------------------------------
79
136
  // Wire messages
80
137
  // ---------------------------------------------------------------------------
81
138
 
82
- interface HelloMessage {
139
+ /** Identity fields; absent in messages from tabs before protocol 1. */
140
+ interface IdentityFields {
141
+ readonly protocol?: number;
142
+ readonly schemaVersion?: number;
143
+ }
144
+
145
+ interface HelloMessage extends IdentityFields {
83
146
  readonly t: 'hello';
84
147
  readonly fromId: string;
85
148
  /** Highest epoch the sender has observed (helps a promoter monotonically
@@ -87,7 +150,7 @@ interface HelloMessage {
87
150
  readonly epoch?: number;
88
151
  }
89
152
 
90
- interface ReqMessage {
153
+ interface ReqMessage extends IdentityFields {
91
154
  readonly t: 'req';
92
155
  readonly epoch: number;
93
156
  readonly fromId: string;
@@ -96,7 +159,7 @@ interface ReqMessage {
96
159
  readonly args: readonly unknown[];
97
160
  }
98
161
 
99
- interface AnnounceMessage {
162
+ interface AnnounceMessage extends IdentityFields {
100
163
  readonly t: 'announce';
101
164
  readonly epoch: number;
102
165
  readonly clientId: string;
@@ -169,16 +232,20 @@ export function newTabId(): string {
169
232
  * Runs on the leader tab. Announces leadership, answers follower `req`s by
170
233
  * invoking the (already-running) worker via `invoke`, and rebroadcasts every
171
234
  * worker event to followers. Owns nothing about the worker lifecycle — the
172
- * worker-host stays the single core owner; this is a relay.
235
+ * worker-host stays the single core owner; this is a relay. A hello from a
236
+ * newer tab calls `onNewerTab`, whose owner closes the core and releases the
237
+ * lock.
173
238
  */
174
239
  export class LeaderBridge {
175
240
  readonly #channel: CrossTabChannel;
176
241
  readonly #epoch: number;
177
242
  readonly #clientId: string;
243
+ readonly #identity: TabIdentity;
178
244
  readonly #invoke: (
179
245
  method: string,
180
246
  args: readonly unknown[],
181
247
  ) => Promise<unknown>;
248
+ readonly #onNewerTab: () => void;
182
249
  readonly #onMessage: (event: { data: MultiTabMessage }) => void;
183
250
  #closed = false;
184
251
 
@@ -186,12 +253,16 @@ export class LeaderBridge {
186
253
  channel: CrossTabChannel;
187
254
  epoch: number;
188
255
  clientId: string;
256
+ identity: TabIdentity;
189
257
  invoke: (method: string, args: readonly unknown[]) => Promise<unknown>;
258
+ onNewerTab: () => void;
190
259
  }) {
191
260
  this.#channel = options.channel;
192
261
  this.#epoch = options.epoch;
193
262
  this.#clientId = options.clientId;
263
+ this.#identity = options.identity;
194
264
  this.#invoke = options.invoke;
265
+ this.#onNewerTab = options.onNewerTab;
195
266
  this.#onMessage = (event) => this.#handle(event.data);
196
267
  this.#channel.addEventListener('message', this.#onMessage);
197
268
  this.announce();
@@ -204,6 +275,7 @@ export class LeaderBridge {
204
275
  t: 'announce',
205
276
  epoch: this.#epoch,
206
277
  clientId: this.#clientId,
278
+ ...this.#identity,
207
279
  });
208
280
  }
209
281
 
@@ -216,7 +288,12 @@ export class LeaderBridge {
216
288
  #handle(message: MultiTabMessage): void {
217
289
  if (this.#closed) return;
218
290
  if (message.t === 'hello') {
219
- // A follower joined or probes liveness: tell it who leads.
291
+ // A newer tab takes over; any other follower learns who leads.
292
+ if (compareTabIdentity(message, this.#identity) > 0) {
293
+ this.close();
294
+ this.#onNewerTab();
295
+ return;
296
+ }
220
297
  this.announce();
221
298
  return;
222
299
  }
@@ -225,6 +302,20 @@ export class LeaderBridge {
225
302
  // follower will re-stamp once it sees our announce.
226
303
  if (message.epoch !== this.#epoch) return;
227
304
  const { reqId, method, args } = message;
305
+ if (compareTabIdentity(message, this.#identity) !== 0) {
306
+ this.#channel.postMessage({
307
+ t: 'res',
308
+ epoch: this.#epoch,
309
+ reqId,
310
+ ok: false,
311
+ error: {
312
+ code: LEADER_INCOMPATIBLE_CODE,
313
+ message: 'the leader runs a different protocol or schema version',
314
+ retryable: true,
315
+ },
316
+ });
317
+ return;
318
+ }
228
319
  this.#invoke(method, args).then(
229
320
  (value) => {
230
321
  this.#channel.postMessage({
@@ -289,7 +380,6 @@ interface QueuedCall {
289
380
  interface InFlight {
290
381
  readonly resolve: (value: unknown) => void;
291
382
  readonly reject: (error: unknown) => void;
292
- readonly cancelTimer: () => void;
293
383
  }
294
384
 
295
385
  /**
@@ -299,11 +389,13 @@ interface InFlight {
299
389
  * Feeds fanned-out events to `onEvent`. Learns leadership changes and hands
300
390
  * the resolved leader `clientId` back through `onLeaderChange`. Probes the
301
391
  * bound leader with `hello` after a quiet period and reports `blocked` only
302
- * when a probe goes unanswered (see the module comment).
392
+ * when a probe goes unanswered, or when the leader's identity differs from
393
+ * this tab's (see the module comment).
303
394
  */
304
395
  export class FollowerLink {
305
396
  readonly #channel: CrossTabChannel;
306
397
  readonly #fromId: string;
398
+ readonly #identity: TabIdentity;
307
399
  readonly #onEvent: (event: SyncWorkerEvent) => void;
308
400
  readonly #onLeaderChange: (clientId: string) => void;
309
401
  readonly #onStateChange: (state: LeadershipState) => void;
@@ -335,6 +427,7 @@ export class FollowerLink {
335
427
  constructor(options: {
336
428
  channel: CrossTabChannel;
337
429
  fromId: string;
430
+ identity: TabIdentity;
338
431
  onEvent: (event: SyncWorkerEvent) => void;
339
432
  onLeaderChange: (clientId: string) => void;
340
433
  onStateChange?: (state: LeadershipState) => void;
@@ -344,6 +437,7 @@ export class FollowerLink {
344
437
  }) {
345
438
  this.#channel = options.channel;
346
439
  this.#fromId = options.fromId;
440
+ this.#identity = options.identity;
347
441
  this.#onEvent = options.onEvent;
348
442
  this.#onLeaderChange = options.onLeaderChange;
349
443
  this.#onStateChange = options.onStateChange ?? (() => {});
@@ -442,13 +536,7 @@ export class FollowerLink {
442
536
  );
443
537
  }
444
538
  if (this.#state.state === 'blocked') {
445
- return Promise.reject(
446
- new ClientSyncError(
447
- FOLLOWER_TIMEOUT_CODE,
448
- 'the follower cannot reach the tab that owns the database',
449
- true,
450
- ),
451
- );
539
+ return Promise.reject(this.#blockedError());
452
540
  }
453
541
  return new Promise((resolve, reject) => {
454
542
  const queued: QueuedCall = {
@@ -491,22 +579,11 @@ export class FollowerLink {
491
579
 
492
580
  #send(queued: QueuedCall): void {
493
581
  const reqId = this.#nextReqId++;
494
- // The deadline fails this one call. Leader reachability is decided by the
495
- // liveness probe alone, so a slow call against a live leader leaves the
496
- // link bound.
582
+ // No deadline: a live leader may queue the call behind a long sync round.
583
+ // The liveness probe, a leader change, or close settles it otherwise.
497
584
  this.#inFlight.set(reqId, {
498
585
  resolve: queued.resolve,
499
586
  reject: queued.reject,
500
- cancelTimer: this.#schedule(() => {
501
- this.#inFlight.delete(reqId);
502
- queued.reject(
503
- new ClientSyncError(
504
- FOLLOWER_TIMEOUT_CODE,
505
- 'the leader did not answer within the follower call timeout',
506
- true,
507
- ),
508
- );
509
- }, this.#callTimeoutMs),
510
587
  });
511
588
  this.#channel.postMessage({
512
589
  t: 'req',
@@ -515,9 +592,33 @@ export class FollowerLink {
515
592
  reqId,
516
593
  method: queued.method,
517
594
  args: queued.args,
595
+ ...this.#identity,
518
596
  });
519
597
  }
520
598
 
599
+ /** The rejection for a call made while the link is blocked. */
600
+ #blockedError(): ClientSyncError {
601
+ return this.#state.state === 'blocked' &&
602
+ this.#state.reason === 'leader-incompatible'
603
+ ? new ClientSyncError(
604
+ LEADER_INCOMPATIBLE_CODE,
605
+ 'the tab that owns the database runs a different protocol or schema version',
606
+ true,
607
+ )
608
+ : new ClientSyncError(
609
+ FOLLOWER_TIMEOUT_CODE,
610
+ 'the follower cannot reach the tab that owns the database',
611
+ true,
612
+ );
613
+ }
614
+
615
+ /** Reject every forwarded call; their leader will not answer them. */
616
+ #rejectInFlight(error: ClientSyncError): void {
617
+ const pending = [...this.#inFlight.values()];
618
+ this.#inFlight.clear();
619
+ for (const inflight of pending) inflight.reject(error);
620
+ }
621
+
521
622
  #dropQueued(queued: QueuedCall): void {
522
623
  const index = this.#queue.indexOf(queued);
523
624
  if (index >= 0) this.#queue.splice(index, 1);
@@ -531,9 +632,24 @@ export class FollowerLink {
531
632
  }
532
633
  // Ignore a stale announce (older than the leader we already track).
533
634
  if (message.epoch < this.#epoch) return;
635
+ const relation = compareTabIdentity(message, this.#identity);
636
+ if (relation !== 0) {
637
+ this.#setIncompatible(relation < 0 ? 'older' : 'newer');
638
+ return;
639
+ }
534
640
  const changed =
535
641
  message.epoch !== this.#epoch ||
536
642
  message.clientId !== this.#leaderClientId;
643
+ // Calls sent to the previous leader get no answer from this one.
644
+ if (changed && this.#epoch >= 0) {
645
+ this.#rejectInFlight(
646
+ new ClientSyncError(
647
+ LEADER_HANDOVER_CODE,
648
+ 'the leader changed before it answered the call',
649
+ true,
650
+ ),
651
+ );
652
+ }
537
653
  this.#epoch = message.epoch;
538
654
  this.#leaderClientId = message.clientId;
539
655
  if (changed) this.#onLeaderChange(message.clientId);
@@ -554,7 +670,6 @@ export class FollowerLink {
554
670
  const inflight = this.#inFlight.get(message.reqId);
555
671
  if (inflight === undefined) return;
556
672
  this.#inFlight.delete(message.reqId);
557
- inflight.cancelTimer();
558
673
  if (message.ok) {
559
674
  inflight.resolve(message.value);
560
675
  } else {
@@ -590,11 +705,18 @@ export class FollowerLink {
590
705
  /**
591
706
  * The leader we were bound to went away (its lock released). Un-bind so new
592
707
  * calls queue again; re-request an announce so the next leader finds us.
593
- * In-flight calls stay pending — their per-call timeout is the backstop,
594
- * and the winner's announce will flush any that were queued.
708
+ * Calls in flight to the departed leader reject; the winner's announce
709
+ * flushes the queued ones.
595
710
  */
596
711
  unbind(): void {
597
712
  if (this.#closed) return;
713
+ this.#rejectInFlight(
714
+ new ClientSyncError(
715
+ LEADER_HANDOVER_CODE,
716
+ 'the leader released the database before it answered the call',
717
+ true,
718
+ ),
719
+ );
598
720
  this.#epoch = -1;
599
721
  this.#leaderClientId = '';
600
722
  this.#setState({ state: 'waiting', reason: 'handover' });
@@ -602,6 +724,22 @@ export class FollowerLink {
602
724
  this.#probe();
603
725
  }
604
726
 
727
+ /**
728
+ * This tab won the lock and runs its own core: calls queued for the next
729
+ * leader run on `invoke` instead of failing, then the link closes.
730
+ */
731
+ handOver(
732
+ invoke: (method: string, args: readonly unknown[]) => Promise<unknown>,
733
+ ): void {
734
+ const queued = this.#queue;
735
+ this.#queue = [];
736
+ for (const call of queued) {
737
+ call.cancelTimer?.();
738
+ invoke(call.method, call.args).then(call.resolve, call.reject);
739
+ }
740
+ this.close();
741
+ }
742
+
605
743
  close(): void {
606
744
  if (this.#closed) return;
607
745
  this.#closed = true;
@@ -611,11 +749,7 @@ export class FollowerLink {
611
749
  WORKER_FAILED_CODE,
612
750
  'the follower link was closed',
613
751
  );
614
- for (const inflight of this.#inFlight.values()) {
615
- inflight.cancelTimer();
616
- inflight.reject(closedError);
617
- }
618
- this.#inFlight.clear();
752
+ this.#rejectInFlight(closedError);
619
753
  for (const queued of this.#queue) {
620
754
  queued.cancelTimer?.();
621
755
  queued.reject(closedError);
@@ -631,7 +765,12 @@ export class FollowerLink {
631
765
  const previous = this.#state;
632
766
  if (
633
767
  previous.state === state.state &&
634
- (state.state === 'blocked' ||
768
+ ((state.state === 'blocked' &&
769
+ previous.state === 'blocked' &&
770
+ previous.reason === state.reason &&
771
+ (state.reason !== 'leader-incompatible' ||
772
+ (previous.reason === 'leader-incompatible' &&
773
+ previous.leader === state.leader))) ||
635
774
  (state.state === 'waiting' &&
636
775
  previous.state === 'waiting' &&
637
776
  previous.reason === state.reason) ||
@@ -656,7 +795,35 @@ export class FollowerLink {
656
795
  t: 'hello',
657
796
  fromId: this.#fromId,
658
797
  epoch: this.#maxEpochSeen,
798
+ ...this.#identity,
799
+ });
800
+ }
801
+
802
+ /**
803
+ * The leader runs another build. Send it nothing and stay unbound until a
804
+ * leader with this tab's identity announces (a newer leader's own hello
805
+ * already asked an older one to step down; see the module comment).
806
+ */
807
+ #setIncompatible(leader: 'older' | 'newer'): void {
808
+ this.#clearReachabilityTimers();
809
+ this.#epoch = -1;
810
+ this.#leaderClientId = '';
811
+ this.#setState({
812
+ state: 'blocked',
813
+ reason: 'leader-incompatible',
814
+ code: LEADER_INCOMPATIBLE_CODE,
815
+ leader,
816
+ retryable: true,
659
817
  });
818
+ const error = this.#blockedError();
819
+ this.#rejectInFlight(error);
820
+ const queued = this.#queue;
821
+ this.#queue = [];
822
+ for (const call of queued) {
823
+ call.cancelTimer?.();
824
+ call.reject(error);
825
+ }
826
+ this.#resolveBindWaiters();
660
827
  }
661
828
 
662
829
  /**
@@ -671,6 +838,7 @@ export class FollowerLink {
671
838
  code: FOLLOWER_TIMEOUT_CODE,
672
839
  retryable: true,
673
840
  });
841
+ this.#rejectInFlight(this.#blockedError());
674
842
  const reprobe = (): void => {
675
843
  this.#cancelProbe = this.#schedule(() => {
676
844
  reprobe();
@@ -1,4 +1,5 @@
1
1
  import {
2
+ blockedLeadershipAvailability,
2
3
  classifySyncAvailability,
3
4
  type SyncAvailability,
4
5
  } from './availability';
@@ -314,6 +315,8 @@ function reconcileRows<Row>(
314
315
 
315
316
  interface CachedObservation {
316
317
  onChange(batch: ClientChangeBatch): void;
318
+ /** A leader serves this tab again (it leads or follows a bound leader). */
319
+ onLeaderServing?(): void;
317
320
  reset(): void;
318
321
  dispose(): void;
319
322
  }
@@ -355,6 +358,9 @@ class ObservationCache {
355
358
  onChange(batch: ClientChangeBatch): void {
356
359
  for (const entry of this.#active) entry.onChange(batch);
357
360
  }
361
+ onLeaderServing(): void {
362
+ for (const entry of this.#active) entry.onLeaderServing?.();
363
+ }
358
364
  clear(): void {
359
365
  for (const entry of this.#entries.values()) entry.dispose();
360
366
  this.#entries.clear();
@@ -432,51 +438,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
432
438
  this.#onRoundFailureChange(),
433
439
  );
434
440
  this.#onAvailabilityChange();
435
- if (this.spec.claimCoverage !== false) {
436
- const claims: Promise<void>[] = [];
437
- // A composed spec carries one coverage entry per query branch, and
438
- // several branches can target the same base. One claim per distinct
439
- // base covers the union of their units; a later entry for the same
440
- // base does not replace the earlier one.
441
- const unitsByBase = new Map<
442
- string,
443
- { base: WindowBase; units: Set<string> }
444
- >();
445
- for (const coverage of this.spec.coverage ?? []) {
446
- const baseKey = windowBaseKey(coverage.base);
447
- const existing = unitsByBase.get(baseKey);
448
- if (existing === undefined) {
449
- unitsByBase.set(baseKey, {
450
- base: coverage.base,
451
- units: new Set(coverage.units),
452
- });
453
- } else {
454
- for (const unit of coverage.units) existing.units.add(unit);
455
- }
456
- }
457
- for (const { base, units } of unitsByBase.values()) {
458
- claims.push(this.store.setWindowClaim(this.#owner, base, [...units]));
459
- }
460
- const generation = this.#generation;
461
- this.#claimPending = claims.length > 0;
462
- if (claims.length > 0)
463
- void Promise.all(claims).then(
464
- () => {
465
- if (generation !== this.#generation || this.#listeners.size === 0)
466
- return;
467
- this.#claimPending = false;
468
- if (!this.#running && this.#state.phase !== 'ready')
469
- this.#requestRead();
470
- },
471
- (error: unknown) => {
472
- if (generation !== this.#generation || this.#listeners.size === 0)
473
- return;
474
- this.#claimPending = false;
475
- this.#claimError = errorOf(error);
476
- this.#requestRead();
477
- },
478
- );
479
- }
441
+ this.#claimCoverage();
480
442
  this.#requestRead();
481
443
  }
482
444
  return () => {
@@ -492,6 +454,54 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
492
454
  };
493
455
  };
494
456
 
457
+ /** Claim this query's window coverage; a failure surfaces on the next read. */
458
+ #claimCoverage(): void {
459
+ if (this.spec.claimCoverage === false) return;
460
+ const claims: Promise<void>[] = [];
461
+ // A composed spec carries one coverage entry per query branch, and
462
+ // several branches can target the same base. One claim per distinct
463
+ // base covers the union of their units; a later entry for the same
464
+ // base does not replace the earlier one.
465
+ const unitsByBase = new Map<
466
+ string,
467
+ { base: WindowBase; units: Set<string> }
468
+ >();
469
+ for (const coverage of this.spec.coverage ?? []) {
470
+ const baseKey = windowBaseKey(coverage.base);
471
+ const existing = unitsByBase.get(baseKey);
472
+ if (existing === undefined) {
473
+ unitsByBase.set(baseKey, {
474
+ base: coverage.base,
475
+ units: new Set(coverage.units),
476
+ });
477
+ } else {
478
+ for (const unit of coverage.units) existing.units.add(unit);
479
+ }
480
+ }
481
+ for (const { base, units } of unitsByBase.values()) {
482
+ claims.push(this.store.setWindowClaim(this.#owner, base, [...units]));
483
+ }
484
+ const generation = this.#generation;
485
+ this.#claimPending = claims.length > 0;
486
+ if (claims.length > 0)
487
+ void Promise.all(claims).then(
488
+ () => {
489
+ if (generation !== this.#generation || this.#listeners.size === 0)
490
+ return;
491
+ this.#claimPending = false;
492
+ if (!this.#running && this.#state.phase !== 'ready')
493
+ this.#requestRead();
494
+ },
495
+ (error: unknown) => {
496
+ if (generation !== this.#generation || this.#listeners.size === 0)
497
+ return;
498
+ this.#claimPending = false;
499
+ this.#claimError = errorOf(error);
500
+ this.#requestRead();
501
+ },
502
+ );
503
+ }
504
+
495
505
  refresh = (): void => {
496
506
  if (this.#delegate !== undefined) this.#delegate.refresh();
497
507
  else this.#requestRead(true);
@@ -518,6 +528,15 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
518
528
  this.reset();
519
529
  }
520
530
 
531
+ /** A claim rejected while no leader served this tab (blocked, handover,
532
+ * another build) is claimed again; its stored error would fail every read. */
533
+ onLeaderServing(): void {
534
+ if (this.#delegate !== undefined || this.#claimError === undefined) return;
535
+ this.#claimError = undefined;
536
+ this.#claimCoverage();
537
+ this.#requestRead();
538
+ }
539
+
521
540
  onChange(batch: ClientChangeBatch): void {
522
541
  if (!batchMatches(batch, this.spec)) return;
523
542
  if (batch.revision > this.#desiredRevision) {
@@ -979,12 +998,10 @@ export class ReactiveClientStore {
979
998
  const snapshot = this.status.getSnapshot();
980
999
  if (snapshot.status === undefined) {
981
1000
  return snapshot.leadership?.state === 'blocked'
982
- ? {
983
- state: 'blocked',
984
- reason: 'leader-unreachable',
985
- currentSchemaVersion: this.client.currentSchemaVersion ?? 0,
986
- retryable: true,
987
- }
1001
+ ? blockedLeadershipAvailability(
1002
+ snapshot.leadership,
1003
+ this.client.currentSchemaVersion ?? 0,
1004
+ )
988
1005
  : { state: 'ready' };
989
1006
  }
990
1007
  return classifySyncAvailability(snapshot.status, snapshot.leadership);
@@ -1173,6 +1190,14 @@ export class ReactiveClientStore {
1173
1190
  leadership,
1174
1191
  });
1175
1192
  if (previous.isLoading) this.status.refresh();
1193
+ // Window edits that failed while no leader served this tab apply now;
1194
+ // an applied group compares equal and makes no call.
1195
+ if (leadership.state === 'leader' || leadership.state === 'follower') {
1196
+ for (const group of this.#windowClaims.values()) {
1197
+ if (group.claims.size > 0) this.#scheduleWindow(group);
1198
+ }
1199
+ this.#queries.onLeaderServing();
1200
+ }
1176
1201
  });
1177
1202
  this.status.refresh();
1178
1203
  this.conflicts.refresh();