@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/dist/multi-tab.d.ts
CHANGED
|
@@ -5,6 +5,22 @@ export declare const FOLLOWER_TIMEOUT_CODE = "client.follower_timeout";
|
|
|
5
5
|
export declare const DEFAULT_FOLLOWER_CALL_TIMEOUT_MS = 10000;
|
|
6
6
|
/** Max follower calls queued across a handover before we fail loudly. */
|
|
7
7
|
export declare const DEFAULT_FOLLOWER_QUEUE_LIMIT = 256;
|
|
8
|
+
export type LeadershipState = {
|
|
9
|
+
readonly state: 'leader';
|
|
10
|
+
readonly clientId: string;
|
|
11
|
+
} | {
|
|
12
|
+
readonly state: 'follower';
|
|
13
|
+
readonly leaderClientId: string;
|
|
14
|
+
readonly epoch: number;
|
|
15
|
+
} | {
|
|
16
|
+
readonly state: 'waiting';
|
|
17
|
+
readonly reason: 'handover' | 'leader-announcement';
|
|
18
|
+
} | {
|
|
19
|
+
readonly state: 'blocked';
|
|
20
|
+
readonly reason: 'leader-unreachable';
|
|
21
|
+
readonly code: typeof FOLLOWER_TIMEOUT_CODE;
|
|
22
|
+
readonly retryable: true;
|
|
23
|
+
};
|
|
8
24
|
interface HelloMessage {
|
|
9
25
|
readonly t: 'hello';
|
|
10
26
|
readonly fromId: string;
|
|
@@ -16,6 +32,10 @@ interface ByeMessage {
|
|
|
16
32
|
readonly t: 'bye';
|
|
17
33
|
readonly fromId: string;
|
|
18
34
|
}
|
|
35
|
+
interface BoundMessage {
|
|
36
|
+
readonly t: 'bound';
|
|
37
|
+
readonly fromId: string;
|
|
38
|
+
}
|
|
19
39
|
interface ReqMessage {
|
|
20
40
|
readonly t: 'req';
|
|
21
41
|
readonly epoch: number;
|
|
@@ -46,7 +66,7 @@ interface EventMessage {
|
|
|
46
66
|
readonly epoch: number;
|
|
47
67
|
readonly event: SyncWorkerEvent;
|
|
48
68
|
}
|
|
49
|
-
export type MultiTabMessage = HelloMessage | ByeMessage | ReqMessage | AnnounceMessage | ResMessage | EventMessage;
|
|
69
|
+
export type MultiTabMessage = HelloMessage | ByeMessage | BoundMessage | ReqMessage | AnnounceMessage | ResMessage | EventMessage;
|
|
50
70
|
/**
|
|
51
71
|
* The tiny cross-tab channel surface we depend on — the DOM `BroadcastChannel`
|
|
52
72
|
* satisfies it. Injectable so bun tests can pair two instances by name.
|
|
@@ -80,6 +100,7 @@ export declare class LeaderBridge {
|
|
|
80
100
|
epoch: number;
|
|
81
101
|
clientId: string;
|
|
82
102
|
invoke: (method: string, args: readonly unknown[]) => Promise<unknown>;
|
|
103
|
+
heartbeatMs?: number;
|
|
83
104
|
});
|
|
84
105
|
/** Re-announce leadership (on promotion and on a follower `hello`). */
|
|
85
106
|
announce(): void;
|
|
@@ -101,12 +122,14 @@ export declare class FollowerLink {
|
|
|
101
122
|
fromId: string;
|
|
102
123
|
onEvent: (event: SyncWorkerEvent) => void;
|
|
103
124
|
onLeaderChange: (clientId: string) => void;
|
|
125
|
+
onStateChange?: (state: LeadershipState) => void;
|
|
104
126
|
callTimeoutMs?: number;
|
|
105
127
|
queueLimit?: number;
|
|
106
128
|
});
|
|
107
129
|
get epoch(): number;
|
|
108
130
|
get maxEpochSeen(): number;
|
|
109
131
|
get leaderClientId(): string;
|
|
132
|
+
get leadershipState(): LeadershipState;
|
|
110
133
|
/** Whether a leader is currently bound (an announce has been heard). */
|
|
111
134
|
get bound(): boolean;
|
|
112
135
|
/**
|
package/dist/multi-tab.js
CHANGED
|
@@ -73,12 +73,18 @@ export class LeaderBridge {
|
|
|
73
73
|
#clientId;
|
|
74
74
|
#invoke;
|
|
75
75
|
#onMessage;
|
|
76
|
+
#heartbeatMs;
|
|
77
|
+
#followers = new Set();
|
|
78
|
+
#heartbeat;
|
|
76
79
|
#closed = false;
|
|
77
80
|
constructor(options) {
|
|
78
81
|
this.#channel = options.channel;
|
|
79
82
|
this.#epoch = options.epoch;
|
|
80
83
|
this.#clientId = options.clientId;
|
|
81
84
|
this.#invoke = options.invoke;
|
|
85
|
+
this.#heartbeatMs =
|
|
86
|
+
options.heartbeatMs ??
|
|
87
|
+
Math.max(50, Math.floor(DEFAULT_FOLLOWER_CALL_TIMEOUT_MS / 3));
|
|
82
88
|
this.#onMessage = (event) => this.#handle(event.data);
|
|
83
89
|
this.#channel.addEventListener('message', this.#onMessage);
|
|
84
90
|
this.announce();
|
|
@@ -104,9 +110,22 @@ export class LeaderBridge {
|
|
|
104
110
|
return;
|
|
105
111
|
if (message.t === 'hello') {
|
|
106
112
|
// A follower joined (or is contesting) — tell it who leads.
|
|
113
|
+
this.#trackFollower(message.fromId);
|
|
107
114
|
this.announce();
|
|
108
115
|
return;
|
|
109
116
|
}
|
|
117
|
+
if (message.t === 'bound') {
|
|
118
|
+
this.#trackFollower(message.fromId);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (message.t === 'bye') {
|
|
122
|
+
this.#followers.delete(message.fromId);
|
|
123
|
+
if (this.#followers.size === 0 && this.#heartbeat !== undefined) {
|
|
124
|
+
clearInterval(this.#heartbeat);
|
|
125
|
+
this.#heartbeat = undefined;
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
110
129
|
if (message.t !== 'req')
|
|
111
130
|
return;
|
|
112
131
|
// Ignore requests stamped for a different (dead/older) leader; that
|
|
@@ -143,11 +162,21 @@ export class LeaderBridge {
|
|
|
143
162
|
});
|
|
144
163
|
});
|
|
145
164
|
}
|
|
165
|
+
#trackFollower(fromId) {
|
|
166
|
+
this.#followers.add(fromId);
|
|
167
|
+
if (this.#heartbeat !== undefined)
|
|
168
|
+
return;
|
|
169
|
+
this.#heartbeat = setInterval(() => this.announce(), this.#heartbeatMs);
|
|
170
|
+
}
|
|
146
171
|
close() {
|
|
147
172
|
if (this.#closed)
|
|
148
173
|
return;
|
|
149
174
|
this.#closed = true;
|
|
175
|
+
if (this.#heartbeat !== undefined)
|
|
176
|
+
clearInterval(this.#heartbeat);
|
|
177
|
+
this.#heartbeat = undefined;
|
|
150
178
|
this.#channel.removeEventListener('message', this.#onMessage);
|
|
179
|
+
this.#channel.close();
|
|
151
180
|
}
|
|
152
181
|
}
|
|
153
182
|
/**
|
|
@@ -162,6 +191,7 @@ export class FollowerLink {
|
|
|
162
191
|
#fromId;
|
|
163
192
|
#onEvent;
|
|
164
193
|
#onLeaderChange;
|
|
194
|
+
#onStateChange;
|
|
165
195
|
#callTimeoutMs;
|
|
166
196
|
#queueLimit;
|
|
167
197
|
#onMessage;
|
|
@@ -174,6 +204,12 @@ export class FollowerLink {
|
|
|
174
204
|
#inFlight = new Map();
|
|
175
205
|
#queue = [];
|
|
176
206
|
#closed = false;
|
|
207
|
+
#state = {
|
|
208
|
+
state: 'waiting',
|
|
209
|
+
reason: 'leader-announcement',
|
|
210
|
+
};
|
|
211
|
+
#waitingTimer;
|
|
212
|
+
#blockedTimer;
|
|
177
213
|
/** Resolvers waiting for the first `announce` to bind a leader. */
|
|
178
214
|
#bindWaiters = [];
|
|
179
215
|
constructor(options) {
|
|
@@ -181,6 +217,7 @@ export class FollowerLink {
|
|
|
181
217
|
this.#fromId = options.fromId;
|
|
182
218
|
this.#onEvent = options.onEvent;
|
|
183
219
|
this.#onLeaderChange = options.onLeaderChange;
|
|
220
|
+
this.#onStateChange = options.onStateChange ?? (() => { });
|
|
184
221
|
this.#callTimeoutMs =
|
|
185
222
|
options.callTimeoutMs ?? DEFAULT_FOLLOWER_CALL_TIMEOUT_MS;
|
|
186
223
|
this.#queueLimit = options.queueLimit ?? DEFAULT_FOLLOWER_QUEUE_LIMIT;
|
|
@@ -188,6 +225,7 @@ export class FollowerLink {
|
|
|
188
225
|
this.#channel.addEventListener('message', this.#onMessage);
|
|
189
226
|
// Ask the current leader to announce itself.
|
|
190
227
|
this.#channel.postMessage({ t: 'hello', fromId: this.#fromId });
|
|
228
|
+
this.#armUnboundDeadline();
|
|
191
229
|
}
|
|
192
230
|
get epoch() {
|
|
193
231
|
return this.#epoch;
|
|
@@ -198,6 +236,9 @@ export class FollowerLink {
|
|
|
198
236
|
get leaderClientId() {
|
|
199
237
|
return this.#leaderClientId;
|
|
200
238
|
}
|
|
239
|
+
get leadershipState() {
|
|
240
|
+
return this.#state;
|
|
241
|
+
}
|
|
201
242
|
/** Whether a leader is currently bound (an announce has been heard). */
|
|
202
243
|
get bound() {
|
|
203
244
|
return this.#epoch >= 0;
|
|
@@ -221,7 +262,8 @@ export class FollowerLink {
|
|
|
221
262
|
return new Promise((resolve, reject) => {
|
|
222
263
|
const timer = setTimeout(() => {
|
|
223
264
|
this.#dropBindWaiter(settle);
|
|
224
|
-
|
|
265
|
+
this.#setBlocked();
|
|
266
|
+
reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'no leader announced within the follower bind timeout', true));
|
|
225
267
|
}, timeoutMs);
|
|
226
268
|
const settle = () => {
|
|
227
269
|
clearTimeout(timer);
|
|
@@ -248,6 +290,9 @@ export class FollowerLink {
|
|
|
248
290
|
if (this.#closed) {
|
|
249
291
|
return Promise.reject(new ClientSyncError(WORKER_FAILED_CODE, 'the follower link is closed'));
|
|
250
292
|
}
|
|
293
|
+
if (this.#state.state === 'blocked') {
|
|
294
|
+
return Promise.reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'the follower cannot reach the tab that owns the database', true));
|
|
295
|
+
}
|
|
251
296
|
return new Promise((resolve, reject) => {
|
|
252
297
|
const queued = {
|
|
253
298
|
method,
|
|
@@ -259,12 +304,14 @@ export class FollowerLink {
|
|
|
259
304
|
if (this.#epoch < 0) {
|
|
260
305
|
// No leader bound yet — queue with a deadline so we never hang.
|
|
261
306
|
if (this.#queue.length >= this.#queueLimit) {
|
|
262
|
-
|
|
307
|
+
this.#setBlocked();
|
|
308
|
+
reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'follower call queue overflow while awaiting a leader', true));
|
|
263
309
|
return;
|
|
264
310
|
}
|
|
265
311
|
queued.timer = setTimeout(() => {
|
|
266
312
|
this.#dropQueued(queued);
|
|
267
|
-
|
|
313
|
+
this.#setBlocked();
|
|
314
|
+
reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'no leader answered within the follower call timeout', true));
|
|
268
315
|
}, this.#callTimeoutMs);
|
|
269
316
|
this.#queue.push(queued);
|
|
270
317
|
return;
|
|
@@ -279,7 +326,8 @@ export class FollowerLink {
|
|
|
279
326
|
reject: queued.reject,
|
|
280
327
|
timer: setTimeout(() => {
|
|
281
328
|
this.#inFlight.delete(reqId);
|
|
282
|
-
|
|
329
|
+
this.#setBlocked();
|
|
330
|
+
queued.reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'the leader did not answer within the follower call timeout', true));
|
|
283
331
|
}, this.#callTimeoutMs),
|
|
284
332
|
};
|
|
285
333
|
this.#inFlight.set(reqId, inflight);
|
|
@@ -313,6 +361,15 @@ export class FollowerLink {
|
|
|
313
361
|
this.#leaderClientId = message.clientId;
|
|
314
362
|
if (changed)
|
|
315
363
|
this.#onLeaderChange(message.clientId);
|
|
364
|
+
this.#setState({
|
|
365
|
+
state: 'follower',
|
|
366
|
+
leaderClientId: message.clientId,
|
|
367
|
+
epoch: message.epoch,
|
|
368
|
+
});
|
|
369
|
+
this.#armBoundDeadline();
|
|
370
|
+
if (changed) {
|
|
371
|
+
this.#channel.postMessage({ t: 'bound', fromId: this.#fromId });
|
|
372
|
+
}
|
|
316
373
|
this.#resolveBindWaiters();
|
|
317
374
|
this.#flushQueue();
|
|
318
375
|
return;
|
|
@@ -366,6 +423,8 @@ export class FollowerLink {
|
|
|
366
423
|
return;
|
|
367
424
|
this.#epoch = -1;
|
|
368
425
|
this.#leaderClientId = '';
|
|
426
|
+
this.#setState({ state: 'waiting', reason: 'handover' });
|
|
427
|
+
this.#armUnboundDeadline();
|
|
369
428
|
this.#channel.postMessage({
|
|
370
429
|
t: 'hello',
|
|
371
430
|
fromId: this.#fromId,
|
|
@@ -376,6 +435,7 @@ export class FollowerLink {
|
|
|
376
435
|
if (this.#closed)
|
|
377
436
|
return;
|
|
378
437
|
this.#closed = true;
|
|
438
|
+
this.#clearReachabilityTimers();
|
|
379
439
|
this.#channel.removeEventListener('message', this.#onMessage);
|
|
380
440
|
this.#channel.postMessage({ t: 'bye', fromId: this.#fromId });
|
|
381
441
|
const closedError = new ClientSyncError(WORKER_FAILED_CODE, 'the follower link was closed');
|
|
@@ -396,4 +456,53 @@ export class FollowerLink {
|
|
|
396
456
|
this.#resolveBindWaiters();
|
|
397
457
|
this.#channel.close();
|
|
398
458
|
}
|
|
459
|
+
#setState(state) {
|
|
460
|
+
const previous = this.#state;
|
|
461
|
+
if (previous.state === state.state &&
|
|
462
|
+
(state.state === 'blocked' ||
|
|
463
|
+
(state.state === 'waiting' &&
|
|
464
|
+
previous.state === 'waiting' &&
|
|
465
|
+
previous.reason === state.reason) ||
|
|
466
|
+
(state.state === 'follower' &&
|
|
467
|
+
previous.state === 'follower' &&
|
|
468
|
+
previous.epoch === state.epoch &&
|
|
469
|
+
previous.leaderClientId === state.leaderClientId))) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
this.#state = state;
|
|
473
|
+
try {
|
|
474
|
+
this.#onStateChange(state);
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
// A status listener must never break cross-tab coordination.
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
#setBlocked() {
|
|
481
|
+
this.#clearReachabilityTimers();
|
|
482
|
+
this.#setState({
|
|
483
|
+
state: 'blocked',
|
|
484
|
+
reason: 'leader-unreachable',
|
|
485
|
+
code: FOLLOWER_TIMEOUT_CODE,
|
|
486
|
+
retryable: true,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
#armUnboundDeadline() {
|
|
490
|
+
this.#clearReachabilityTimers();
|
|
491
|
+
this.#blockedTimer = setTimeout(() => this.#setBlocked(), this.#callTimeoutMs);
|
|
492
|
+
}
|
|
493
|
+
#armBoundDeadline() {
|
|
494
|
+
this.#clearReachabilityTimers();
|
|
495
|
+
this.#waitingTimer = setTimeout(() => {
|
|
496
|
+
this.#setState({ state: 'waiting', reason: 'leader-announcement' });
|
|
497
|
+
}, Math.max(1, Math.floor((this.#callTimeoutMs * 2) / 3)));
|
|
498
|
+
this.#blockedTimer = setTimeout(() => this.#setBlocked(), this.#callTimeoutMs);
|
|
499
|
+
}
|
|
500
|
+
#clearReachabilityTimers() {
|
|
501
|
+
if (this.#waitingTimer !== undefined)
|
|
502
|
+
clearTimeout(this.#waitingTimer);
|
|
503
|
+
if (this.#blockedTimer !== undefined)
|
|
504
|
+
clearTimeout(this.#blockedTimer);
|
|
505
|
+
this.#waitingTimer = undefined;
|
|
506
|
+
this.#blockedTimer = undefined;
|
|
507
|
+
}
|
|
399
508
|
}
|
package/dist/reactive-store.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { type SyncAvailability } from './availability.js';
|
|
1
2
|
import type { CommitOutcome, QueryReadSpec, QuerySnapshot, WindowCoverage, WindowState } from './client.js';
|
|
2
3
|
import type { SqlValue } from './database.js';
|
|
3
4
|
import type { ClientChangeListener, SyncStatusSnapshot } from './invalidation.js';
|
|
5
|
+
import type { LeadershipState } from './multi-tab.js';
|
|
4
6
|
import { type WindowBase } from './window.js';
|
|
5
7
|
export interface QueryDependency {
|
|
6
8
|
readonly table: string;
|
|
@@ -12,21 +14,26 @@ export interface ReactiveQuerySpec<Row> {
|
|
|
12
14
|
readonly params?: readonly SqlValue[];
|
|
13
15
|
readonly dependencies: readonly QueryDependency[];
|
|
14
16
|
readonly coverage?: readonly WindowCoverage[];
|
|
17
|
+
readonly mapRow?: (row: Readonly<Record<string, SqlValue>>) => Row;
|
|
15
18
|
readonly rowKey?: (row: Row) => readonly SqlValue[];
|
|
16
19
|
readonly claimCoverage?: boolean;
|
|
17
20
|
}
|
|
18
|
-
export type LiveQueryPhase = 'loading' | 'partial' | 'ready' | 'error';
|
|
21
|
+
export type LiveQueryPhase = 'loading' | 'partial' | 'ready' | 'blocked' | 'error';
|
|
19
22
|
export interface LiveQueryResult<Row> {
|
|
20
23
|
readonly rows: readonly Row[];
|
|
21
24
|
readonly phase: LiveQueryPhase;
|
|
22
25
|
readonly revision: bigint | undefined;
|
|
23
26
|
readonly error: Error | undefined;
|
|
24
27
|
readonly isRefreshing: boolean;
|
|
28
|
+
readonly availability: SyncAvailability;
|
|
25
29
|
}
|
|
26
30
|
export interface ReactiveQueryClient {
|
|
31
|
+
readonly currentSchemaVersion?: number;
|
|
27
32
|
onChange(listener: ClientChangeListener): () => void;
|
|
28
33
|
querySnapshot<Row = Record<string, SqlValue>>(spec: QueryReadSpec): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
|
|
29
34
|
statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
|
|
35
|
+
leadershipSnapshot?(): LeadershipState | undefined;
|
|
36
|
+
onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
|
|
30
37
|
readonly conflicts: readonly unknown[] | (() => readonly unknown[] | Promise<readonly unknown[]>);
|
|
31
38
|
readonly rejections: readonly unknown[] | (() => readonly unknown[] | Promise<readonly unknown[]>);
|
|
32
39
|
commitOutcomes(): readonly CommitOutcome[] | Promise<readonly CommitOutcome[]>;
|
|
@@ -46,6 +53,7 @@ export interface WindowRetention {
|
|
|
46
53
|
}
|
|
47
54
|
export interface StatusStoreSnapshot {
|
|
48
55
|
readonly status: SyncStatusSnapshot | undefined;
|
|
56
|
+
readonly leadership: LeadershipState | undefined;
|
|
49
57
|
readonly error: Error | undefined;
|
|
50
58
|
readonly isLoading: boolean;
|
|
51
59
|
}
|
|
@@ -70,6 +78,7 @@ export declare class ReactiveClientStore {
|
|
|
70
78
|
readonly outcomes: ExternalStoreEntry<OutcomeStoreSnapshot>;
|
|
71
79
|
constructor(client: ReactiveQueryClient);
|
|
72
80
|
query<Row>(spec: ReactiveQuerySpec<Row>): ExternalStoreEntry<LiveQueryResult<Row>>;
|
|
81
|
+
availabilitySnapshot(): SyncAvailability;
|
|
73
82
|
/** Retain a composable window working set outside React. The returned
|
|
74
83
|
* handle exposes registration completion and releases only this owner. */
|
|
75
84
|
retainWindow(base: WindowBase, units: readonly string[]): WindowRetention;
|
package/dist/reactive-store.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classifySyncAvailability, } from './availability.js';
|
|
1
2
|
import { windowBaseKey } from './window.js';
|
|
2
3
|
function errorOf(value) {
|
|
3
4
|
return value instanceof Error ? value : new Error(String(value));
|
|
@@ -62,6 +63,19 @@ function encodeCanonical(value, stack) {
|
|
|
62
63
|
export function canonicalValue(value) {
|
|
63
64
|
return encodeCanonical(value, new Set());
|
|
64
65
|
}
|
|
66
|
+
const reactiveFunctionIds = new WeakMap();
|
|
67
|
+
let nextReactiveFunctionId = 1;
|
|
68
|
+
function reactiveFunctionId(fn) {
|
|
69
|
+
if (fn === undefined)
|
|
70
|
+
return undefined;
|
|
71
|
+
let id = reactiveFunctionIds.get(fn);
|
|
72
|
+
if (id === undefined) {
|
|
73
|
+
id = nextReactiveFunctionId;
|
|
74
|
+
nextReactiveFunctionId += 1;
|
|
75
|
+
reactiveFunctionIds.set(fn, id);
|
|
76
|
+
}
|
|
77
|
+
return id;
|
|
78
|
+
}
|
|
65
79
|
function scheduleMicrotask(task) {
|
|
66
80
|
if (typeof queueMicrotask === 'function')
|
|
67
81
|
queueMicrotask(task);
|
|
@@ -175,6 +189,7 @@ class QueryEntry {
|
|
|
175
189
|
revision: undefined,
|
|
176
190
|
error: undefined,
|
|
177
191
|
isRefreshing: false,
|
|
192
|
+
availability: { state: 'ready' },
|
|
178
193
|
};
|
|
179
194
|
#subscribers = 0;
|
|
180
195
|
#scheduled = false;
|
|
@@ -182,6 +197,7 @@ class QueryEntry {
|
|
|
182
197
|
#requested = false;
|
|
183
198
|
#desiredRevision = 0n;
|
|
184
199
|
#claimReady = Promise.resolve();
|
|
200
|
+
#offStatus;
|
|
185
201
|
constructor(store, spec) {
|
|
186
202
|
this.store = store;
|
|
187
203
|
this.spec = spec;
|
|
@@ -191,6 +207,8 @@ class QueryEntry {
|
|
|
191
207
|
this.#listeners.add(listener);
|
|
192
208
|
this.#subscribers += 1;
|
|
193
209
|
if (this.#subscribers === 1) {
|
|
210
|
+
this.#offStatus = this.store.status.subscribe(() => this.#onAvailabilityChange());
|
|
211
|
+
this.#onAvailabilityChange();
|
|
194
212
|
if (this.spec.claimCoverage !== false) {
|
|
195
213
|
const claims = [];
|
|
196
214
|
for (const coverage of this.spec.coverage ?? []) {
|
|
@@ -204,8 +222,11 @@ class QueryEntry {
|
|
|
204
222
|
if (!this.#listeners.delete(listener))
|
|
205
223
|
return;
|
|
206
224
|
this.#subscribers -= 1;
|
|
207
|
-
if (this.#subscribers === 0)
|
|
225
|
+
if (this.#subscribers === 0) {
|
|
226
|
+
this.#offStatus?.();
|
|
227
|
+
this.#offStatus = undefined;
|
|
208
228
|
this.store.releaseWindowClaims(this.#owner);
|
|
229
|
+
}
|
|
209
230
|
};
|
|
210
231
|
};
|
|
211
232
|
refresh = () => this.#requestRead(true);
|
|
@@ -222,7 +243,9 @@ class QueryEntry {
|
|
|
222
243
|
if (next.rows === this.#state.rows &&
|
|
223
244
|
next.phase === this.#state.phase &&
|
|
224
245
|
next.error === this.#state.error &&
|
|
225
|
-
next.isRefreshing === this.#state.isRefreshing
|
|
246
|
+
next.isRefreshing === this.#state.isRefreshing &&
|
|
247
|
+
canonicalValue(next.availability) ===
|
|
248
|
+
canonicalValue(this.#state.availability)) {
|
|
226
249
|
return;
|
|
227
250
|
}
|
|
228
251
|
this.#state = next;
|
|
@@ -244,6 +267,30 @@ class QueryEntry {
|
|
|
244
267
|
void this.#readLoop();
|
|
245
268
|
});
|
|
246
269
|
}
|
|
270
|
+
#onAvailabilityChange() {
|
|
271
|
+
const availability = this.store.availabilitySnapshot();
|
|
272
|
+
if (availability.state === 'blocked') {
|
|
273
|
+
this.#publish({
|
|
274
|
+
...this.#state,
|
|
275
|
+
phase: 'blocked',
|
|
276
|
+
availability,
|
|
277
|
+
isRefreshing: false,
|
|
278
|
+
});
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const wasBlocked = this.#state.phase === 'blocked';
|
|
282
|
+
this.#publish({
|
|
283
|
+
...this.#state,
|
|
284
|
+
phase: wasBlocked
|
|
285
|
+
? this.#state.rows.length > 0
|
|
286
|
+
? 'partial'
|
|
287
|
+
: 'loading'
|
|
288
|
+
: this.#state.phase,
|
|
289
|
+
availability,
|
|
290
|
+
});
|
|
291
|
+
if (wasBlocked)
|
|
292
|
+
this.#requestRead();
|
|
293
|
+
}
|
|
247
294
|
async #readLoop() {
|
|
248
295
|
if (this.#running || this.#subscribers === 0)
|
|
249
296
|
return;
|
|
@@ -251,6 +298,8 @@ class QueryEntry {
|
|
|
251
298
|
try {
|
|
252
299
|
do {
|
|
253
300
|
this.#requested = false;
|
|
301
|
+
if (this.store.availabilitySnapshot().state === 'blocked')
|
|
302
|
+
break;
|
|
254
303
|
await this.#claimReady;
|
|
255
304
|
const snapshot = await this.store.client.querySnapshot({
|
|
256
305
|
sql: this.spec.sql,
|
|
@@ -261,11 +310,24 @@ class QueryEntry {
|
|
|
261
310
|
? { coverage: this.spec.coverage }
|
|
262
311
|
: {}),
|
|
263
312
|
});
|
|
313
|
+
const availability = this.store.availabilitySnapshot();
|
|
314
|
+
if (availability.state === 'blocked') {
|
|
315
|
+
this.#publish({
|
|
316
|
+
...this.#state,
|
|
317
|
+
phase: 'blocked',
|
|
318
|
+
availability,
|
|
319
|
+
isRefreshing: false,
|
|
320
|
+
});
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
264
323
|
if (snapshot.revision < this.#desiredRevision) {
|
|
265
324
|
this.#requested = true;
|
|
266
325
|
continue;
|
|
267
326
|
}
|
|
268
|
-
const
|
|
327
|
+
const mappedRows = this.spec.mapRow === undefined
|
|
328
|
+
? snapshot.rows
|
|
329
|
+
: snapshot.rows.map(this.spec.mapRow);
|
|
330
|
+
const rows = reconcileRows(this.#state.rows, mappedRows, this.spec.rowKey);
|
|
269
331
|
const phase = snapshot.coverage.complete
|
|
270
332
|
? 'ready'
|
|
271
333
|
: rows.length > 0
|
|
@@ -277,6 +339,7 @@ class QueryEntry {
|
|
|
277
339
|
revision: snapshot.revision,
|
|
278
340
|
error: undefined,
|
|
279
341
|
isRefreshing: false,
|
|
342
|
+
availability,
|
|
280
343
|
});
|
|
281
344
|
} while (this.#requested && this.#subscribers > 0);
|
|
282
345
|
}
|
|
@@ -287,6 +350,7 @@ class QueryEntry {
|
|
|
287
350
|
phase: this.#state.revision === undefined ? 'error' : this.#state.phase,
|
|
288
351
|
error: wrapped,
|
|
289
352
|
isRefreshing: false,
|
|
353
|
+
availability: this.store.availabilitySnapshot(),
|
|
290
354
|
});
|
|
291
355
|
}
|
|
292
356
|
finally {
|
|
@@ -383,21 +447,33 @@ export class ReactiveClientStore {
|
|
|
383
447
|
#windows = new Map();
|
|
384
448
|
#windowClaims = new Map();
|
|
385
449
|
#offChange;
|
|
450
|
+
#offLeadership;
|
|
386
451
|
status;
|
|
387
452
|
conflicts;
|
|
388
453
|
outcomes;
|
|
389
454
|
constructor(client) {
|
|
390
455
|
this.client = client;
|
|
391
|
-
const status = new ValueEntry({
|
|
456
|
+
const status = new ValueEntry({
|
|
457
|
+
status: undefined,
|
|
458
|
+
leadership: client.leadershipSnapshot?.(),
|
|
459
|
+
error: undefined,
|
|
460
|
+
isLoading: true,
|
|
461
|
+
}, async () => {
|
|
392
462
|
try {
|
|
393
463
|
return {
|
|
394
464
|
status: await client.statusSnapshot(),
|
|
465
|
+
leadership: client.leadershipSnapshot?.(),
|
|
395
466
|
error: undefined,
|
|
396
467
|
isLoading: false,
|
|
397
468
|
};
|
|
398
469
|
}
|
|
399
470
|
catch (error) {
|
|
400
|
-
return {
|
|
471
|
+
return {
|
|
472
|
+
status: undefined,
|
|
473
|
+
leadership: client.leadershipSnapshot?.(),
|
|
474
|
+
error: errorOf(error),
|
|
475
|
+
isLoading: false,
|
|
476
|
+
};
|
|
401
477
|
}
|
|
402
478
|
});
|
|
403
479
|
const conflicts = new ValueEntry({ conflicts: [], rejections: [], error: undefined, isLoading: true }, async () => {
|
|
@@ -453,6 +529,7 @@ export class ReactiveClientStore {
|
|
|
453
529
|
baseKey: windowBaseKey(item.base),
|
|
454
530
|
units: [...new Set(item.units)].sort(),
|
|
455
531
|
}));
|
|
532
|
+
const mapRowId = reactiveFunctionId(spec.mapRow);
|
|
456
533
|
const key = canonicalValue({
|
|
457
534
|
id: spec.id,
|
|
458
535
|
sql: spec.sql,
|
|
@@ -460,6 +537,7 @@ export class ReactiveClientStore {
|
|
|
460
537
|
dependencies,
|
|
461
538
|
coverage,
|
|
462
539
|
claimCoverage: spec.claimCoverage !== false,
|
|
540
|
+
...(mapRowId === undefined ? {} : { mapRow: mapRowId }),
|
|
463
541
|
});
|
|
464
542
|
let entry = this.#queries.get(key);
|
|
465
543
|
if (entry === undefined) {
|
|
@@ -468,6 +546,20 @@ export class ReactiveClientStore {
|
|
|
468
546
|
}
|
|
469
547
|
return entry;
|
|
470
548
|
}
|
|
549
|
+
availabilitySnapshot() {
|
|
550
|
+
const snapshot = this.status.getSnapshot();
|
|
551
|
+
if (snapshot.status === undefined) {
|
|
552
|
+
return snapshot.leadership?.state === 'blocked'
|
|
553
|
+
? {
|
|
554
|
+
state: 'blocked',
|
|
555
|
+
reason: 'leader-unreachable',
|
|
556
|
+
currentSchemaVersion: this.client.currentSchemaVersion ?? 0,
|
|
557
|
+
retryable: true,
|
|
558
|
+
}
|
|
559
|
+
: { state: 'ready' };
|
|
560
|
+
}
|
|
561
|
+
return classifySyncAvailability(snapshot.status, snapshot.leadership);
|
|
562
|
+
}
|
|
471
563
|
/** Retain a composable window working set outside React. The returned
|
|
472
564
|
* handle exposes registration completion and releases only this owner. */
|
|
473
565
|
retainWindow(base, units) {
|
|
@@ -571,6 +663,7 @@ export class ReactiveClientStore {
|
|
|
571
663
|
if (batch.status !== undefined) {
|
|
572
664
|
this.status.set({
|
|
573
665
|
status: batch.status,
|
|
666
|
+
leadership: this.client.leadershipSnapshot?.(),
|
|
574
667
|
error: undefined,
|
|
575
668
|
isLoading: false,
|
|
576
669
|
});
|
|
@@ -581,10 +674,19 @@ export class ReactiveClientStore {
|
|
|
581
674
|
if (batch.outcomesChanged)
|
|
582
675
|
this.outcomes.refresh();
|
|
583
676
|
});
|
|
677
|
+
this.#offLeadership = this.client.onLeadershipChange?.((leadership) => {
|
|
678
|
+
const previous = this.status.getSnapshot();
|
|
679
|
+
this.status.set({
|
|
680
|
+
...previous,
|
|
681
|
+
leadership,
|
|
682
|
+
});
|
|
683
|
+
});
|
|
584
684
|
}
|
|
585
685
|
dispose() {
|
|
586
686
|
this.#offChange?.();
|
|
587
687
|
this.#offChange = undefined;
|
|
688
|
+
this.#offLeadership?.();
|
|
689
|
+
this.#offLeadership = undefined;
|
|
588
690
|
for (const group of this.#windowClaims.values()) {
|
|
589
691
|
void Promise.resolve(this.client.setWindow(group.base, []));
|
|
590
692
|
}
|
package/dist/worker-entry.js
CHANGED
|
@@ -88,8 +88,11 @@ export function startSyncWorker(overrides = {}) {
|
|
|
88
88
|
let backgroundDue = Number.POSITIVE_INFINITY;
|
|
89
89
|
function runAutoSync() {
|
|
90
90
|
autoSyncScheduled = false;
|
|
91
|
-
if (closed ||
|
|
91
|
+
if (closed ||
|
|
92
|
+
client === undefined ||
|
|
93
|
+
client.securityLifecycle === 'preflight') {
|
|
92
94
|
return;
|
|
95
|
+
}
|
|
93
96
|
const running = client;
|
|
94
97
|
void serializedSync(() => running.syncUntilIdle())
|
|
95
98
|
.then((summary) => {
|
|
@@ -106,7 +109,11 @@ export function startSyncWorker(overrides = {}) {
|
|
|
106
109
|
});
|
|
107
110
|
}
|
|
108
111
|
function consumeSyncIntent(intent) {
|
|
109
|
-
if (!autoSync ||
|
|
112
|
+
if (!autoSync ||
|
|
113
|
+
closed ||
|
|
114
|
+
client === undefined ||
|
|
115
|
+
client.securityLifecycle === 'preflight' ||
|
|
116
|
+
intent.kind === 'none') {
|
|
110
117
|
return;
|
|
111
118
|
}
|
|
112
119
|
if (intent.kind === 'background') {
|
|
@@ -220,6 +227,9 @@ export function startSyncWorker(overrides = {}) {
|
|
|
220
227
|
...(config.encryption !== undefined
|
|
221
228
|
? { encryption: encryptionConfigFromKeyring(config.encryption) }
|
|
222
229
|
: {}),
|
|
230
|
+
...(config.securityPreflight !== undefined
|
|
231
|
+
? { securityPreflight: config.securityPreflight }
|
|
232
|
+
: {}),
|
|
223
233
|
onSyncNeeded: (reason) => {
|
|
224
234
|
post({ t: 'event', event: { kind: 'sync-needed', reason } });
|
|
225
235
|
consumeSyncIntent({ kind: 'interactive' });
|
|
@@ -257,6 +267,22 @@ export function startSyncWorker(overrides = {}) {
|
|
|
257
267
|
return { clientId: started.clientId };
|
|
258
268
|
}
|
|
259
269
|
const api = {
|
|
270
|
+
securityLifecycle: () => requireClient().securityLifecycle,
|
|
271
|
+
beginSecurityPreflight: async () => {
|
|
272
|
+
if (backgroundTimer !== undefined)
|
|
273
|
+
clearTimeout(backgroundTimer);
|
|
274
|
+
backgroundTimer = undefined;
|
|
275
|
+
backgroundDue = Number.POSITIVE_INFINITY;
|
|
276
|
+
autoSyncScheduled = false;
|
|
277
|
+
await requireClient().beginSecurityPreflight();
|
|
278
|
+
},
|
|
279
|
+
activateSecurity: async (options = {}) => {
|
|
280
|
+
await requireClient().activateSecurity({
|
|
281
|
+
...(options.encryption !== undefined
|
|
282
|
+
? { encryption: encryptionConfigFromKeyring(options.encryption) }
|
|
283
|
+
: {}),
|
|
284
|
+
});
|
|
285
|
+
},
|
|
260
286
|
subscribe: (input) => requireClient().subscribe(input),
|
|
261
287
|
unsubscribe: (id) => requireClient().unsubscribe(id),
|
|
262
288
|
setWindow: async (base, units) => {
|