@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/README.md +31 -16
- package/dist/availability.d.ts +8 -1
- package/dist/availability.js +17 -5
- package/dist/multi-tab.d.ts +49 -8
- package/dist/multi-tab.js +137 -24
- package/dist/reactive-store.js +61 -48
- package/dist/worker-host.d.ts +6 -0
- package/dist/worker-host.js +67 -2
- package/package.json +3 -3
- package/src/availability.ts +27 -7
- package/src/multi-tab.ts +192 -40
- package/src/reactive-store.ts +67 -51
- package/src/worker-host.ts +74 -1
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}
|
|
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}
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
495
|
-
// liveness probe
|
|
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
|
-
*
|
|
594
|
-
*
|
|
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
|
-
|
|
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();
|
package/src/reactive-store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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();
|