@syncular/client 0.29.1 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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/dist/reactive-store.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { classifySyncAvailability, } from './availability.js';
|
|
1
|
+
import { blockedLeadershipAvailability, classifySyncAvailability, } from './availability.js';
|
|
2
2
|
import { ClientSyncError, SyncRoundFailedError } from './errors.js';
|
|
3
3
|
import { windowBaseKey } from './window.js';
|
|
4
4
|
function errorOf(value) {
|
|
@@ -301,47 +301,7 @@ class QueryEntry {
|
|
|
301
301
|
this.#offStatus = this.store.status.subscribe(() => this.#onAvailabilityChange());
|
|
302
302
|
this.#offRound = this.store.roundFailure.subscribe(() => this.#onRoundFailureChange());
|
|
303
303
|
this.#onAvailabilityChange();
|
|
304
|
-
|
|
305
|
-
const claims = [];
|
|
306
|
-
// A composed spec carries one coverage entry per query branch, and
|
|
307
|
-
// several branches can target the same base. One claim per distinct
|
|
308
|
-
// base covers the union of their units; a later entry for the same
|
|
309
|
-
// base does not replace the earlier one.
|
|
310
|
-
const unitsByBase = new Map();
|
|
311
|
-
for (const coverage of this.spec.coverage ?? []) {
|
|
312
|
-
const baseKey = windowBaseKey(coverage.base);
|
|
313
|
-
const existing = unitsByBase.get(baseKey);
|
|
314
|
-
if (existing === undefined) {
|
|
315
|
-
unitsByBase.set(baseKey, {
|
|
316
|
-
base: coverage.base,
|
|
317
|
-
units: new Set(coverage.units),
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
else {
|
|
321
|
-
for (const unit of coverage.units)
|
|
322
|
-
existing.units.add(unit);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
for (const { base, units } of unitsByBase.values()) {
|
|
326
|
-
claims.push(this.store.setWindowClaim(this.#owner, base, [...units]));
|
|
327
|
-
}
|
|
328
|
-
const generation = this.#generation;
|
|
329
|
-
this.#claimPending = claims.length > 0;
|
|
330
|
-
if (claims.length > 0)
|
|
331
|
-
void Promise.all(claims).then(() => {
|
|
332
|
-
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
333
|
-
return;
|
|
334
|
-
this.#claimPending = false;
|
|
335
|
-
if (!this.#running && this.#state.phase !== 'ready')
|
|
336
|
-
this.#requestRead();
|
|
337
|
-
}, (error) => {
|
|
338
|
-
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
339
|
-
return;
|
|
340
|
-
this.#claimPending = false;
|
|
341
|
-
this.#claimError = errorOf(error);
|
|
342
|
-
this.#requestRead();
|
|
343
|
-
});
|
|
344
|
-
}
|
|
304
|
+
this.#claimCoverage();
|
|
345
305
|
this.#requestRead();
|
|
346
306
|
}
|
|
347
307
|
return () => {
|
|
@@ -357,6 +317,50 @@ class QueryEntry {
|
|
|
357
317
|
}
|
|
358
318
|
};
|
|
359
319
|
};
|
|
320
|
+
/** Claim this query's window coverage; a failure surfaces on the next read. */
|
|
321
|
+
#claimCoverage() {
|
|
322
|
+
if (this.spec.claimCoverage === false)
|
|
323
|
+
return;
|
|
324
|
+
const claims = [];
|
|
325
|
+
// A composed spec carries one coverage entry per query branch, and
|
|
326
|
+
// several branches can target the same base. One claim per distinct
|
|
327
|
+
// base covers the union of their units; a later entry for the same
|
|
328
|
+
// base does not replace the earlier one.
|
|
329
|
+
const unitsByBase = new Map();
|
|
330
|
+
for (const coverage of this.spec.coverage ?? []) {
|
|
331
|
+
const baseKey = windowBaseKey(coverage.base);
|
|
332
|
+
const existing = unitsByBase.get(baseKey);
|
|
333
|
+
if (existing === undefined) {
|
|
334
|
+
unitsByBase.set(baseKey, {
|
|
335
|
+
base: coverage.base,
|
|
336
|
+
units: new Set(coverage.units),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
for (const unit of coverage.units)
|
|
341
|
+
existing.units.add(unit);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
for (const { base, units } of unitsByBase.values()) {
|
|
345
|
+
claims.push(this.store.setWindowClaim(this.#owner, base, [...units]));
|
|
346
|
+
}
|
|
347
|
+
const generation = this.#generation;
|
|
348
|
+
this.#claimPending = claims.length > 0;
|
|
349
|
+
if (claims.length > 0)
|
|
350
|
+
void Promise.all(claims).then(() => {
|
|
351
|
+
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
352
|
+
return;
|
|
353
|
+
this.#claimPending = false;
|
|
354
|
+
if (!this.#running && this.#state.phase !== 'ready')
|
|
355
|
+
this.#requestRead();
|
|
356
|
+
}, (error) => {
|
|
357
|
+
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
358
|
+
return;
|
|
359
|
+
this.#claimPending = false;
|
|
360
|
+
this.#claimError = errorOf(error);
|
|
361
|
+
this.#requestRead();
|
|
362
|
+
});
|
|
363
|
+
}
|
|
360
364
|
refresh = () => {
|
|
361
365
|
if (this.#delegate !== undefined)
|
|
362
366
|
this.#delegate.refresh();
|
|
@@ -442,6 +446,12 @@ class QueryEntry {
|
|
|
442
446
|
return;
|
|
443
447
|
}
|
|
444
448
|
const wasBlocked = this.#state.phase === 'blocked';
|
|
449
|
+
// A claim that failed while the leader link was blocked is retried with
|
|
450
|
+
// the link: its stored error would otherwise fail every later read.
|
|
451
|
+
if (wasBlocked && this.#claimError !== undefined) {
|
|
452
|
+
this.#claimError = undefined;
|
|
453
|
+
this.#claimCoverage();
|
|
454
|
+
}
|
|
445
455
|
this.#publish({
|
|
446
456
|
...this.#state,
|
|
447
457
|
phase: wasBlocked
|
|
@@ -811,12 +821,7 @@ export class ReactiveClientStore {
|
|
|
811
821
|
const snapshot = this.status.getSnapshot();
|
|
812
822
|
if (snapshot.status === undefined) {
|
|
813
823
|
return snapshot.leadership?.state === 'blocked'
|
|
814
|
-
?
|
|
815
|
-
state: 'blocked',
|
|
816
|
-
reason: 'leader-unreachable',
|
|
817
|
-
currentSchemaVersion: this.client.currentSchemaVersion ?? 0,
|
|
818
|
-
retryable: true,
|
|
819
|
-
}
|
|
824
|
+
? blockedLeadershipAvailability(snapshot.leadership, this.client.currentSchemaVersion ?? 0)
|
|
820
825
|
: { state: 'ready' };
|
|
821
826
|
}
|
|
822
827
|
return classifySyncAvailability(snapshot.status, snapshot.leadership);
|
|
@@ -990,6 +995,14 @@ export class ReactiveClientStore {
|
|
|
990
995
|
});
|
|
991
996
|
if (previous.isLoading)
|
|
992
997
|
this.status.refresh();
|
|
998
|
+
// Window edits that failed while no leader served this tab apply now;
|
|
999
|
+
// an applied group compares equal and makes no call.
|
|
1000
|
+
if (leadership.state === 'leader' || leadership.state === 'follower') {
|
|
1001
|
+
for (const group of this.#windowClaims.values()) {
|
|
1002
|
+
if (group.claims.size > 0)
|
|
1003
|
+
this.#scheduleWindow(group);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
993
1006
|
});
|
|
994
1007
|
this.status.refresh();
|
|
995
1008
|
this.conflicts.refresh();
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -190,6 +190,12 @@ export declare class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
190
190
|
get __isClosed(): boolean;
|
|
191
191
|
/** @internal — swap this handle from follower to leader (promotion). */
|
|
192
192
|
__becomeLeader(core: LeaderCore): void;
|
|
193
|
+
/**
|
|
194
|
+
* @internal — a newer tab asked for the database: close the core and
|
|
195
|
+
* release the lock so that tab promotes. This tab stays blocked until it
|
|
196
|
+
* reloads with the newer build.
|
|
197
|
+
*/
|
|
198
|
+
__yieldToNewerTab(): void;
|
|
193
199
|
/** @internal — apply a follower reachability snapshot in place. */
|
|
194
200
|
__setLeadership(state: LeadershipState): void;
|
|
195
201
|
/** @internal — dispatch a worker/relayed event to handle-local listeners. */
|
package/dist/worker-host.js
CHANGED
|
@@ -5,7 +5,7 @@ import { ClientSyncError } from './errors.js';
|
|
|
5
5
|
import { ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './invalidation.js';
|
|
6
6
|
import { singleOwnerLock, webLocksLeaderLock, } from './leader-lock.js';
|
|
7
7
|
import { decodeLocalDataRebootstrapResult, } from './local-rebootstrap.js';
|
|
8
|
-
import { broadcastChannelFactory, FollowerLink, LeaderBridge, multiTabChannelName, newTabId, } from './multi-tab.js';
|
|
8
|
+
import { broadcastChannelFactory, FollowerLink, LEADER_INCOMPATIBLE_CODE, LeaderBridge, MULTI_TAB_PROTOCOL_VERSION, multiTabChannelName, newTabId, } from './multi-tab.js';
|
|
9
9
|
import { decodePreviousVersionAuditResult, decodePreviousVersionDiscardResult, decodePreviousVersionSnapshot, } from './previous-version.js';
|
|
10
10
|
import { NOT_LEADER_CODE, WORKER_FAILED_CODE, WORKER_RESTART_REQUIRED_CODE, } from './worker-protocol.js';
|
|
11
11
|
const WORKER_BUNDLE_LOAD_FAILURE = /(?:failed to fetch dynamically imported module|error loading dynamically imported module|importing a module script failed|failed to load module script)/iu;
|
|
@@ -143,6 +143,34 @@ export class SyncClientHandle {
|
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* @internal — a newer tab asked for the database: close the core and
|
|
148
|
+
* release the lock so that tab promotes. This tab stays blocked until it
|
|
149
|
+
* reloads with the newer build.
|
|
150
|
+
*/
|
|
151
|
+
__yieldToNewerTab() {
|
|
152
|
+
const core = this.#core;
|
|
153
|
+
if (core === undefined)
|
|
154
|
+
return;
|
|
155
|
+
this.#core = undefined;
|
|
156
|
+
this.#role = 'follower';
|
|
157
|
+
this.__setLeadership({
|
|
158
|
+
state: 'blocked',
|
|
159
|
+
reason: 'leader-incompatible',
|
|
160
|
+
code: LEADER_INCOMPATIBLE_CODE,
|
|
161
|
+
leader: 'newer',
|
|
162
|
+
retryable: true,
|
|
163
|
+
});
|
|
164
|
+
for (const listener of this.#roleListeners) {
|
|
165
|
+
try {
|
|
166
|
+
listener('follower');
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
/* a UI listener must never break the handover */
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
void core.close(true);
|
|
173
|
+
}
|
|
146
174
|
/** @internal — apply a follower reachability snapshot in place. */
|
|
147
175
|
__setLeadership(state) {
|
|
148
176
|
this.#leadership = state;
|
|
@@ -234,6 +262,10 @@ export class SyncClientHandle {
|
|
|
234
262
|
if (this.#closed) {
|
|
235
263
|
return Promise.reject(new ClientSyncError(WORKER_FAILED_CODE, 'the handle is closed'));
|
|
236
264
|
}
|
|
265
|
+
if (this.#leadership.state === 'blocked' &&
|
|
266
|
+
this.#leadership.reason === 'leader-incompatible') {
|
|
267
|
+
return Promise.reject(new ClientSyncError(LEADER_INCOMPATIBLE_CODE, 'the tab that owns the database runs a different protocol or schema version', true));
|
|
268
|
+
}
|
|
237
269
|
if (this.#role === 'follower') {
|
|
238
270
|
if (this.#follower === undefined) {
|
|
239
271
|
return Promise.reject(new ClientSyncError(NOT_LEADER_CODE, 'this tab is not the leader — another tab owns the syncular ' +
|
|
@@ -621,6 +653,8 @@ async function bootLeader(config, lockName, lease, parts) {
|
|
|
621
653
|
fireConfigCallbacks(config, event);
|
|
622
654
|
handleRef.handle?.__dispatchEvent(event);
|
|
623
655
|
};
|
|
656
|
+
// A newer tab may say hello before the handle exists; yield once it does.
|
|
657
|
+
let newerTab = false;
|
|
624
658
|
const makeBridge = config.multiTab !== false
|
|
625
659
|
? (clientId, invoke) => {
|
|
626
660
|
const factory = config.channelFactory ?? broadcastChannelFactory();
|
|
@@ -629,7 +663,12 @@ async function bootLeader(config, lockName, lease, parts) {
|
|
|
629
663
|
channel,
|
|
630
664
|
epoch: parts.epoch ?? 0,
|
|
631
665
|
clientId,
|
|
666
|
+
identity: tabIdentity(config),
|
|
632
667
|
invoke,
|
|
668
|
+
onNewerTab: () => {
|
|
669
|
+
newerTab = true;
|
|
670
|
+
handleRef.handle?.__yieldToNewerTab();
|
|
671
|
+
},
|
|
633
672
|
});
|
|
634
673
|
}
|
|
635
674
|
: undefined;
|
|
@@ -653,8 +692,16 @@ async function bootLeader(config, lockName, lease, parts) {
|
|
|
653
692
|
leadershipListeners: parts.leadershipListeners,
|
|
654
693
|
});
|
|
655
694
|
handleRef.handle = handle;
|
|
695
|
+
if (newerTab)
|
|
696
|
+
handle.__yieldToNewerTab();
|
|
656
697
|
return handle;
|
|
657
698
|
}
|
|
699
|
+
function tabIdentity(config) {
|
|
700
|
+
return {
|
|
701
|
+
protocol: MULTI_TAB_PROTOCOL_VERSION,
|
|
702
|
+
schemaVersion: config.schema.version,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
658
705
|
/**
|
|
659
706
|
* Boot a follower: open the channel, bind to the leader, and race the lock in
|
|
660
707
|
* the background so this tab promotes itself the instant the leader closes.
|
|
@@ -668,6 +715,7 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
668
715
|
const follower = new FollowerLink({
|
|
669
716
|
channel,
|
|
670
717
|
fromId: newTabId(),
|
|
718
|
+
identity: tabIdentity(config),
|
|
671
719
|
onEvent: (event) => handleRef.handle?.__dispatchEvent(event),
|
|
672
720
|
onLeaderChange: (clientId) => {
|
|
673
721
|
// Learn the leader's shared client id (best-effort; the handle exposes
|
|
@@ -702,6 +750,8 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
702
750
|
const nextEpoch = follower.maxEpochSeen + 1;
|
|
703
751
|
// Unbind the link so any late leader traffic is ignored, then promote.
|
|
704
752
|
follower.unbind();
|
|
753
|
+
// A newer tab may say hello before this core is installed.
|
|
754
|
+
let newerTab = false;
|
|
705
755
|
try {
|
|
706
756
|
const core = await startWorkerCore({
|
|
707
757
|
config,
|
|
@@ -719,7 +769,12 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
719
769
|
channel: promoteChannel,
|
|
720
770
|
epoch: nextEpoch,
|
|
721
771
|
clientId,
|
|
772
|
+
identity: tabIdentity(config),
|
|
722
773
|
invoke,
|
|
774
|
+
onNewerTab: () => {
|
|
775
|
+
newerTab = true;
|
|
776
|
+
handle.__yieldToNewerTab();
|
|
777
|
+
},
|
|
723
778
|
});
|
|
724
779
|
},
|
|
725
780
|
}
|
|
@@ -733,6 +788,8 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
733
788
|
return;
|
|
734
789
|
}
|
|
735
790
|
handle.__becomeLeader(core);
|
|
791
|
+
if (newerTab)
|
|
792
|
+
handle.__yieldToNewerTab();
|
|
736
793
|
}
|
|
737
794
|
catch {
|
|
738
795
|
// Promotion failed to spawn a worker — release so the next tab tries.
|
|
@@ -775,7 +832,15 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
775
832
|
// the shared origin replica behind the same barrier before it is returned;
|
|
776
833
|
// otherwise an already-running leader would silently ignore the request.
|
|
777
834
|
if (config.securityPreflight === true) {
|
|
778
|
-
|
|
835
|
+
try {
|
|
836
|
+
await handle.beginSecurityPreflight();
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
// The caller never receives this handle, so it must not stay queued for
|
|
840
|
+
// the lock and promote into a database nobody holds.
|
|
841
|
+
await handle.close();
|
|
842
|
+
throw error;
|
|
843
|
+
}
|
|
779
844
|
}
|
|
780
845
|
return handle;
|
|
781
846
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
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",
|
|
@@ -89,10 +89,10 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.
|
|
92
|
+
"@syncular/core": "0.30.0"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.
|
|
95
|
+
"@syncular/server": "0.30.0",
|
|
96
96
|
"playwright": "1.63.0"
|
|
97
97
|
}
|
|
98
98
|
}
|
package/src/availability.ts
CHANGED
|
@@ -10,13 +10,38 @@ export type SyncAvailability =
|
|
|
10
10
|
| 'client-upgrade-required'
|
|
11
11
|
| 'server-behind'
|
|
12
12
|
| 'incompatible-schema'
|
|
13
|
-
| 'leader-unreachable'
|
|
13
|
+
| 'leader-unreachable'
|
|
14
|
+
| 'leader-incompatible';
|
|
14
15
|
readonly currentSchemaVersion: number;
|
|
16
|
+
/** `leader-incompatible`: whether the tab holding the database runs an
|
|
17
|
+
* older or a newer build than this one. */
|
|
18
|
+
readonly leader?: 'older' | 'newer';
|
|
15
19
|
readonly requiredSchemaVersion?: number;
|
|
16
20
|
readonly latestServerSchemaVersion?: number;
|
|
17
21
|
readonly retryable: boolean;
|
|
18
22
|
};
|
|
19
23
|
|
|
24
|
+
/** The availability of a tab whose leader link is blocked. */
|
|
25
|
+
export function blockedLeadershipAvailability(
|
|
26
|
+
leadership: Extract<LeadershipState, { state: 'blocked' }>,
|
|
27
|
+
currentSchemaVersion: number,
|
|
28
|
+
): SyncAvailability {
|
|
29
|
+
return leadership.reason === 'leader-incompatible'
|
|
30
|
+
? {
|
|
31
|
+
state: 'blocked',
|
|
32
|
+
reason: 'leader-incompatible',
|
|
33
|
+
currentSchemaVersion,
|
|
34
|
+
leader: leadership.leader,
|
|
35
|
+
retryable: true,
|
|
36
|
+
}
|
|
37
|
+
: {
|
|
38
|
+
state: 'blocked',
|
|
39
|
+
reason: 'leader-unreachable',
|
|
40
|
+
currentSchemaVersion,
|
|
41
|
+
retryable: true,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
20
45
|
/** Classify schema and browser-ownership state without parsing diagnostics. */
|
|
21
46
|
export function classifySyncAvailability(
|
|
22
47
|
status: SyncStatusSnapshot,
|
|
@@ -24,12 +49,7 @@ export function classifySyncAvailability(
|
|
|
24
49
|
): SyncAvailability {
|
|
25
50
|
const currentSchemaVersion = status.currentSchemaVersion;
|
|
26
51
|
if (leadership?.state === 'blocked') {
|
|
27
|
-
return
|
|
28
|
-
state: 'blocked',
|
|
29
|
-
reason: 'leader-unreachable',
|
|
30
|
-
currentSchemaVersion,
|
|
31
|
-
retryable: true,
|
|
32
|
-
};
|
|
52
|
+
return blockedLeadershipAvailability(leadership, currentSchemaVersion);
|
|
33
53
|
}
|
|
34
54
|
const required = status.schemaFloor?.requiredSchemaVersion;
|
|
35
55
|
const latest = status.schemaFloor?.latestSchemaVersion;
|