@syncular/client 0.15.13 → 0.15.14
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 +66 -2
- package/dist/availability.d.ts +17 -0
- package/dist/availability.js +48 -0
- package/dist/client.js +1 -0
- 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-host.d.ts +33 -1
- package/dist/worker-host.js +107 -7
- package/package.json +3 -3
- package/src/availability.ts +70 -0
- package/src/client.ts +1 -0
- 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-host.ts +148 -6
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-host.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ import type { EncryptionKeyringConfig } from './encryption.js';
|
|
|
29
29
|
import { ChangeEmitter, type ClientChangeListener, InvalidationEmitter, type InvalidationListener, type LocalRevision, type SyncStatusSnapshot } from './invalidation.js';
|
|
30
30
|
import { type LeaderLease, type LeaderLock } from './leader-lock.js';
|
|
31
31
|
import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge.js';
|
|
32
|
-
import { type CrossTabChannel, FollowerLink, LeaderBridge } from './multi-tab.js';
|
|
32
|
+
import { type CrossTabChannel, FollowerLink, LeaderBridge, type LeadershipState } from './multi-tab.js';
|
|
33
33
|
import type { OutboxCommit } from './outbox.js';
|
|
34
34
|
import type { CommitOutcome, CommitOutcomeQuery, ResolveCommitOutcomeInput } from './outcomes.js';
|
|
35
35
|
import type { ClientSchema } from './schema.js';
|
|
@@ -37,6 +37,25 @@ import type { SubscriptionRecord } from './state.js';
|
|
|
37
37
|
import type { WindowBase } from './window.js';
|
|
38
38
|
import { type SyncWorkerEvent, type WorkerDatabaseInit, type WorkerEndpoints, type WorkerErrorShape } from './worker-protocol.js';
|
|
39
39
|
export type HandleRole = 'leader' | 'follower';
|
|
40
|
+
export type BrowserReplicaMode = {
|
|
41
|
+
readonly mode: 'shared';
|
|
42
|
+
} | {
|
|
43
|
+
readonly mode: 'isolated';
|
|
44
|
+
readonly id: string;
|
|
45
|
+
};
|
|
46
|
+
export interface IsolatedReplicaNames {
|
|
47
|
+
readonly databaseName: string;
|
|
48
|
+
readonly databaseDirectory: string;
|
|
49
|
+
readonly lockName: string;
|
|
50
|
+
readonly channelName: string;
|
|
51
|
+
}
|
|
52
|
+
/** Derive the complete ownership tuple for an independently owned replica. */
|
|
53
|
+
export declare function isolatedReplicaNames(options: {
|
|
54
|
+
readonly databaseName: string;
|
|
55
|
+
readonly databaseDirectory?: string;
|
|
56
|
+
readonly lockName?: string;
|
|
57
|
+
readonly replicaId: string;
|
|
58
|
+
}): IsolatedReplicaNames;
|
|
40
59
|
export interface SyncClientHandleConfig {
|
|
41
60
|
/**
|
|
42
61
|
* Spawns the worker running `startSyncWorker()` (a factory so bundlers
|
|
@@ -57,6 +76,8 @@ export interface SyncClientHandleConfig {
|
|
|
57
76
|
/** Default: Web Locks when available, else single-owner. */
|
|
58
77
|
readonly leaderLock?: LeaderLock;
|
|
59
78
|
readonly lockName?: string;
|
|
79
|
+
/** Shared by default; isolated derives the database/lock/channel tuple. */
|
|
80
|
+
readonly replica?: BrowserReplicaMode;
|
|
60
81
|
/**
|
|
61
82
|
* Multi-tab followers (TODO 3.2). On by default: a tab that loses the
|
|
62
83
|
* leader election becomes a FOLLOWER that proxies to the leader over a
|
|
@@ -71,6 +92,8 @@ export interface SyncClientHandleConfig {
|
|
|
71
92
|
readonly followerCallTimeoutMs?: number;
|
|
72
93
|
/** Fires when this handle's role changes (follower → leader on promotion). */
|
|
73
94
|
readonly onRoleChange?: (role: HandleRole) => void;
|
|
95
|
+
/** Fires when reachability or ownership changes without replacing the handle. */
|
|
96
|
+
readonly onLeadershipChange?: (state: LeadershipState) => void;
|
|
74
97
|
readonly onSyncNeeded?: (reason: 'startup' | 'hello' | WakeReason) => void;
|
|
75
98
|
readonly onConflict?: (conflict: ConflictRecord) => void;
|
|
76
99
|
/** A worker-side autoSync round finished (or failed). */
|
|
@@ -107,19 +130,27 @@ export declare class SyncClientHandle {
|
|
|
107
130
|
get role(): HandleRole;
|
|
108
131
|
/** Resolved client id — the leader's; shared by all tabs on this origin. */
|
|
109
132
|
get clientId(): string;
|
|
133
|
+
get currentSchemaVersion(): number;
|
|
134
|
+
get leadership(): LeadershipState;
|
|
135
|
+
leadershipSnapshot(): LeadershipState;
|
|
110
136
|
/** @internal — use {@link createSyncClientHandle}. */
|
|
111
137
|
constructor(internals: {
|
|
112
138
|
role: HandleRole;
|
|
113
139
|
clientId: string;
|
|
140
|
+
currentSchemaVersion: number;
|
|
114
141
|
core?: LeaderCore;
|
|
115
142
|
follower?: FollowerLink;
|
|
116
143
|
invalidation: InvalidationEmitter;
|
|
117
144
|
changes: ChangeEmitter;
|
|
118
145
|
presence: Set<(scopeKey: string) => void>;
|
|
119
146
|
roleListeners?: Set<(role: HandleRole) => void>;
|
|
147
|
+
leadershipListeners?: Set<(state: LeadershipState) => void>;
|
|
148
|
+
leadership?: LeadershipState;
|
|
120
149
|
});
|
|
121
150
|
/** @internal — swap this handle from follower to leader (promotion). */
|
|
122
151
|
__becomeLeader(core: LeaderCore): void;
|
|
152
|
+
/** @internal — apply a follower reachability snapshot in place. */
|
|
153
|
+
__setLeadership(state: LeadershipState): void;
|
|
123
154
|
/** @internal — dispatch a worker/relayed event to handle-local listeners. */
|
|
124
155
|
__dispatchEvent(event: SyncWorkerEvent): void;
|
|
125
156
|
/**
|
|
@@ -137,6 +168,7 @@ export declare class SyncClientHandle {
|
|
|
137
168
|
onPresence(listener: (scopeKey: string) => void): () => void;
|
|
138
169
|
/** Subscribe to role transitions (follower → leader on promotion). */
|
|
139
170
|
onRoleChange(listener: (role: HandleRole) => void): () => void;
|
|
171
|
+
onLeadershipChange(listener: (state: LeadershipState) => void): () => void;
|
|
140
172
|
subscribe(input: SubscribeInput): Promise<void>;
|
|
141
173
|
unsubscribe(id: string): Promise<void>;
|
|
142
174
|
setWindow(base: WindowBase, units: readonly string[]): Promise<void>;
|
package/dist/worker-host.js
CHANGED
|
@@ -4,6 +4,21 @@ import { ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './i
|
|
|
4
4
|
import { singleOwnerLock, webLocksLeaderLock, } from './leader-lock.js';
|
|
5
5
|
import { broadcastChannelFactory, FollowerLink, LeaderBridge, multiTabChannelName, newTabId, } from './multi-tab.js';
|
|
6
6
|
import { NOT_LEADER_CODE, WORKER_FAILED_CODE, } from './worker-protocol.js';
|
|
7
|
+
/** Derive the complete ownership tuple for an independently owned replica. */
|
|
8
|
+
export function isolatedReplicaNames(options) {
|
|
9
|
+
if (!/^[A-Za-z0-9._-]+$/.test(options.replicaId)) {
|
|
10
|
+
throw new ClientSyncError('sync.invalid_request', 'an isolated replica id must contain only letters, numbers, dot, underscore, or dash');
|
|
11
|
+
}
|
|
12
|
+
const suffix = `--replica-${options.replicaId}`;
|
|
13
|
+
const databaseName = `${options.databaseName}${suffix}`;
|
|
14
|
+
const lockName = `${options.lockName ?? 'syncular-leader'}${suffix}`;
|
|
15
|
+
return {
|
|
16
|
+
databaseName,
|
|
17
|
+
databaseDirectory: `${options.databaseDirectory ?? `.syncular/${options.databaseName}`}${suffix}`,
|
|
18
|
+
lockName,
|
|
19
|
+
channelName: multiTabChannelName(lockName),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
7
22
|
function defaultLeaderLock() {
|
|
8
23
|
const nav = globalThis.navigator;
|
|
9
24
|
return nav?.locks !== undefined
|
|
@@ -27,26 +42,50 @@ export class SyncClientHandle {
|
|
|
27
42
|
get clientId() {
|
|
28
43
|
return this.#clientId;
|
|
29
44
|
}
|
|
45
|
+
get currentSchemaVersion() {
|
|
46
|
+
return this.#currentSchemaVersion;
|
|
47
|
+
}
|
|
48
|
+
get leadership() {
|
|
49
|
+
return this.#leadership;
|
|
50
|
+
}
|
|
51
|
+
leadershipSnapshot() {
|
|
52
|
+
return this.#leadership;
|
|
53
|
+
}
|
|
30
54
|
#role;
|
|
31
55
|
#clientId;
|
|
56
|
+
#currentSchemaVersion;
|
|
57
|
+
#leadership;
|
|
32
58
|
#core;
|
|
33
59
|
#follower;
|
|
34
60
|
#invalidation;
|
|
35
61
|
#changes;
|
|
36
62
|
#presence;
|
|
37
63
|
#roleListeners;
|
|
64
|
+
#leadershipListeners;
|
|
38
65
|
#devtoolsUnregister;
|
|
39
66
|
#closed = false;
|
|
40
67
|
/** @internal — use {@link createSyncClientHandle}. */
|
|
41
68
|
constructor(internals) {
|
|
42
69
|
this.#role = internals.role;
|
|
43
70
|
this.#clientId = internals.clientId;
|
|
71
|
+
this.#currentSchemaVersion = internals.currentSchemaVersion;
|
|
72
|
+
this.#leadership =
|
|
73
|
+
internals.leadership ??
|
|
74
|
+
(internals.role === 'leader'
|
|
75
|
+
? { state: 'leader', clientId: internals.clientId }
|
|
76
|
+
: (internals.follower?.leadershipState ?? {
|
|
77
|
+
state: 'blocked',
|
|
78
|
+
reason: 'leader-unreachable',
|
|
79
|
+
code: 'client.follower_timeout',
|
|
80
|
+
retryable: true,
|
|
81
|
+
}));
|
|
44
82
|
this.#core = internals.core;
|
|
45
83
|
this.#follower = internals.follower;
|
|
46
84
|
this.#invalidation = internals.invalidation;
|
|
47
85
|
this.#changes = internals.changes;
|
|
48
86
|
this.#presence = internals.presence;
|
|
49
87
|
this.#roleListeners = internals.roleListeners ?? new Set();
|
|
88
|
+
this.#leadershipListeners = internals.leadershipListeners ?? new Set();
|
|
50
89
|
// RFC 0002 §3.2: console introspection — a no-op outside a dev page.
|
|
51
90
|
this.#devtoolsUnregister = registerDevtools({
|
|
52
91
|
kind: 'handle',
|
|
@@ -69,6 +108,7 @@ export class SyncClientHandle {
|
|
|
69
108
|
this.#core = core;
|
|
70
109
|
this.#clientId = core.clientId;
|
|
71
110
|
this.#role = 'leader';
|
|
111
|
+
this.__setLeadership({ state: 'leader', clientId: core.clientId });
|
|
72
112
|
for (const listener of this.#roleListeners) {
|
|
73
113
|
try {
|
|
74
114
|
listener('leader');
|
|
@@ -78,6 +118,20 @@ export class SyncClientHandle {
|
|
|
78
118
|
}
|
|
79
119
|
}
|
|
80
120
|
}
|
|
121
|
+
/** @internal — apply a follower reachability snapshot in place. */
|
|
122
|
+
__setLeadership(state) {
|
|
123
|
+
this.#leadership = state;
|
|
124
|
+
if (state.state === 'follower')
|
|
125
|
+
this.#clientId = state.leaderClientId;
|
|
126
|
+
for (const listener of this.#leadershipListeners) {
|
|
127
|
+
try {
|
|
128
|
+
listener(state);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
/* a UI listener must never break leadership transitions */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
81
135
|
/** @internal — dispatch a worker/relayed event to handle-local listeners. */
|
|
82
136
|
__dispatchEvent(event) {
|
|
83
137
|
if (event.kind === 'presence') {
|
|
@@ -126,6 +180,12 @@ export class SyncClientHandle {
|
|
|
126
180
|
this.#roleListeners.delete(listener);
|
|
127
181
|
};
|
|
128
182
|
}
|
|
183
|
+
onLeadershipChange(listener) {
|
|
184
|
+
this.#leadershipListeners.add(listener);
|
|
185
|
+
return () => {
|
|
186
|
+
this.#leadershipListeners.delete(listener);
|
|
187
|
+
};
|
|
188
|
+
}
|
|
129
189
|
#call(method, args) {
|
|
130
190
|
if (this.#closed) {
|
|
131
191
|
return Promise.reject(new ClientSyncError(WORKER_FAILED_CODE, 'the handle is closed'));
|
|
@@ -407,14 +467,19 @@ function fireConfigCallbacks(config, event) {
|
|
|
407
467
|
* `multiTab: false`: a losing tab resolves to a dead not-leader handle.
|
|
408
468
|
*/
|
|
409
469
|
export async function createSyncClientHandle(config) {
|
|
410
|
-
const
|
|
411
|
-
const
|
|
470
|
+
const resolvedConfig = resolveReplicaConfig(config);
|
|
471
|
+
const lock = resolvedConfig.leaderLock ?? defaultLeaderLock();
|
|
472
|
+
const lockName = resolvedConfig.lockName ?? 'syncular-leader';
|
|
412
473
|
const invalidation = new InvalidationEmitter();
|
|
413
474
|
const changes = new ChangeEmitter();
|
|
414
475
|
const presence = new Set();
|
|
415
476
|
const roleListeners = new Set();
|
|
416
|
-
if (
|
|
417
|
-
roleListeners.add(
|
|
477
|
+
if (resolvedConfig.onRoleChange !== undefined)
|
|
478
|
+
roleListeners.add(resolvedConfig.onRoleChange);
|
|
479
|
+
const leadershipListeners = new Set();
|
|
480
|
+
if (resolvedConfig.onLeadershipChange !== undefined) {
|
|
481
|
+
leadershipListeners.add(resolvedConfig.onLeadershipChange);
|
|
482
|
+
}
|
|
418
483
|
// Leadership BEFORE the worker exists: one core per origin, and a losing
|
|
419
484
|
// tab never boots a database it must not own.
|
|
420
485
|
const lease = lock.tryAcquire !== undefined
|
|
@@ -425,32 +490,36 @@ export async function createSyncClientHandle(config) {
|
|
|
425
490
|
// Epoch derivation for a fresh boot: epoch 0. A promoter (below) reads
|
|
426
491
|
// the highest epoch it has seen and adds one, so leaders monotonically
|
|
427
492
|
// increase it across handovers.
|
|
428
|
-
return await bootLeader(
|
|
493
|
+
return await bootLeader(resolvedConfig, lockName, lease, {
|
|
429
494
|
epoch: 0,
|
|
430
495
|
invalidation,
|
|
431
496
|
changes,
|
|
432
497
|
presence,
|
|
433
498
|
roleListeners,
|
|
499
|
+
leadershipListeners,
|
|
434
500
|
});
|
|
435
501
|
}
|
|
436
502
|
// ---- Lost the election. ----
|
|
437
|
-
if (
|
|
503
|
+
if (resolvedConfig.multiTab === false) {
|
|
438
504
|
// Opted-out single-tab contract: a dead not-leader handle.
|
|
439
505
|
return new SyncClientHandle({
|
|
440
506
|
role: 'follower',
|
|
441
507
|
clientId: '',
|
|
508
|
+
currentSchemaVersion: resolvedConfig.schema.version,
|
|
442
509
|
invalidation,
|
|
443
510
|
changes,
|
|
444
511
|
presence,
|
|
445
512
|
roleListeners,
|
|
513
|
+
leadershipListeners,
|
|
446
514
|
});
|
|
447
515
|
}
|
|
448
516
|
// ---- Follower: proxy to the leader; contest + promote on its close. ----
|
|
449
|
-
return await bootFollower(
|
|
517
|
+
return await bootFollower(resolvedConfig, lockName, lock, {
|
|
450
518
|
invalidation,
|
|
451
519
|
changes,
|
|
452
520
|
presence,
|
|
453
521
|
roleListeners,
|
|
522
|
+
leadershipListeners,
|
|
454
523
|
});
|
|
455
524
|
}
|
|
456
525
|
/** Boot (or promote to) a leader: spawn the worker, wire the bridge. */
|
|
@@ -471,6 +540,7 @@ async function bootLeader(config, lockName, lease, parts) {
|
|
|
471
540
|
epoch: parts.epoch ?? 0,
|
|
472
541
|
clientId,
|
|
473
542
|
invoke,
|
|
543
|
+
heartbeatMs: Math.max(10, Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3)),
|
|
474
544
|
});
|
|
475
545
|
}
|
|
476
546
|
: undefined;
|
|
@@ -484,11 +554,13 @@ async function bootLeader(config, lockName, lease, parts) {
|
|
|
484
554
|
const handle = new SyncClientHandle({
|
|
485
555
|
role: 'leader',
|
|
486
556
|
clientId: core.clientId,
|
|
557
|
+
currentSchemaVersion: config.schema.version,
|
|
487
558
|
core,
|
|
488
559
|
invalidation: parts.invalidation,
|
|
489
560
|
changes: parts.changes,
|
|
490
561
|
presence: parts.presence,
|
|
491
562
|
roleListeners: parts.roleListeners,
|
|
563
|
+
leadershipListeners: parts.leadershipListeners,
|
|
492
564
|
});
|
|
493
565
|
handleRef.handle = handle;
|
|
494
566
|
return handle;
|
|
@@ -512,6 +584,7 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
512
584
|
// it after binding). Nothing else to do — calls already flush.
|
|
513
585
|
void clientId;
|
|
514
586
|
},
|
|
587
|
+
onStateChange: (state) => handleRef.handle?.__setLeadership(state),
|
|
515
588
|
...(config.followerCallTimeoutMs !== undefined
|
|
516
589
|
? { callTimeoutMs: config.followerCallTimeoutMs }
|
|
517
590
|
: {}),
|
|
@@ -549,6 +622,7 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
549
622
|
epoch: nextEpoch,
|
|
550
623
|
clientId,
|
|
551
624
|
invoke,
|
|
625
|
+
heartbeatMs: Math.max(10, Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3)),
|
|
552
626
|
});
|
|
553
627
|
},
|
|
554
628
|
}
|
|
@@ -568,11 +642,13 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
568
642
|
// leave '' until promotion (the shared id is the leader's — hooks that
|
|
569
643
|
// need it read it after a round). Followers rarely need clientId directly.
|
|
570
644
|
clientId: '',
|
|
645
|
+
currentSchemaVersion: config.schema.version,
|
|
571
646
|
follower,
|
|
572
647
|
invalidation: parts.invalidation,
|
|
573
648
|
changes: parts.changes,
|
|
574
649
|
presence: parts.presence,
|
|
575
650
|
roleListeners: parts.roleListeners,
|
|
651
|
+
leadershipListeners: parts.leadershipListeners,
|
|
576
652
|
});
|
|
577
653
|
handleRef.handle = handle;
|
|
578
654
|
// Do not hand back a follower until its link has bound to the leader (the
|
|
@@ -591,3 +667,27 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
591
667
|
}
|
|
592
668
|
return handle;
|
|
593
669
|
}
|
|
670
|
+
function resolveReplicaConfig(config) {
|
|
671
|
+
if (config.replica?.mode !== 'isolated')
|
|
672
|
+
return config;
|
|
673
|
+
if (config.database.mode !== 'persistent') {
|
|
674
|
+
throw new ClientSyncError('sync.invalid_request', 'isolated browser replicas require a named persistent database');
|
|
675
|
+
}
|
|
676
|
+
const names = isolatedReplicaNames({
|
|
677
|
+
databaseName: config.database.name,
|
|
678
|
+
...(config.database.directory !== undefined
|
|
679
|
+
? { databaseDirectory: config.database.directory }
|
|
680
|
+
: {}),
|
|
681
|
+
...(config.lockName !== undefined ? { lockName: config.lockName } : {}),
|
|
682
|
+
replicaId: config.replica.id,
|
|
683
|
+
});
|
|
684
|
+
return {
|
|
685
|
+
...config,
|
|
686
|
+
lockName: names.lockName,
|
|
687
|
+
database: {
|
|
688
|
+
...config.database,
|
|
689
|
+
name: names.databaseName,
|
|
690
|
+
directory: names.databaseDirectory,
|
|
691
|
+
},
|
|
692
|
+
};
|
|
693
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.14",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
},
|
|
82
82
|
"dependencies": {
|
|
83
83
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
84
|
-
"@syncular/core": "0.15.
|
|
84
|
+
"@syncular/core": "0.15.14"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"better-sqlite3": ">=11"
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
}
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.15.
|
|
95
|
+
"@syncular/server": "0.15.14",
|
|
96
96
|
"@types/better-sqlite3": "^7.6.13",
|
|
97
97
|
"better-sqlite3": "^12.11.1"
|
|
98
98
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { SyncStatusSnapshot } from './invalidation';
|
|
2
|
+
import type { LeadershipState } from './multi-tab';
|
|
3
|
+
|
|
4
|
+
export type SyncAvailability =
|
|
5
|
+
| { readonly state: 'ready' }
|
|
6
|
+
| { readonly state: 'migrating'; readonly currentSchemaVersion: number }
|
|
7
|
+
| {
|
|
8
|
+
readonly state: 'blocked';
|
|
9
|
+
readonly reason:
|
|
10
|
+
| 'client-upgrade-required'
|
|
11
|
+
| 'server-behind'
|
|
12
|
+
| 'incompatible-schema'
|
|
13
|
+
| 'leader-unreachable';
|
|
14
|
+
readonly currentSchemaVersion: number;
|
|
15
|
+
readonly requiredSchemaVersion?: number;
|
|
16
|
+
readonly latestServerSchemaVersion?: number;
|
|
17
|
+
readonly retryable: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Classify schema and browser-ownership state without parsing diagnostics. */
|
|
21
|
+
export function classifySyncAvailability(
|
|
22
|
+
status: SyncStatusSnapshot,
|
|
23
|
+
leadership?: LeadershipState,
|
|
24
|
+
): SyncAvailability {
|
|
25
|
+
const currentSchemaVersion = status.currentSchemaVersion;
|
|
26
|
+
if (leadership?.state === 'blocked') {
|
|
27
|
+
return {
|
|
28
|
+
state: 'blocked',
|
|
29
|
+
reason: 'leader-unreachable',
|
|
30
|
+
currentSchemaVersion,
|
|
31
|
+
retryable: true,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const required = status.schemaFloor?.requiredSchemaVersion;
|
|
35
|
+
const latest = status.schemaFloor?.latestSchemaVersion;
|
|
36
|
+
if (required !== undefined && required > currentSchemaVersion) {
|
|
37
|
+
return {
|
|
38
|
+
state: 'blocked',
|
|
39
|
+
reason: 'client-upgrade-required',
|
|
40
|
+
currentSchemaVersion,
|
|
41
|
+
requiredSchemaVersion: required,
|
|
42
|
+
...(latest !== undefined ? { latestServerSchemaVersion: latest } : {}),
|
|
43
|
+
retryable: false,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (latest !== undefined && latest < currentSchemaVersion) {
|
|
47
|
+
return {
|
|
48
|
+
state: 'blocked',
|
|
49
|
+
reason: 'server-behind',
|
|
50
|
+
currentSchemaVersion,
|
|
51
|
+
...(required !== undefined ? { requiredSchemaVersion: required } : {}),
|
|
52
|
+
latestServerSchemaVersion: latest,
|
|
53
|
+
retryable: false,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (status.schemaFloor !== undefined) {
|
|
57
|
+
return {
|
|
58
|
+
state: 'blocked',
|
|
59
|
+
reason: 'incompatible-schema',
|
|
60
|
+
currentSchemaVersion,
|
|
61
|
+
...(required !== undefined ? { requiredSchemaVersion: required } : {}),
|
|
62
|
+
...(latest !== undefined ? { latestServerSchemaVersion: latest } : {}),
|
|
63
|
+
retryable: false,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (status.upgrading) {
|
|
67
|
+
return { state: 'migrating', currentSchemaVersion };
|
|
68
|
+
}
|
|
69
|
+
return { state: 'ready' };
|
|
70
|
+
}
|