@syncular/client 0.15.13 → 0.15.15
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 +88 -2
- package/dist/availability.d.ts +17 -0
- package/dist/availability.js +48 -0
- package/dist/client.d.ts +33 -0
- package/dist/client.js +124 -22
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/invalidation.d.ts +1 -0
- package/dist/multi-tab.d.ts +24 -1
- package/dist/multi-tab.js +113 -4
- package/dist/reactive-store.d.ts +10 -1
- package/dist/reactive-store.js +107 -5
- package/dist/worker-entry.js +28 -2
- package/dist/worker-host.d.ts +40 -3
- package/dist/worker-host.js +126 -7
- package/dist/worker-protocol.d.ts +10 -1
- package/package.json +3 -3
- package/src/availability.ts +70 -0
- package/src/client.ts +176 -23
- package/src/index.ts +1 -0
- package/src/invalidation.ts +1 -0
- package/src/multi-tab.ts +158 -0
- package/src/reactive-store.ts +138 -7
- package/src/worker-entry.ts +32 -2
- package/src/worker-host.ts +174 -6
- package/src/worker-protocol.ts +11 -0
package/src/multi-tab.ts
CHANGED
|
@@ -46,6 +46,24 @@ export const DEFAULT_FOLLOWER_CALL_TIMEOUT_MS = 10_000;
|
|
|
46
46
|
/** Max follower calls queued across a handover before we fail loudly. */
|
|
47
47
|
export const DEFAULT_FOLLOWER_QUEUE_LIMIT = 256;
|
|
48
48
|
|
|
49
|
+
export type LeadershipState =
|
|
50
|
+
| { readonly state: 'leader'; readonly clientId: string }
|
|
51
|
+
| {
|
|
52
|
+
readonly state: 'follower';
|
|
53
|
+
readonly leaderClientId: string;
|
|
54
|
+
readonly epoch: number;
|
|
55
|
+
}
|
|
56
|
+
| {
|
|
57
|
+
readonly state: 'waiting';
|
|
58
|
+
readonly reason: 'handover' | 'leader-announcement';
|
|
59
|
+
}
|
|
60
|
+
| {
|
|
61
|
+
readonly state: 'blocked';
|
|
62
|
+
readonly reason: 'leader-unreachable';
|
|
63
|
+
readonly code: typeof FOLLOWER_TIMEOUT_CODE;
|
|
64
|
+
readonly retryable: true;
|
|
65
|
+
};
|
|
66
|
+
|
|
49
67
|
// ---------------------------------------------------------------------------
|
|
50
68
|
// Wire messages
|
|
51
69
|
// ---------------------------------------------------------------------------
|
|
@@ -63,6 +81,11 @@ interface ByeMessage {
|
|
|
63
81
|
readonly fromId: string;
|
|
64
82
|
}
|
|
65
83
|
|
|
84
|
+
interface BoundMessage {
|
|
85
|
+
readonly t: 'bound';
|
|
86
|
+
readonly fromId: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
66
89
|
interface ReqMessage {
|
|
67
90
|
readonly t: 'req';
|
|
68
91
|
readonly epoch: number;
|
|
@@ -96,6 +119,7 @@ interface EventMessage {
|
|
|
96
119
|
export type MultiTabMessage =
|
|
97
120
|
| HelloMessage
|
|
98
121
|
| ByeMessage
|
|
122
|
+
| BoundMessage
|
|
99
123
|
| ReqMessage
|
|
100
124
|
| AnnounceMessage
|
|
101
125
|
| ResMessage
|
|
@@ -157,6 +181,9 @@ export class LeaderBridge {
|
|
|
157
181
|
args: readonly unknown[],
|
|
158
182
|
) => Promise<unknown>;
|
|
159
183
|
readonly #onMessage: (event: { data: MultiTabMessage }) => void;
|
|
184
|
+
readonly #heartbeatMs: number;
|
|
185
|
+
readonly #followers = new Set<string>();
|
|
186
|
+
#heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
160
187
|
#closed = false;
|
|
161
188
|
|
|
162
189
|
constructor(options: {
|
|
@@ -164,11 +191,15 @@ export class LeaderBridge {
|
|
|
164
191
|
epoch: number;
|
|
165
192
|
clientId: string;
|
|
166
193
|
invoke: (method: string, args: readonly unknown[]) => Promise<unknown>;
|
|
194
|
+
heartbeatMs?: number;
|
|
167
195
|
}) {
|
|
168
196
|
this.#channel = options.channel;
|
|
169
197
|
this.#epoch = options.epoch;
|
|
170
198
|
this.#clientId = options.clientId;
|
|
171
199
|
this.#invoke = options.invoke;
|
|
200
|
+
this.#heartbeatMs =
|
|
201
|
+
options.heartbeatMs ??
|
|
202
|
+
Math.max(50, Math.floor(DEFAULT_FOLLOWER_CALL_TIMEOUT_MS / 3));
|
|
172
203
|
this.#onMessage = (event) => this.#handle(event.data);
|
|
173
204
|
this.#channel.addEventListener('message', this.#onMessage);
|
|
174
205
|
this.announce();
|
|
@@ -194,9 +225,22 @@ export class LeaderBridge {
|
|
|
194
225
|
if (this.#closed) return;
|
|
195
226
|
if (message.t === 'hello') {
|
|
196
227
|
// A follower joined (or is contesting) — tell it who leads.
|
|
228
|
+
this.#trackFollower(message.fromId);
|
|
197
229
|
this.announce();
|
|
198
230
|
return;
|
|
199
231
|
}
|
|
232
|
+
if (message.t === 'bound') {
|
|
233
|
+
this.#trackFollower(message.fromId);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (message.t === 'bye') {
|
|
237
|
+
this.#followers.delete(message.fromId);
|
|
238
|
+
if (this.#followers.size === 0 && this.#heartbeat !== undefined) {
|
|
239
|
+
clearInterval(this.#heartbeat);
|
|
240
|
+
this.#heartbeat = undefined;
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
200
244
|
if (message.t !== 'req') return;
|
|
201
245
|
// Ignore requests stamped for a different (dead/older) leader; that
|
|
202
246
|
// follower will re-stamp once it sees our announce.
|
|
@@ -237,10 +281,19 @@ export class LeaderBridge {
|
|
|
237
281
|
);
|
|
238
282
|
}
|
|
239
283
|
|
|
284
|
+
#trackFollower(fromId: string): void {
|
|
285
|
+
this.#followers.add(fromId);
|
|
286
|
+
if (this.#heartbeat !== undefined) return;
|
|
287
|
+
this.#heartbeat = setInterval(() => this.announce(), this.#heartbeatMs);
|
|
288
|
+
}
|
|
289
|
+
|
|
240
290
|
close(): void {
|
|
241
291
|
if (this.#closed) return;
|
|
242
292
|
this.#closed = true;
|
|
293
|
+
if (this.#heartbeat !== undefined) clearInterval(this.#heartbeat);
|
|
294
|
+
this.#heartbeat = undefined;
|
|
243
295
|
this.#channel.removeEventListener('message', this.#onMessage);
|
|
296
|
+
this.#channel.close();
|
|
244
297
|
}
|
|
245
298
|
}
|
|
246
299
|
|
|
@@ -274,6 +327,7 @@ export class FollowerLink {
|
|
|
274
327
|
readonly #fromId: string;
|
|
275
328
|
readonly #onEvent: (event: SyncWorkerEvent) => void;
|
|
276
329
|
readonly #onLeaderChange: (clientId: string) => void;
|
|
330
|
+
readonly #onStateChange: (state: LeadershipState) => void;
|
|
277
331
|
readonly #callTimeoutMs: number;
|
|
278
332
|
readonly #queueLimit: number;
|
|
279
333
|
readonly #onMessage: (event: { data: MultiTabMessage }) => void;
|
|
@@ -287,6 +341,12 @@ export class FollowerLink {
|
|
|
287
341
|
readonly #inFlight = new Map<number, InFlight>();
|
|
288
342
|
#queue: QueuedCall[] = [];
|
|
289
343
|
#closed = false;
|
|
344
|
+
#state: LeadershipState = {
|
|
345
|
+
state: 'waiting',
|
|
346
|
+
reason: 'leader-announcement',
|
|
347
|
+
};
|
|
348
|
+
#waitingTimer: ReturnType<typeof setTimeout> | undefined;
|
|
349
|
+
#blockedTimer: ReturnType<typeof setTimeout> | undefined;
|
|
290
350
|
/** Resolvers waiting for the first `announce` to bind a leader. */
|
|
291
351
|
#bindWaiters: Array<() => void> = [];
|
|
292
352
|
|
|
@@ -295,6 +355,7 @@ export class FollowerLink {
|
|
|
295
355
|
fromId: string;
|
|
296
356
|
onEvent: (event: SyncWorkerEvent) => void;
|
|
297
357
|
onLeaderChange: (clientId: string) => void;
|
|
358
|
+
onStateChange?: (state: LeadershipState) => void;
|
|
298
359
|
callTimeoutMs?: number;
|
|
299
360
|
queueLimit?: number;
|
|
300
361
|
}) {
|
|
@@ -302,6 +363,7 @@ export class FollowerLink {
|
|
|
302
363
|
this.#fromId = options.fromId;
|
|
303
364
|
this.#onEvent = options.onEvent;
|
|
304
365
|
this.#onLeaderChange = options.onLeaderChange;
|
|
366
|
+
this.#onStateChange = options.onStateChange ?? (() => {});
|
|
305
367
|
this.#callTimeoutMs =
|
|
306
368
|
options.callTimeoutMs ?? DEFAULT_FOLLOWER_CALL_TIMEOUT_MS;
|
|
307
369
|
this.#queueLimit = options.queueLimit ?? DEFAULT_FOLLOWER_QUEUE_LIMIT;
|
|
@@ -309,6 +371,7 @@ export class FollowerLink {
|
|
|
309
371
|
this.#channel.addEventListener('message', this.#onMessage);
|
|
310
372
|
// Ask the current leader to announce itself.
|
|
311
373
|
this.#channel.postMessage({ t: 'hello', fromId: this.#fromId });
|
|
374
|
+
this.#armUnboundDeadline();
|
|
312
375
|
}
|
|
313
376
|
|
|
314
377
|
get epoch(): number {
|
|
@@ -323,6 +386,10 @@ export class FollowerLink {
|
|
|
323
386
|
return this.#leaderClientId;
|
|
324
387
|
}
|
|
325
388
|
|
|
389
|
+
get leadershipState(): LeadershipState {
|
|
390
|
+
return this.#state;
|
|
391
|
+
}
|
|
392
|
+
|
|
326
393
|
/** Whether a leader is currently bound (an announce has been heard). */
|
|
327
394
|
get bound(): boolean {
|
|
328
395
|
return this.#epoch >= 0;
|
|
@@ -348,10 +415,12 @@ export class FollowerLink {
|
|
|
348
415
|
return new Promise((resolve, reject) => {
|
|
349
416
|
const timer = setTimeout(() => {
|
|
350
417
|
this.#dropBindWaiter(settle);
|
|
418
|
+
this.#setBlocked();
|
|
351
419
|
reject(
|
|
352
420
|
new ClientSyncError(
|
|
353
421
|
FOLLOWER_TIMEOUT_CODE,
|
|
354
422
|
'no leader announced within the follower bind timeout',
|
|
423
|
+
true,
|
|
355
424
|
),
|
|
356
425
|
);
|
|
357
426
|
}, timeoutMs);
|
|
@@ -382,6 +451,15 @@ export class FollowerLink {
|
|
|
382
451
|
new ClientSyncError(WORKER_FAILED_CODE, 'the follower link is closed'),
|
|
383
452
|
);
|
|
384
453
|
}
|
|
454
|
+
if (this.#state.state === 'blocked') {
|
|
455
|
+
return Promise.reject(
|
|
456
|
+
new ClientSyncError(
|
|
457
|
+
FOLLOWER_TIMEOUT_CODE,
|
|
458
|
+
'the follower cannot reach the tab that owns the database',
|
|
459
|
+
true,
|
|
460
|
+
),
|
|
461
|
+
);
|
|
462
|
+
}
|
|
385
463
|
return new Promise((resolve, reject) => {
|
|
386
464
|
const queued: QueuedCall = {
|
|
387
465
|
method,
|
|
@@ -393,20 +471,24 @@ export class FollowerLink {
|
|
|
393
471
|
if (this.#epoch < 0) {
|
|
394
472
|
// No leader bound yet — queue with a deadline so we never hang.
|
|
395
473
|
if (this.#queue.length >= this.#queueLimit) {
|
|
474
|
+
this.#setBlocked();
|
|
396
475
|
reject(
|
|
397
476
|
new ClientSyncError(
|
|
398
477
|
FOLLOWER_TIMEOUT_CODE,
|
|
399
478
|
'follower call queue overflow while awaiting a leader',
|
|
479
|
+
true,
|
|
400
480
|
),
|
|
401
481
|
);
|
|
402
482
|
return;
|
|
403
483
|
}
|
|
404
484
|
queued.timer = setTimeout(() => {
|
|
405
485
|
this.#dropQueued(queued);
|
|
486
|
+
this.#setBlocked();
|
|
406
487
|
reject(
|
|
407
488
|
new ClientSyncError(
|
|
408
489
|
FOLLOWER_TIMEOUT_CODE,
|
|
409
490
|
'no leader answered within the follower call timeout',
|
|
491
|
+
true,
|
|
410
492
|
),
|
|
411
493
|
);
|
|
412
494
|
}, this.#callTimeoutMs);
|
|
@@ -424,10 +506,12 @@ export class FollowerLink {
|
|
|
424
506
|
reject: queued.reject,
|
|
425
507
|
timer: setTimeout(() => {
|
|
426
508
|
this.#inFlight.delete(reqId);
|
|
509
|
+
this.#setBlocked();
|
|
427
510
|
queued.reject(
|
|
428
511
|
new ClientSyncError(
|
|
429
512
|
FOLLOWER_TIMEOUT_CODE,
|
|
430
513
|
'the leader did not answer within the follower call timeout',
|
|
514
|
+
true,
|
|
431
515
|
),
|
|
432
516
|
);
|
|
433
517
|
}, this.#callTimeoutMs),
|
|
@@ -462,6 +546,15 @@ export class FollowerLink {
|
|
|
462
546
|
this.#epoch = message.epoch;
|
|
463
547
|
this.#leaderClientId = message.clientId;
|
|
464
548
|
if (changed) this.#onLeaderChange(message.clientId);
|
|
549
|
+
this.#setState({
|
|
550
|
+
state: 'follower',
|
|
551
|
+
leaderClientId: message.clientId,
|
|
552
|
+
epoch: message.epoch,
|
|
553
|
+
});
|
|
554
|
+
this.#armBoundDeadline();
|
|
555
|
+
if (changed) {
|
|
556
|
+
this.#channel.postMessage({ t: 'bound', fromId: this.#fromId });
|
|
557
|
+
}
|
|
465
558
|
this.#resolveBindWaiters();
|
|
466
559
|
this.#flushQueue();
|
|
467
560
|
return;
|
|
@@ -516,6 +609,8 @@ export class FollowerLink {
|
|
|
516
609
|
if (this.#closed) return;
|
|
517
610
|
this.#epoch = -1;
|
|
518
611
|
this.#leaderClientId = '';
|
|
612
|
+
this.#setState({ state: 'waiting', reason: 'handover' });
|
|
613
|
+
this.#armUnboundDeadline();
|
|
519
614
|
this.#channel.postMessage({
|
|
520
615
|
t: 'hello',
|
|
521
616
|
fromId: this.#fromId,
|
|
@@ -526,6 +621,7 @@ export class FollowerLink {
|
|
|
526
621
|
close(): void {
|
|
527
622
|
if (this.#closed) return;
|
|
528
623
|
this.#closed = true;
|
|
624
|
+
this.#clearReachabilityTimers();
|
|
529
625
|
this.#channel.removeEventListener('message', this.#onMessage);
|
|
530
626
|
this.#channel.postMessage({ t: 'bye', fromId: this.#fromId });
|
|
531
627
|
const closedError = new ClientSyncError(
|
|
@@ -547,4 +643,66 @@ export class FollowerLink {
|
|
|
547
643
|
this.#resolveBindWaiters();
|
|
548
644
|
this.#channel.close();
|
|
549
645
|
}
|
|
646
|
+
|
|
647
|
+
#setState(state: LeadershipState): void {
|
|
648
|
+
const previous = this.#state;
|
|
649
|
+
if (
|
|
650
|
+
previous.state === state.state &&
|
|
651
|
+
(state.state === 'blocked' ||
|
|
652
|
+
(state.state === 'waiting' &&
|
|
653
|
+
previous.state === 'waiting' &&
|
|
654
|
+
previous.reason === state.reason) ||
|
|
655
|
+
(state.state === 'follower' &&
|
|
656
|
+
previous.state === 'follower' &&
|
|
657
|
+
previous.epoch === state.epoch &&
|
|
658
|
+
previous.leaderClientId === state.leaderClientId))
|
|
659
|
+
) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
this.#state = state;
|
|
663
|
+
try {
|
|
664
|
+
this.#onStateChange(state);
|
|
665
|
+
} catch {
|
|
666
|
+
// A status listener must never break cross-tab coordination.
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
#setBlocked(): void {
|
|
671
|
+
this.#clearReachabilityTimers();
|
|
672
|
+
this.#setState({
|
|
673
|
+
state: 'blocked',
|
|
674
|
+
reason: 'leader-unreachable',
|
|
675
|
+
code: FOLLOWER_TIMEOUT_CODE,
|
|
676
|
+
retryable: true,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
#armUnboundDeadline(): void {
|
|
681
|
+
this.#clearReachabilityTimers();
|
|
682
|
+
this.#blockedTimer = setTimeout(
|
|
683
|
+
() => this.#setBlocked(),
|
|
684
|
+
this.#callTimeoutMs,
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
#armBoundDeadline(): void {
|
|
689
|
+
this.#clearReachabilityTimers();
|
|
690
|
+
this.#waitingTimer = setTimeout(
|
|
691
|
+
() => {
|
|
692
|
+
this.#setState({ state: 'waiting', reason: 'leader-announcement' });
|
|
693
|
+
},
|
|
694
|
+
Math.max(1, Math.floor((this.#callTimeoutMs * 2) / 3)),
|
|
695
|
+
);
|
|
696
|
+
this.#blockedTimer = setTimeout(
|
|
697
|
+
() => this.#setBlocked(),
|
|
698
|
+
this.#callTimeoutMs,
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
#clearReachabilityTimers(): void {
|
|
703
|
+
if (this.#waitingTimer !== undefined) clearTimeout(this.#waitingTimer);
|
|
704
|
+
if (this.#blockedTimer !== undefined) clearTimeout(this.#blockedTimer);
|
|
705
|
+
this.#waitingTimer = undefined;
|
|
706
|
+
this.#blockedTimer = undefined;
|
|
707
|
+
}
|
|
550
708
|
}
|
package/src/reactive-store.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
classifySyncAvailability,
|
|
3
|
+
type SyncAvailability,
|
|
4
|
+
} from './availability';
|
|
1
5
|
import type {
|
|
2
6
|
CommitOutcome,
|
|
3
7
|
QueryReadSpec,
|
|
@@ -11,6 +15,7 @@ import type {
|
|
|
11
15
|
ClientChangeListener,
|
|
12
16
|
SyncStatusSnapshot,
|
|
13
17
|
} from './invalidation';
|
|
18
|
+
import type { LeadershipState } from './multi-tab';
|
|
14
19
|
import { type WindowBase, windowBaseKey } from './window';
|
|
15
20
|
|
|
16
21
|
export interface QueryDependency {
|
|
@@ -24,11 +29,17 @@ export interface ReactiveQuerySpec<Row> {
|
|
|
24
29
|
readonly params?: readonly SqlValue[];
|
|
25
30
|
readonly dependencies: readonly QueryDependency[];
|
|
26
31
|
readonly coverage?: readonly WindowCoverage[];
|
|
32
|
+
readonly mapRow?: (row: Readonly<Record<string, SqlValue>>) => Row;
|
|
27
33
|
readonly rowKey?: (row: Row) => readonly SqlValue[];
|
|
28
34
|
readonly claimCoverage?: boolean;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
|
-
export type LiveQueryPhase =
|
|
37
|
+
export type LiveQueryPhase =
|
|
38
|
+
| 'loading'
|
|
39
|
+
| 'partial'
|
|
40
|
+
| 'ready'
|
|
41
|
+
| 'blocked'
|
|
42
|
+
| 'error';
|
|
32
43
|
|
|
33
44
|
export interface LiveQueryResult<Row> {
|
|
34
45
|
readonly rows: readonly Row[];
|
|
@@ -36,14 +47,18 @@ export interface LiveQueryResult<Row> {
|
|
|
36
47
|
readonly revision: bigint | undefined;
|
|
37
48
|
readonly error: Error | undefined;
|
|
38
49
|
readonly isRefreshing: boolean;
|
|
50
|
+
readonly availability: SyncAvailability;
|
|
39
51
|
}
|
|
40
52
|
|
|
41
53
|
export interface ReactiveQueryClient {
|
|
54
|
+
readonly currentSchemaVersion?: number;
|
|
42
55
|
onChange(listener: ClientChangeListener): () => void;
|
|
43
56
|
querySnapshot<Row = Record<string, SqlValue>>(
|
|
44
57
|
spec: QueryReadSpec,
|
|
45
58
|
): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
|
|
46
59
|
statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
|
|
60
|
+
leadershipSnapshot?(): LeadershipState | undefined;
|
|
61
|
+
onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
|
|
47
62
|
readonly conflicts:
|
|
48
63
|
| readonly unknown[]
|
|
49
64
|
| (() => readonly unknown[] | Promise<readonly unknown[]>);
|
|
@@ -72,6 +87,7 @@ export interface WindowRetention {
|
|
|
72
87
|
|
|
73
88
|
export interface StatusStoreSnapshot {
|
|
74
89
|
readonly status: SyncStatusSnapshot | undefined;
|
|
90
|
+
readonly leadership: LeadershipState | undefined;
|
|
75
91
|
readonly error: Error | undefined;
|
|
76
92
|
readonly isLoading: boolean;
|
|
77
93
|
}
|
|
@@ -162,6 +178,25 @@ export function canonicalValue(value: unknown): string {
|
|
|
162
178
|
return encodeCanonical(value, new Set());
|
|
163
179
|
}
|
|
164
180
|
|
|
181
|
+
const reactiveFunctionIds = new WeakMap<
|
|
182
|
+
(...args: never[]) => unknown,
|
|
183
|
+
number
|
|
184
|
+
>();
|
|
185
|
+
let nextReactiveFunctionId = 1;
|
|
186
|
+
|
|
187
|
+
function reactiveFunctionId(
|
|
188
|
+
fn: ((...args: never[]) => unknown) | undefined,
|
|
189
|
+
): number | undefined {
|
|
190
|
+
if (fn === undefined) return undefined;
|
|
191
|
+
let id = reactiveFunctionIds.get(fn);
|
|
192
|
+
if (id === undefined) {
|
|
193
|
+
id = nextReactiveFunctionId;
|
|
194
|
+
nextReactiveFunctionId += 1;
|
|
195
|
+
reactiveFunctionIds.set(fn, id);
|
|
196
|
+
}
|
|
197
|
+
return id;
|
|
198
|
+
}
|
|
199
|
+
|
|
165
200
|
function scheduleMicrotask(task: () => void): void {
|
|
166
201
|
if (typeof queueMicrotask === 'function') queueMicrotask(task);
|
|
167
202
|
else void Promise.resolve().then(task);
|
|
@@ -277,6 +312,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
277
312
|
revision: undefined,
|
|
278
313
|
error: undefined,
|
|
279
314
|
isRefreshing: false,
|
|
315
|
+
availability: { state: 'ready' },
|
|
280
316
|
};
|
|
281
317
|
#subscribers = 0;
|
|
282
318
|
#scheduled = false;
|
|
@@ -284,6 +320,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
284
320
|
#requested = false;
|
|
285
321
|
#desiredRevision = 0n;
|
|
286
322
|
#claimReady: Promise<void> = Promise.resolve();
|
|
323
|
+
#offStatus: (() => void) | undefined;
|
|
287
324
|
|
|
288
325
|
constructor(
|
|
289
326
|
readonly store: ReactiveClientStore,
|
|
@@ -296,6 +333,10 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
296
333
|
this.#listeners.add(listener);
|
|
297
334
|
this.#subscribers += 1;
|
|
298
335
|
if (this.#subscribers === 1) {
|
|
336
|
+
this.#offStatus = this.store.status.subscribe(() =>
|
|
337
|
+
this.#onAvailabilityChange(),
|
|
338
|
+
);
|
|
339
|
+
this.#onAvailabilityChange();
|
|
299
340
|
if (this.spec.claimCoverage !== false) {
|
|
300
341
|
const claims: Promise<void>[] = [];
|
|
301
342
|
for (const coverage of this.spec.coverage ?? []) {
|
|
@@ -314,7 +355,11 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
314
355
|
return () => {
|
|
315
356
|
if (!this.#listeners.delete(listener)) return;
|
|
316
357
|
this.#subscribers -= 1;
|
|
317
|
-
if (this.#subscribers === 0)
|
|
358
|
+
if (this.#subscribers === 0) {
|
|
359
|
+
this.#offStatus?.();
|
|
360
|
+
this.#offStatus = undefined;
|
|
361
|
+
this.store.releaseWindowClaims(this.#owner);
|
|
362
|
+
}
|
|
318
363
|
};
|
|
319
364
|
};
|
|
320
365
|
|
|
@@ -333,7 +378,9 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
333
378
|
next.rows === this.#state.rows &&
|
|
334
379
|
next.phase === this.#state.phase &&
|
|
335
380
|
next.error === this.#state.error &&
|
|
336
|
-
next.isRefreshing === this.#state.isRefreshing
|
|
381
|
+
next.isRefreshing === this.#state.isRefreshing &&
|
|
382
|
+
canonicalValue(next.availability) ===
|
|
383
|
+
canonicalValue(this.#state.availability)
|
|
337
384
|
) {
|
|
338
385
|
return;
|
|
339
386
|
}
|
|
@@ -358,14 +405,41 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
358
405
|
});
|
|
359
406
|
}
|
|
360
407
|
|
|
408
|
+
#onAvailabilityChange(): void {
|
|
409
|
+
const availability = this.store.availabilitySnapshot();
|
|
410
|
+
if (availability.state === 'blocked') {
|
|
411
|
+
this.#publish({
|
|
412
|
+
...this.#state,
|
|
413
|
+
phase: 'blocked',
|
|
414
|
+
availability,
|
|
415
|
+
isRefreshing: false,
|
|
416
|
+
});
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const wasBlocked = this.#state.phase === 'blocked';
|
|
420
|
+
this.#publish({
|
|
421
|
+
...this.#state,
|
|
422
|
+
phase: wasBlocked
|
|
423
|
+
? this.#state.rows.length > 0
|
|
424
|
+
? 'partial'
|
|
425
|
+
: 'loading'
|
|
426
|
+
: this.#state.phase,
|
|
427
|
+
availability,
|
|
428
|
+
});
|
|
429
|
+
if (wasBlocked) this.#requestRead();
|
|
430
|
+
}
|
|
431
|
+
|
|
361
432
|
async #readLoop(): Promise<void> {
|
|
362
433
|
if (this.#running || this.#subscribers === 0) return;
|
|
363
434
|
this.#running = true;
|
|
364
435
|
try {
|
|
365
436
|
do {
|
|
366
437
|
this.#requested = false;
|
|
438
|
+
if (this.store.availabilitySnapshot().state === 'blocked') break;
|
|
367
439
|
await this.#claimReady;
|
|
368
|
-
const snapshot = await this.store.client.querySnapshot<
|
|
440
|
+
const snapshot = await this.store.client.querySnapshot<
|
|
441
|
+
Record<string, SqlValue>
|
|
442
|
+
>({
|
|
369
443
|
sql: this.spec.sql,
|
|
370
444
|
...(this.spec.params !== undefined
|
|
371
445
|
? { params: this.spec.params }
|
|
@@ -374,13 +448,27 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
374
448
|
? { coverage: this.spec.coverage }
|
|
375
449
|
: {}),
|
|
376
450
|
});
|
|
451
|
+
const availability = this.store.availabilitySnapshot();
|
|
452
|
+
if (availability.state === 'blocked') {
|
|
453
|
+
this.#publish({
|
|
454
|
+
...this.#state,
|
|
455
|
+
phase: 'blocked',
|
|
456
|
+
availability,
|
|
457
|
+
isRefreshing: false,
|
|
458
|
+
});
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
377
461
|
if (snapshot.revision < this.#desiredRevision) {
|
|
378
462
|
this.#requested = true;
|
|
379
463
|
continue;
|
|
380
464
|
}
|
|
465
|
+
const mappedRows =
|
|
466
|
+
this.spec.mapRow === undefined
|
|
467
|
+
? (snapshot.rows as readonly Row[])
|
|
468
|
+
: snapshot.rows.map(this.spec.mapRow);
|
|
381
469
|
const rows = reconcileRows(
|
|
382
470
|
this.#state.rows,
|
|
383
|
-
|
|
471
|
+
mappedRows,
|
|
384
472
|
this.spec.rowKey,
|
|
385
473
|
);
|
|
386
474
|
const phase: LiveQueryPhase = snapshot.coverage.complete
|
|
@@ -394,6 +482,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
394
482
|
revision: snapshot.revision,
|
|
395
483
|
error: undefined,
|
|
396
484
|
isRefreshing: false,
|
|
485
|
+
availability,
|
|
397
486
|
});
|
|
398
487
|
} while (this.#requested && this.#subscribers > 0);
|
|
399
488
|
} catch (error) {
|
|
@@ -403,6 +492,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
403
492
|
phase: this.#state.revision === undefined ? 'error' : this.#state.phase,
|
|
404
493
|
error: wrapped,
|
|
405
494
|
isRefreshing: false,
|
|
495
|
+
availability: this.store.availabilitySnapshot(),
|
|
406
496
|
});
|
|
407
497
|
} finally {
|
|
408
498
|
this.#running = false;
|
|
@@ -505,22 +595,34 @@ export class ReactiveClientStore {
|
|
|
505
595
|
readonly #windows = new Map<string, WindowEntry>();
|
|
506
596
|
readonly #windowClaims = new Map<string, WindowClaimGroup>();
|
|
507
597
|
#offChange: (() => void) | undefined;
|
|
598
|
+
#offLeadership: (() => void) | undefined;
|
|
508
599
|
readonly status: ExternalStoreEntry<StatusStoreSnapshot>;
|
|
509
600
|
readonly conflicts: ExternalStoreEntry<ConflictStoreSnapshot>;
|
|
510
601
|
readonly outcomes: ExternalStoreEntry<OutcomeStoreSnapshot>;
|
|
511
602
|
|
|
512
603
|
constructor(readonly client: ReactiveQueryClient) {
|
|
513
604
|
const status = new ValueEntry<StatusStoreSnapshot>(
|
|
514
|
-
{
|
|
605
|
+
{
|
|
606
|
+
status: undefined,
|
|
607
|
+
leadership: client.leadershipSnapshot?.(),
|
|
608
|
+
error: undefined,
|
|
609
|
+
isLoading: true,
|
|
610
|
+
},
|
|
515
611
|
async () => {
|
|
516
612
|
try {
|
|
517
613
|
return {
|
|
518
614
|
status: await client.statusSnapshot(),
|
|
615
|
+
leadership: client.leadershipSnapshot?.(),
|
|
519
616
|
error: undefined,
|
|
520
617
|
isLoading: false,
|
|
521
618
|
};
|
|
522
619
|
} catch (error) {
|
|
523
|
-
return {
|
|
620
|
+
return {
|
|
621
|
+
status: undefined,
|
|
622
|
+
leadership: client.leadershipSnapshot?.(),
|
|
623
|
+
error: errorOf(error),
|
|
624
|
+
isLoading: false,
|
|
625
|
+
};
|
|
524
626
|
}
|
|
525
627
|
},
|
|
526
628
|
);
|
|
@@ -584,6 +686,9 @@ export class ReactiveClientStore {
|
|
|
584
686
|
baseKey: windowBaseKey(item.base),
|
|
585
687
|
units: [...new Set(item.units)].sort(),
|
|
586
688
|
}));
|
|
689
|
+
const mapRowId = reactiveFunctionId(
|
|
690
|
+
spec.mapRow as ((...args: never[]) => unknown) | undefined,
|
|
691
|
+
);
|
|
587
692
|
const key = canonicalValue({
|
|
588
693
|
id: spec.id,
|
|
589
694
|
sql: spec.sql,
|
|
@@ -591,6 +696,7 @@ export class ReactiveClientStore {
|
|
|
591
696
|
dependencies,
|
|
592
697
|
coverage,
|
|
593
698
|
claimCoverage: spec.claimCoverage !== false,
|
|
699
|
+
...(mapRowId === undefined ? {} : { mapRow: mapRowId }),
|
|
594
700
|
});
|
|
595
701
|
let entry = this.#queries.get(key) as QueryEntry<Row> | undefined;
|
|
596
702
|
if (entry === undefined) {
|
|
@@ -600,6 +706,21 @@ export class ReactiveClientStore {
|
|
|
600
706
|
return entry;
|
|
601
707
|
}
|
|
602
708
|
|
|
709
|
+
availabilitySnapshot(): SyncAvailability {
|
|
710
|
+
const snapshot = this.status.getSnapshot();
|
|
711
|
+
if (snapshot.status === undefined) {
|
|
712
|
+
return snapshot.leadership?.state === 'blocked'
|
|
713
|
+
? {
|
|
714
|
+
state: 'blocked',
|
|
715
|
+
reason: 'leader-unreachable',
|
|
716
|
+
currentSchemaVersion: this.client.currentSchemaVersion ?? 0,
|
|
717
|
+
retryable: true,
|
|
718
|
+
}
|
|
719
|
+
: { state: 'ready' };
|
|
720
|
+
}
|
|
721
|
+
return classifySyncAvailability(snapshot.status, snapshot.leadership);
|
|
722
|
+
}
|
|
723
|
+
|
|
603
724
|
/** Retain a composable window working set outside React. The returned
|
|
604
725
|
* handle exposes registration completion and releases only this owner. */
|
|
605
726
|
retainWindow(base: WindowBase, units: readonly string[]): WindowRetention {
|
|
@@ -701,6 +822,7 @@ export class ReactiveClientStore {
|
|
|
701
822
|
if (batch.status !== undefined) {
|
|
702
823
|
(this.status as ValueEntry<StatusStoreSnapshot>).set({
|
|
703
824
|
status: batch.status,
|
|
825
|
+
leadership: this.client.leadershipSnapshot?.(),
|
|
704
826
|
error: undefined,
|
|
705
827
|
isLoading: false,
|
|
706
828
|
});
|
|
@@ -710,11 +832,20 @@ export class ReactiveClientStore {
|
|
|
710
832
|
}
|
|
711
833
|
if (batch.outcomesChanged) this.outcomes.refresh();
|
|
712
834
|
});
|
|
835
|
+
this.#offLeadership = this.client.onLeadershipChange?.((leadership) => {
|
|
836
|
+
const previous = this.status.getSnapshot();
|
|
837
|
+
(this.status as ValueEntry<StatusStoreSnapshot>).set({
|
|
838
|
+
...previous,
|
|
839
|
+
leadership,
|
|
840
|
+
});
|
|
841
|
+
});
|
|
713
842
|
}
|
|
714
843
|
|
|
715
844
|
dispose(): void {
|
|
716
845
|
this.#offChange?.();
|
|
717
846
|
this.#offChange = undefined;
|
|
847
|
+
this.#offLeadership?.();
|
|
848
|
+
this.#offLeadership = undefined;
|
|
718
849
|
for (const group of this.#windowClaims.values()) {
|
|
719
850
|
void Promise.resolve(this.client.setWindow(group.base, []));
|
|
720
851
|
}
|