@syncular/client 0.29.0 → 0.30.0

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' });
@@ -611,11 +733,7 @@ export class FollowerLink {
611
733
  WORKER_FAILED_CODE,
612
734
  'the follower link was closed',
613
735
  );
614
- for (const inflight of this.#inFlight.values()) {
615
- inflight.cancelTimer();
616
- inflight.reject(closedError);
617
- }
618
- this.#inFlight.clear();
736
+ this.#rejectInFlight(closedError);
619
737
  for (const queued of this.#queue) {
620
738
  queued.cancelTimer?.();
621
739
  queued.reject(closedError);
@@ -631,7 +749,12 @@ export class FollowerLink {
631
749
  const previous = this.#state;
632
750
  if (
633
751
  previous.state === state.state &&
634
- (state.state === 'blocked' ||
752
+ ((state.state === 'blocked' &&
753
+ previous.state === 'blocked' &&
754
+ previous.reason === state.reason &&
755
+ (state.reason !== 'leader-incompatible' ||
756
+ (previous.reason === 'leader-incompatible' &&
757
+ previous.leader === state.leader))) ||
635
758
  (state.state === 'waiting' &&
636
759
  previous.state === 'waiting' &&
637
760
  previous.reason === state.reason) ||
@@ -656,7 +779,35 @@ export class FollowerLink {
656
779
  t: 'hello',
657
780
  fromId: this.#fromId,
658
781
  epoch: this.#maxEpochSeen,
782
+ ...this.#identity,
783
+ });
784
+ }
785
+
786
+ /**
787
+ * The leader runs another build. Send it nothing and stay unbound until a
788
+ * leader with this tab's identity announces (a newer leader's own hello
789
+ * already asked an older one to step down; see the module comment).
790
+ */
791
+ #setIncompatible(leader: 'older' | 'newer'): void {
792
+ this.#clearReachabilityTimers();
793
+ this.#epoch = -1;
794
+ this.#leaderClientId = '';
795
+ this.#setState({
796
+ state: 'blocked',
797
+ reason: 'leader-incompatible',
798
+ code: LEADER_INCOMPATIBLE_CODE,
799
+ leader,
800
+ retryable: true,
659
801
  });
802
+ const error = this.#blockedError();
803
+ this.#rejectInFlight(error);
804
+ const queued = this.#queue;
805
+ this.#queue = [];
806
+ for (const call of queued) {
807
+ call.cancelTimer?.();
808
+ call.reject(error);
809
+ }
810
+ this.#resolveBindWaiters();
660
811
  }
661
812
 
662
813
  /**
@@ -671,6 +822,7 @@ export class FollowerLink {
671
822
  code: FOLLOWER_TIMEOUT_CODE,
672
823
  retryable: true,
673
824
  });
825
+ this.#rejectInFlight(this.#blockedError());
674
826
  const reprobe = (): void => {
675
827
  this.#cancelProbe = this.#schedule(() => {
676
828
  reprobe();
@@ -1,4 +1,5 @@
1
1
  import {
2
+ blockedLeadershipAvailability,
2
3
  classifySyncAvailability,
3
4
  type SyncAvailability,
4
5
  } from './availability';
@@ -432,51 +433,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
432
433
  this.#onRoundFailureChange(),
433
434
  );
434
435
  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
- }
436
+ this.#claimCoverage();
480
437
  this.#requestRead();
481
438
  }
482
439
  return () => {
@@ -492,6 +449,54 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
492
449
  };
493
450
  };
494
451
 
452
+ /** Claim this query's window coverage; a failure surfaces on the next read. */
453
+ #claimCoverage(): void {
454
+ if (this.spec.claimCoverage === false) return;
455
+ const claims: Promise<void>[] = [];
456
+ // A composed spec carries one coverage entry per query branch, and
457
+ // several branches can target the same base. One claim per distinct
458
+ // base covers the union of their units; a later entry for the same
459
+ // base does not replace the earlier one.
460
+ const unitsByBase = new Map<
461
+ string,
462
+ { base: WindowBase; units: Set<string> }
463
+ >();
464
+ for (const coverage of this.spec.coverage ?? []) {
465
+ const baseKey = windowBaseKey(coverage.base);
466
+ const existing = unitsByBase.get(baseKey);
467
+ if (existing === undefined) {
468
+ unitsByBase.set(baseKey, {
469
+ base: coverage.base,
470
+ units: new Set(coverage.units),
471
+ });
472
+ } else {
473
+ for (const unit of coverage.units) existing.units.add(unit);
474
+ }
475
+ }
476
+ for (const { base, units } of unitsByBase.values()) {
477
+ claims.push(this.store.setWindowClaim(this.#owner, base, [...units]));
478
+ }
479
+ const generation = this.#generation;
480
+ this.#claimPending = claims.length > 0;
481
+ if (claims.length > 0)
482
+ void Promise.all(claims).then(
483
+ () => {
484
+ if (generation !== this.#generation || this.#listeners.size === 0)
485
+ return;
486
+ this.#claimPending = false;
487
+ if (!this.#running && this.#state.phase !== 'ready')
488
+ this.#requestRead();
489
+ },
490
+ (error: unknown) => {
491
+ if (generation !== this.#generation || this.#listeners.size === 0)
492
+ return;
493
+ this.#claimPending = false;
494
+ this.#claimError = errorOf(error);
495
+ this.#requestRead();
496
+ },
497
+ );
498
+ }
499
+
495
500
  refresh = (): void => {
496
501
  if (this.#delegate !== undefined) this.#delegate.refresh();
497
502
  else this.#requestRead(true);
@@ -579,6 +584,12 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
579
584
  return;
580
585
  }
581
586
  const wasBlocked = this.#state.phase === 'blocked';
587
+ // A claim that failed while the leader link was blocked is retried with
588
+ // the link: its stored error would otherwise fail every later read.
589
+ if (wasBlocked && this.#claimError !== undefined) {
590
+ this.#claimError = undefined;
591
+ this.#claimCoverage();
592
+ }
582
593
  this.#publish({
583
594
  ...this.#state,
584
595
  phase: wasBlocked
@@ -979,12 +990,10 @@ export class ReactiveClientStore {
979
990
  const snapshot = this.status.getSnapshot();
980
991
  if (snapshot.status === undefined) {
981
992
  return snapshot.leadership?.state === 'blocked'
982
- ? {
983
- state: 'blocked',
984
- reason: 'leader-unreachable',
985
- currentSchemaVersion: this.client.currentSchemaVersion ?? 0,
986
- retryable: true,
987
- }
993
+ ? blockedLeadershipAvailability(
994
+ snapshot.leadership,
995
+ this.client.currentSchemaVersion ?? 0,
996
+ )
988
997
  : { state: 'ready' };
989
998
  }
990
999
  return classifySyncAvailability(snapshot.status, snapshot.leadership);
@@ -1173,6 +1182,13 @@ export class ReactiveClientStore {
1173
1182
  leadership,
1174
1183
  });
1175
1184
  if (previous.isLoading) this.status.refresh();
1185
+ // Window edits that failed while no leader served this tab apply now;
1186
+ // an applied group compares equal and makes no call.
1187
+ if (leadership.state === 'leader' || leadership.state === 'follower') {
1188
+ for (const group of this.#windowClaims.values()) {
1189
+ if (group.claims.size > 0) this.#scheduleWindow(group);
1190
+ }
1191
+ }
1176
1192
  });
1177
1193
  this.status.refresh();
1178
1194
  this.conflicts.refresh();