@syncular/client 0.29.1 → 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/README.md CHANGED
@@ -209,20 +209,34 @@ A leader tab that processes no messages (hung or frozen) therefore blocks its
209
209
  followers within `followerCallTimeoutMs`; a closed leader releases its Web
210
210
  Lock and hands over without waiting for that deadline. A blocked follower keeps
211
211
  probing at the same interval: an answer rebinds the same handle, and a granted
212
- Web Lock promotes it. A single call that runs past `followerCallTimeoutMs`
213
- rejects with `client.follower_timeout` and leaves the handle bound, because
214
- reachability is decided by the probe alone. An unreachable `BroadcastChannel`
215
- is never treated as evidence that the lock owner is stale, so it never
216
- authorizes a second worker or database owner.
217
-
218
- **Upgrade note: reload every open tab.** Followers running 0.26.0 or older
219
- wait for a leader heartbeat that current leaders no longer send. Such a tab
220
- next to a current leader reports `leader-unreachable` with
221
- `client.follower_timeout` after `followerCallTimeoutMs`, unless a current
222
- follower's probe happens to make the leader announce, and stays that way
223
- until it reloads. A current follower next to an older leader works, because
224
- older leaders answer every `hello`. After upgrading, reload every open tab of
225
- the origin.
212
+ Web Lock promotes it. An unreachable `BroadcastChannel` is never treated as
213
+ evidence that the lock owner is stale, so it never authorizes a second worker
214
+ or database owner.
215
+
216
+ **Forwarded calls have no deadline.** The leader's core serializes window
217
+ edits and sync rounds, so a follower's `setWindow` can wait behind a long
218
+ bootstrap download, exactly as the same call does in the leader tab. While
219
+ the leader answers probes, a forwarded call waits for its result. It rejects
220
+ with `client.follower_timeout` when the link goes `blocked`, with
221
+ `client.leader_handover` when another leader announces or this tab promotes
222
+ before the answer arrives, and with `client.worker_failed` when the handle
223
+ closes. Only calls queued while no leader is bound keep the
224
+ `followerCallTimeoutMs` deadline.
225
+
226
+ **Tabs of different builds never serve each other.** Every `hello`, `req`,
227
+ and `announce` carries `MULTI_TAB_PROTOCOL_VERSION` and the schema version.
228
+ A follower whose leader differs sends it nothing: `handle.leadership` becomes
229
+ `{ state: 'blocked', reason: 'leader-incompatible', code:
230
+ 'client.leader_incompatible', leader: 'older' | 'newer', retryable: true }`
231
+ and calls reject with `client.leader_incompatible`. A leader that hears a
232
+ `hello` from a newer tab closes its core and releases the Web Lock; the newer
233
+ tab promotes, and the former leader stays blocked with `leader: 'newer'`
234
+ until it reloads. A leader answers a request with another identity with a
235
+ `client.leader_incompatible` error. Tabs running 0.29.1 or older send no
236
+ identity and count as older: they never step down, so a newer tab next to
237
+ one shows `leader: 'older'` until that tab reloads or closes. Bump
238
+ `MULTI_TAB_PROTOCOL_VERSION` with every change to a message shape or to a
239
+ forwarded method's arguments or result.
226
240
 
227
241
  **A hidden leader keeps leadership.** A visible tab does not take leadership
228
242
  from a hidden leader. Web Locks `steal` revokes the lease while the leader's
@@ -291,8 +305,9 @@ errors or inspecting generated schema modules:
291
305
  ```
292
306
 
293
307
  The state is a discriminated union covering startup, migration,
294
- `client-upgrade-required`, `server-behind`, `incompatible-schema`, and
295
- `leader-unreachable`. Recovery changes the same handle/provider back to its
308
+ `client-upgrade-required`, `server-behind`, `incompatible-schema`,
309
+ `leader-unreachable`, and `leader-incompatible` (with `leader: 'older' |
310
+ 'newer'`). Recovery changes the same handle/provider back to its
296
311
  children; a blocked live query has `phase === 'blocked'`, never an indefinite
297
312
  loading state.
298
313
 
@@ -7,11 +7,18 @@ export type SyncAvailability = {
7
7
  readonly currentSchemaVersion: number;
8
8
  } | {
9
9
  readonly state: 'blocked';
10
- readonly reason: 'client-upgrade-required' | 'server-behind' | 'incompatible-schema' | 'leader-unreachable';
10
+ readonly reason: 'client-upgrade-required' | 'server-behind' | 'incompatible-schema' | 'leader-unreachable' | 'leader-incompatible';
11
11
  readonly currentSchemaVersion: number;
12
+ /** `leader-incompatible`: whether the tab holding the database runs an
13
+ * older or a newer build than this one. */
14
+ readonly leader?: 'older' | 'newer';
12
15
  readonly requiredSchemaVersion?: number;
13
16
  readonly latestServerSchemaVersion?: number;
14
17
  readonly retryable: boolean;
15
18
  };
19
+ /** The availability of a tab whose leader link is blocked. */
20
+ export declare function blockedLeadershipAvailability(leadership: Extract<LeadershipState, {
21
+ state: 'blocked';
22
+ }>, currentSchemaVersion: number): SyncAvailability;
16
23
  /** Classify schema and browser-ownership state without parsing diagnostics. */
17
24
  export declare function classifySyncAvailability(status: SyncStatusSnapshot, leadership?: LeadershipState): SyncAvailability;
@@ -1,13 +1,25 @@
1
- /** Classify schema and browser-ownership state without parsing diagnostics. */
2
- export function classifySyncAvailability(status, leadership) {
3
- const currentSchemaVersion = status.currentSchemaVersion;
4
- if (leadership?.state === 'blocked') {
5
- return {
1
+ /** The availability of a tab whose leader link is blocked. */
2
+ export function blockedLeadershipAvailability(leadership, currentSchemaVersion) {
3
+ return leadership.reason === 'leader-incompatible'
4
+ ? {
5
+ state: 'blocked',
6
+ reason: 'leader-incompatible',
7
+ currentSchemaVersion,
8
+ leader: leadership.leader,
9
+ retryable: true,
10
+ }
11
+ : {
6
12
  state: 'blocked',
7
13
  reason: 'leader-unreachable',
8
14
  currentSchemaVersion,
9
15
  retryable: true,
10
16
  };
17
+ }
18
+ /** Classify schema and browser-ownership state without parsing diagnostics. */
19
+ export function classifySyncAvailability(status, leadership) {
20
+ const currentSchemaVersion = status.currentSchemaVersion;
21
+ if (leadership?.state === 'blocked') {
22
+ return blockedLeadershipAvailability(leadership, currentSchemaVersion);
11
23
  }
12
24
  const required = status.schemaFloor?.requiredSchemaVersion;
13
25
  const latest = status.schemaFloor?.latestSchemaVersion;
@@ -1,7 +1,30 @@
1
1
  import type { SyncWorkerEvent } from './worker-protocol.js';
2
2
  /** Client-local: a follower call could not reach a leader before its deadline. */
3
3
  export declare const FOLLOWER_TIMEOUT_CODE = "client.follower_timeout";
4
- /** Default deadline for a follower request (covers a leader-handover gap). */
4
+ /** Client-local: the leader runs a different protocol or schema version. */
5
+ export declare const LEADER_INCOMPATIBLE_CODE = "client.leader_incompatible";
6
+ /** Client-local: the leader that received a call stopped leading before it answered. */
7
+ export declare const LEADER_HANDOVER_CODE = "client.leader_handover";
8
+ /**
9
+ * Version of the leader/follower wire and the forwarded handle API. Bump it
10
+ * with every change to a message shape or to a forwarded method's arguments
11
+ * or result, so tabs of different builds never serve each other.
12
+ */
13
+ export declare const MULTI_TAB_PROTOCOL_VERSION = 1;
14
+ /** What a tab must share with its leader to be served by it. */
15
+ export interface TabIdentity {
16
+ readonly protocol: number;
17
+ readonly schemaVersion: number;
18
+ }
19
+ /**
20
+ * Order a peer's identity against our own: -1 older, 0 same, 1 newer. A peer
21
+ * from before protocol 1 sends no identity and is older.
22
+ */
23
+ export declare function compareTabIdentity(peer: {
24
+ readonly protocol?: number;
25
+ readonly schemaVersion?: number;
26
+ }, own: TabIdentity): -1 | 0 | 1;
27
+ /** Default deadline for a call queued while no leader is bound (the handover gap). */
5
28
  export declare const DEFAULT_FOLLOWER_CALL_TIMEOUT_MS = 10000;
6
29
  /** Max follower calls queued across a handover before we fail loudly. */
7
30
  export declare const DEFAULT_FOLLOWER_QUEUE_LIMIT = 256;
@@ -20,15 +43,27 @@ export type LeadershipState = {
20
43
  readonly reason: 'leader-unreachable';
21
44
  readonly code: typeof FOLLOWER_TIMEOUT_CODE;
22
45
  readonly retryable: true;
46
+ } | {
47
+ readonly state: 'blocked';
48
+ readonly reason: 'leader-incompatible';
49
+ readonly code: typeof LEADER_INCOMPATIBLE_CODE;
50
+ /** Whether the tab holding the database runs an older or a newer build. */
51
+ readonly leader: 'older' | 'newer';
52
+ readonly retryable: true;
23
53
  };
24
- interface HelloMessage {
54
+ /** Identity fields; absent in messages from tabs before protocol 1. */
55
+ interface IdentityFields {
56
+ readonly protocol?: number;
57
+ readonly schemaVersion?: number;
58
+ }
59
+ interface HelloMessage extends IdentityFields {
25
60
  readonly t: 'hello';
26
61
  readonly fromId: string;
27
62
  /** Highest epoch the sender has observed (helps a promoter monotonically
28
63
  * advance even if it never saw the previous leader's announce). */
29
64
  readonly epoch?: number;
30
65
  }
31
- interface ReqMessage {
66
+ interface ReqMessage extends IdentityFields {
32
67
  readonly t: 'req';
33
68
  readonly epoch: number;
34
69
  readonly fromId: string;
@@ -36,7 +71,7 @@ interface ReqMessage {
36
71
  readonly method: string;
37
72
  readonly args: readonly unknown[];
38
73
  }
39
- interface AnnounceMessage {
74
+ interface AnnounceMessage extends IdentityFields {
40
75
  readonly t: 'announce';
41
76
  readonly epoch: number;
42
77
  readonly clientId: string;
@@ -83,7 +118,9 @@ export declare function newTabId(): string;
83
118
  * Runs on the leader tab. Announces leadership, answers follower `req`s by
84
119
  * invoking the (already-running) worker via `invoke`, and rebroadcasts every
85
120
  * worker event to followers. Owns nothing about the worker lifecycle — the
86
- * worker-host stays the single core owner; this is a relay.
121
+ * worker-host stays the single core owner; this is a relay. A hello from a
122
+ * newer tab calls `onNewerTab`, whose owner closes the core and releases the
123
+ * lock.
87
124
  */
88
125
  export declare class LeaderBridge {
89
126
  #private;
@@ -91,7 +128,9 @@ export declare class LeaderBridge {
91
128
  channel: CrossTabChannel;
92
129
  epoch: number;
93
130
  clientId: string;
131
+ identity: TabIdentity;
94
132
  invoke: (method: string, args: readonly unknown[]) => Promise<unknown>;
133
+ onNewerTab: () => void;
95
134
  });
96
135
  /** Announce leadership (on promotion and in answer to every `hello`). */
97
136
  announce(): void;
@@ -111,13 +150,15 @@ type FollowerSchedule = (callback: () => void, delayMs: number) => () => void;
111
150
  * Feeds fanned-out events to `onEvent`. Learns leadership changes and hands
112
151
  * the resolved leader `clientId` back through `onLeaderChange`. Probes the
113
152
  * bound leader with `hello` after a quiet period and reports `blocked` only
114
- * when a probe goes unanswered (see the module comment).
153
+ * when a probe goes unanswered, or when the leader's identity differs from
154
+ * this tab's (see the module comment).
115
155
  */
116
156
  export declare class FollowerLink {
117
157
  #private;
118
158
  constructor(options: {
119
159
  channel: CrossTabChannel;
120
160
  fromId: string;
161
+ identity: TabIdentity;
121
162
  onEvent: (event: SyncWorkerEvent) => void;
122
163
  onLeaderChange: (clientId: string) => void;
123
164
  onStateChange?: (state: LeadershipState) => void;
@@ -147,8 +188,8 @@ export declare class FollowerLink {
147
188
  /**
148
189
  * The leader we were bound to went away (its lock released). Un-bind so new
149
190
  * calls queue again; re-request an announce so the next leader finds us.
150
- * In-flight calls stay pending — their per-call timeout is the backstop,
151
- * and the winner's announce will flush any that were queued.
191
+ * Calls in flight to the departed leader reject; the winner's announce
192
+ * flushes the queued ones.
152
193
  */
153
194
  unbind(): void;
154
195
  close(): void;
package/dist/multi-tab.js 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)`.
@@ -49,7 +62,30 @@ import { ClientSyncError } from './errors.js';
49
62
  import { WORKER_FAILED_CODE } from './worker-protocol.js';
50
63
  /** Client-local: a follower call could not reach a leader before its deadline. */
51
64
  export const FOLLOWER_TIMEOUT_CODE = 'client.follower_timeout';
52
- /** Default deadline for a follower request (covers a leader-handover gap). */
65
+ /** Client-local: the leader runs a different protocol or schema version. */
66
+ export const LEADER_INCOMPATIBLE_CODE = 'client.leader_incompatible';
67
+ /** Client-local: the leader that received a call stopped leading before it answered. */
68
+ export const LEADER_HANDOVER_CODE = 'client.leader_handover';
69
+ /**
70
+ * Version of the leader/follower wire and the forwarded handle API. Bump it
71
+ * with every change to a message shape or to a forwarded method's arguments
72
+ * or result, so tabs of different builds never serve each other.
73
+ */
74
+ export const MULTI_TAB_PROTOCOL_VERSION = 1;
75
+ /**
76
+ * Order a peer's identity against our own: -1 older, 0 same, 1 newer. A peer
77
+ * from before protocol 1 sends no identity and is older.
78
+ */
79
+ export function compareTabIdentity(peer, own) {
80
+ if (peer.protocol === undefined || peer.schemaVersion === undefined)
81
+ return -1;
82
+ if (peer.protocol !== own.protocol)
83
+ return peer.protocol < own.protocol ? -1 : 1;
84
+ if (peer.schemaVersion !== own.schemaVersion)
85
+ return peer.schemaVersion < own.schemaVersion ? -1 : 1;
86
+ return 0;
87
+ }
88
+ /** Default deadline for a call queued while no leader is bound (the handover gap). */
53
89
  export const DEFAULT_FOLLOWER_CALL_TIMEOUT_MS = 10_000;
54
90
  /** Max follower calls queued across a handover before we fail loudly. */
55
91
  export const DEFAULT_FOLLOWER_QUEUE_LIMIT = 256;
@@ -76,20 +112,26 @@ export function newTabId() {
76
112
  * Runs on the leader tab. Announces leadership, answers follower `req`s by
77
113
  * invoking the (already-running) worker via `invoke`, and rebroadcasts every
78
114
  * worker event to followers. Owns nothing about the worker lifecycle — the
79
- * worker-host stays the single core owner; this is a relay.
115
+ * worker-host stays the single core owner; this is a relay. A hello from a
116
+ * newer tab calls `onNewerTab`, whose owner closes the core and releases the
117
+ * lock.
80
118
  */
81
119
  export class LeaderBridge {
82
120
  #channel;
83
121
  #epoch;
84
122
  #clientId;
123
+ #identity;
85
124
  #invoke;
125
+ #onNewerTab;
86
126
  #onMessage;
87
127
  #closed = false;
88
128
  constructor(options) {
89
129
  this.#channel = options.channel;
90
130
  this.#epoch = options.epoch;
91
131
  this.#clientId = options.clientId;
132
+ this.#identity = options.identity;
92
133
  this.#invoke = options.invoke;
134
+ this.#onNewerTab = options.onNewerTab;
93
135
  this.#onMessage = (event) => this.#handle(event.data);
94
136
  this.#channel.addEventListener('message', this.#onMessage);
95
137
  this.announce();
@@ -102,6 +144,7 @@ export class LeaderBridge {
102
144
  t: 'announce',
103
145
  epoch: this.#epoch,
104
146
  clientId: this.#clientId,
147
+ ...this.#identity,
105
148
  });
106
149
  }
107
150
  /** Fan a worker event out to all followers on the current epoch. */
@@ -114,7 +157,12 @@ export class LeaderBridge {
114
157
  if (this.#closed)
115
158
  return;
116
159
  if (message.t === 'hello') {
117
- // A follower joined or probes liveness: tell it who leads.
160
+ // A newer tab takes over; any other follower learns who leads.
161
+ if (compareTabIdentity(message, this.#identity) > 0) {
162
+ this.close();
163
+ this.#onNewerTab();
164
+ return;
165
+ }
118
166
  this.announce();
119
167
  return;
120
168
  }
@@ -125,6 +173,20 @@ export class LeaderBridge {
125
173
  if (message.epoch !== this.#epoch)
126
174
  return;
127
175
  const { reqId, method, args } = message;
176
+ if (compareTabIdentity(message, this.#identity) !== 0) {
177
+ this.#channel.postMessage({
178
+ t: 'res',
179
+ epoch: this.#epoch,
180
+ reqId,
181
+ ok: false,
182
+ error: {
183
+ code: LEADER_INCOMPATIBLE_CODE,
184
+ message: 'the leader runs a different protocol or schema version',
185
+ retryable: true,
186
+ },
187
+ });
188
+ return;
189
+ }
128
190
  this.#invoke(method, args).then((value) => {
129
191
  this.#channel.postMessage({
130
192
  t: 'res',
@@ -169,11 +231,13 @@ export class LeaderBridge {
169
231
  * Feeds fanned-out events to `onEvent`. Learns leadership changes and hands
170
232
  * the resolved leader `clientId` back through `onLeaderChange`. Probes the
171
233
  * bound leader with `hello` after a quiet period and reports `blocked` only
172
- * when a probe goes unanswered (see the module comment).
234
+ * when a probe goes unanswered, or when the leader's identity differs from
235
+ * this tab's (see the module comment).
173
236
  */
174
237
  export class FollowerLink {
175
238
  #channel;
176
239
  #fromId;
240
+ #identity;
177
241
  #onEvent;
178
242
  #onLeaderChange;
179
243
  #onStateChange;
@@ -203,6 +267,7 @@ export class FollowerLink {
203
267
  constructor(options) {
204
268
  this.#channel = options.channel;
205
269
  this.#fromId = options.fromId;
270
+ this.#identity = options.identity;
206
271
  this.#onEvent = options.onEvent;
207
272
  this.#onLeaderChange = options.onLeaderChange;
208
273
  this.#onStateChange = options.onStateChange ?? (() => { });
@@ -286,7 +351,7 @@ export class FollowerLink {
286
351
  return Promise.reject(new ClientSyncError(WORKER_FAILED_CODE, 'the follower link is closed'));
287
352
  }
288
353
  if (this.#state.state === 'blocked') {
289
- return Promise.reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'the follower cannot reach the tab that owns the database', true));
354
+ return Promise.reject(this.#blockedError());
290
355
  }
291
356
  return new Promise((resolve, reject) => {
292
357
  const queued = {
@@ -316,16 +381,11 @@ export class FollowerLink {
316
381
  }
317
382
  #send(queued) {
318
383
  const reqId = this.#nextReqId++;
319
- // The deadline fails this one call. Leader reachability is decided by the
320
- // liveness probe alone, so a slow call against a live leader leaves the
321
- // link bound.
384
+ // No deadline: a live leader may queue the call behind a long sync round.
385
+ // The liveness probe, a leader change, or close settles it otherwise.
322
386
  this.#inFlight.set(reqId, {
323
387
  resolve: queued.resolve,
324
388
  reject: queued.reject,
325
- cancelTimer: this.#schedule(() => {
326
- this.#inFlight.delete(reqId);
327
- queued.reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'the leader did not answer within the follower call timeout', true));
328
- }, this.#callTimeoutMs),
329
389
  });
330
390
  this.#channel.postMessage({
331
391
  t: 'req',
@@ -334,8 +394,23 @@ export class FollowerLink {
334
394
  reqId,
335
395
  method: queued.method,
336
396
  args: queued.args,
397
+ ...this.#identity,
337
398
  });
338
399
  }
400
+ /** The rejection for a call made while the link is blocked. */
401
+ #blockedError() {
402
+ return this.#state.state === 'blocked' &&
403
+ this.#state.reason === 'leader-incompatible'
404
+ ? new ClientSyncError(LEADER_INCOMPATIBLE_CODE, 'the tab that owns the database runs a different protocol or schema version', true)
405
+ : new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'the follower cannot reach the tab that owns the database', true);
406
+ }
407
+ /** Reject every forwarded call; their leader will not answer them. */
408
+ #rejectInFlight(error) {
409
+ const pending = [...this.#inFlight.values()];
410
+ this.#inFlight.clear();
411
+ for (const inflight of pending)
412
+ inflight.reject(error);
413
+ }
339
414
  #dropQueued(queued) {
340
415
  const index = this.#queue.indexOf(queued);
341
416
  if (index >= 0)
@@ -351,8 +426,17 @@ export class FollowerLink {
351
426
  // Ignore a stale announce (older than the leader we already track).
352
427
  if (message.epoch < this.#epoch)
353
428
  return;
429
+ const relation = compareTabIdentity(message, this.#identity);
430
+ if (relation !== 0) {
431
+ this.#setIncompatible(relation < 0 ? 'older' : 'newer');
432
+ return;
433
+ }
354
434
  const changed = message.epoch !== this.#epoch ||
355
435
  message.clientId !== this.#leaderClientId;
436
+ // Calls sent to the previous leader get no answer from this one.
437
+ if (changed && this.#epoch >= 0) {
438
+ this.#rejectInFlight(new ClientSyncError(LEADER_HANDOVER_CODE, 'the leader changed before it answered the call', true));
439
+ }
356
440
  this.#epoch = message.epoch;
357
441
  this.#leaderClientId = message.clientId;
358
442
  if (changed)
@@ -376,7 +460,6 @@ export class FollowerLink {
376
460
  if (inflight === undefined)
377
461
  return;
378
462
  this.#inFlight.delete(message.reqId);
379
- inflight.cancelTimer();
380
463
  if (message.ok) {
381
464
  inflight.resolve(message.value);
382
465
  }
@@ -406,12 +489,13 @@ export class FollowerLink {
406
489
  /**
407
490
  * The leader we were bound to went away (its lock released). Un-bind so new
408
491
  * calls queue again; re-request an announce so the next leader finds us.
409
- * In-flight calls stay pending — their per-call timeout is the backstop,
410
- * and the winner's announce will flush any that were queued.
492
+ * Calls in flight to the departed leader reject; the winner's announce
493
+ * flushes the queued ones.
411
494
  */
412
495
  unbind() {
413
496
  if (this.#closed)
414
497
  return;
498
+ this.#rejectInFlight(new ClientSyncError(LEADER_HANDOVER_CODE, 'the leader released the database before it answered the call', true));
415
499
  this.#epoch = -1;
416
500
  this.#leaderClientId = '';
417
501
  this.#setState({ state: 'waiting', reason: 'handover' });
@@ -425,11 +509,7 @@ export class FollowerLink {
425
509
  this.#clearReachabilityTimers();
426
510
  this.#channel.removeEventListener('message', this.#onMessage);
427
511
  const closedError = new ClientSyncError(WORKER_FAILED_CODE, 'the follower link was closed');
428
- for (const inflight of this.#inFlight.values()) {
429
- inflight.cancelTimer();
430
- inflight.reject(closedError);
431
- }
432
- this.#inFlight.clear();
512
+ this.#rejectInFlight(closedError);
433
513
  for (const queued of this.#queue) {
434
514
  queued.cancelTimer?.();
435
515
  queued.reject(closedError);
@@ -443,7 +523,12 @@ export class FollowerLink {
443
523
  #setState(state) {
444
524
  const previous = this.#state;
445
525
  if (previous.state === state.state &&
446
- (state.state === 'blocked' ||
526
+ ((state.state === 'blocked' &&
527
+ previous.state === 'blocked' &&
528
+ previous.reason === state.reason &&
529
+ (state.reason !== 'leader-incompatible' ||
530
+ (previous.reason === 'leader-incompatible' &&
531
+ previous.leader === state.leader))) ||
447
532
  (state.state === 'waiting' &&
448
533
  previous.state === 'waiting' &&
449
534
  previous.reason === state.reason) ||
@@ -467,7 +552,34 @@ export class FollowerLink {
467
552
  t: 'hello',
468
553
  fromId: this.#fromId,
469
554
  epoch: this.#maxEpochSeen,
555
+ ...this.#identity,
556
+ });
557
+ }
558
+ /**
559
+ * The leader runs another build. Send it nothing and stay unbound until a
560
+ * leader with this tab's identity announces (a newer leader's own hello
561
+ * already asked an older one to step down; see the module comment).
562
+ */
563
+ #setIncompatible(leader) {
564
+ this.#clearReachabilityTimers();
565
+ this.#epoch = -1;
566
+ this.#leaderClientId = '';
567
+ this.#setState({
568
+ state: 'blocked',
569
+ reason: 'leader-incompatible',
570
+ code: LEADER_INCOMPATIBLE_CODE,
571
+ leader,
572
+ retryable: true,
470
573
  });
574
+ const error = this.#blockedError();
575
+ this.#rejectInFlight(error);
576
+ const queued = this.#queue;
577
+ this.#queue = [];
578
+ for (const call of queued) {
579
+ call.cancelTimer?.();
580
+ call.reject(error);
581
+ }
582
+ this.#resolveBindWaiters();
471
583
  }
472
584
  /**
473
585
  * No leader answered in time. Calls reject immediately from here on; keep
@@ -481,6 +593,7 @@ export class FollowerLink {
481
593
  code: FOLLOWER_TIMEOUT_CODE,
482
594
  retryable: true,
483
595
  });
596
+ this.#rejectInFlight(this.#blockedError());
484
597
  const reprobe = () => {
485
598
  this.#cancelProbe = this.#schedule(() => {
486
599
  reprobe();