@syncular/client 0.29.1 → 0.30.1
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 +32 -16
- package/dist/availability.d.ts +8 -1
- package/dist/availability.js +17 -5
- package/dist/multi-tab.d.ts +54 -8
- package/dist/multi-tab.js +150 -24
- package/dist/reactive-store.js +69 -48
- package/dist/worker-host.d.ts +6 -0
- package/dist/worker-host.js +69 -3
- package/package.json +3 -3
- package/src/availability.ts +27 -7
- package/src/multi-tab.ts +208 -40
- package/src/reactive-store.ts +76 -51
- package/src/worker-host.ts +76 -2
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) {
|
|
@@ -233,6 +233,10 @@ class ObservationCache {
|
|
|
233
233
|
for (const entry of this.#active)
|
|
234
234
|
entry.onChange(batch);
|
|
235
235
|
}
|
|
236
|
+
onLeaderServing() {
|
|
237
|
+
for (const entry of this.#active)
|
|
238
|
+
entry.onLeaderServing?.();
|
|
239
|
+
}
|
|
236
240
|
clear() {
|
|
237
241
|
for (const entry of this.#entries.values())
|
|
238
242
|
entry.dispose();
|
|
@@ -301,47 +305,7 @@ class QueryEntry {
|
|
|
301
305
|
this.#offStatus = this.store.status.subscribe(() => this.#onAvailabilityChange());
|
|
302
306
|
this.#offRound = this.store.roundFailure.subscribe(() => this.#onRoundFailureChange());
|
|
303
307
|
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
|
-
}
|
|
308
|
+
this.#claimCoverage();
|
|
345
309
|
this.#requestRead();
|
|
346
310
|
}
|
|
347
311
|
return () => {
|
|
@@ -357,6 +321,50 @@ class QueryEntry {
|
|
|
357
321
|
}
|
|
358
322
|
};
|
|
359
323
|
};
|
|
324
|
+
/** Claim this query's window coverage; a failure surfaces on the next read. */
|
|
325
|
+
#claimCoverage() {
|
|
326
|
+
if (this.spec.claimCoverage === false)
|
|
327
|
+
return;
|
|
328
|
+
const claims = [];
|
|
329
|
+
// A composed spec carries one coverage entry per query branch, and
|
|
330
|
+
// several branches can target the same base. One claim per distinct
|
|
331
|
+
// base covers the union of their units; a later entry for the same
|
|
332
|
+
// base does not replace the earlier one.
|
|
333
|
+
const unitsByBase = new Map();
|
|
334
|
+
for (const coverage of this.spec.coverage ?? []) {
|
|
335
|
+
const baseKey = windowBaseKey(coverage.base);
|
|
336
|
+
const existing = unitsByBase.get(baseKey);
|
|
337
|
+
if (existing === undefined) {
|
|
338
|
+
unitsByBase.set(baseKey, {
|
|
339
|
+
base: coverage.base,
|
|
340
|
+
units: new Set(coverage.units),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
for (const unit of coverage.units)
|
|
345
|
+
existing.units.add(unit);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
for (const { base, units } of unitsByBase.values()) {
|
|
349
|
+
claims.push(this.store.setWindowClaim(this.#owner, base, [...units]));
|
|
350
|
+
}
|
|
351
|
+
const generation = this.#generation;
|
|
352
|
+
this.#claimPending = claims.length > 0;
|
|
353
|
+
if (claims.length > 0)
|
|
354
|
+
void Promise.all(claims).then(() => {
|
|
355
|
+
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
356
|
+
return;
|
|
357
|
+
this.#claimPending = false;
|
|
358
|
+
if (!this.#running && this.#state.phase !== 'ready')
|
|
359
|
+
this.#requestRead();
|
|
360
|
+
}, (error) => {
|
|
361
|
+
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
362
|
+
return;
|
|
363
|
+
this.#claimPending = false;
|
|
364
|
+
this.#claimError = errorOf(error);
|
|
365
|
+
this.#requestRead();
|
|
366
|
+
});
|
|
367
|
+
}
|
|
360
368
|
refresh = () => {
|
|
361
369
|
if (this.#delegate !== undefined)
|
|
362
370
|
this.#delegate.refresh();
|
|
@@ -382,6 +390,15 @@ class QueryEntry {
|
|
|
382
390
|
this.store.releaseWindowClaims(this.#owner);
|
|
383
391
|
this.reset();
|
|
384
392
|
}
|
|
393
|
+
/** A claim rejected while no leader served this tab (blocked, handover,
|
|
394
|
+
* another build) is claimed again; its stored error would fail every read. */
|
|
395
|
+
onLeaderServing() {
|
|
396
|
+
if (this.#delegate !== undefined || this.#claimError === undefined)
|
|
397
|
+
return;
|
|
398
|
+
this.#claimError = undefined;
|
|
399
|
+
this.#claimCoverage();
|
|
400
|
+
this.#requestRead();
|
|
401
|
+
}
|
|
385
402
|
onChange(batch) {
|
|
386
403
|
if (!batchMatches(batch, this.spec))
|
|
387
404
|
return;
|
|
@@ -811,12 +828,7 @@ export class ReactiveClientStore {
|
|
|
811
828
|
const snapshot = this.status.getSnapshot();
|
|
812
829
|
if (snapshot.status === undefined) {
|
|
813
830
|
return snapshot.leadership?.state === 'blocked'
|
|
814
|
-
?
|
|
815
|
-
state: 'blocked',
|
|
816
|
-
reason: 'leader-unreachable',
|
|
817
|
-
currentSchemaVersion: this.client.currentSchemaVersion ?? 0,
|
|
818
|
-
retryable: true,
|
|
819
|
-
}
|
|
831
|
+
? blockedLeadershipAvailability(snapshot.leadership, this.client.currentSchemaVersion ?? 0)
|
|
820
832
|
: { state: 'ready' };
|
|
821
833
|
}
|
|
822
834
|
return classifySyncAvailability(snapshot.status, snapshot.leadership);
|
|
@@ -990,6 +1002,15 @@ export class ReactiveClientStore {
|
|
|
990
1002
|
});
|
|
991
1003
|
if (previous.isLoading)
|
|
992
1004
|
this.status.refresh();
|
|
1005
|
+
// Window edits that failed while no leader served this tab apply now;
|
|
1006
|
+
// an applied group compares equal and makes no call.
|
|
1007
|
+
if (leadership.state === 'leader' || leadership.state === 'follower') {
|
|
1008
|
+
for (const group of this.#windowClaims.values()) {
|
|
1009
|
+
if (group.claims.size > 0)
|
|
1010
|
+
this.#scheduleWindow(group);
|
|
1011
|
+
}
|
|
1012
|
+
this.#queries.onLeaderServing();
|
|
1013
|
+
}
|
|
993
1014
|
});
|
|
994
1015
|
this.status.refresh();
|
|
995
1016
|
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;
|
|
@@ -128,7 +128,8 @@ export class SyncClientHandle {
|
|
|
128
128
|
}
|
|
129
129
|
/** @internal — swap this handle from follower to leader (promotion). */
|
|
130
130
|
__becomeLeader(core) {
|
|
131
|
-
this
|
|
131
|
+
// Calls queued for the next leader run on this tab's own core.
|
|
132
|
+
this.#follower?.handOver(core.invoke);
|
|
132
133
|
this.#follower = undefined;
|
|
133
134
|
this.#core = core;
|
|
134
135
|
this.#clientId = core.clientId;
|
|
@@ -143,6 +144,34 @@ export class SyncClientHandle {
|
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* @internal — a newer tab asked for the database: close the core and
|
|
149
|
+
* release the lock so that tab promotes. This tab stays blocked until it
|
|
150
|
+
* reloads with the newer build.
|
|
151
|
+
*/
|
|
152
|
+
__yieldToNewerTab() {
|
|
153
|
+
const core = this.#core;
|
|
154
|
+
if (core === undefined)
|
|
155
|
+
return;
|
|
156
|
+
this.#core = undefined;
|
|
157
|
+
this.#role = 'follower';
|
|
158
|
+
this.__setLeadership({
|
|
159
|
+
state: 'blocked',
|
|
160
|
+
reason: 'leader-incompatible',
|
|
161
|
+
code: LEADER_INCOMPATIBLE_CODE,
|
|
162
|
+
leader: 'newer',
|
|
163
|
+
retryable: true,
|
|
164
|
+
});
|
|
165
|
+
for (const listener of this.#roleListeners) {
|
|
166
|
+
try {
|
|
167
|
+
listener('follower');
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
/* a UI listener must never break the handover */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
void core.close(true);
|
|
174
|
+
}
|
|
146
175
|
/** @internal — apply a follower reachability snapshot in place. */
|
|
147
176
|
__setLeadership(state) {
|
|
148
177
|
this.#leadership = state;
|
|
@@ -234,6 +263,10 @@ export class SyncClientHandle {
|
|
|
234
263
|
if (this.#closed) {
|
|
235
264
|
return Promise.reject(new ClientSyncError(WORKER_FAILED_CODE, 'the handle is closed'));
|
|
236
265
|
}
|
|
266
|
+
if (this.#leadership.state === 'blocked' &&
|
|
267
|
+
this.#leadership.reason === 'leader-incompatible') {
|
|
268
|
+
return Promise.reject(new ClientSyncError(LEADER_INCOMPATIBLE_CODE, 'the tab that owns the database runs a different protocol or schema version', true));
|
|
269
|
+
}
|
|
237
270
|
if (this.#role === 'follower') {
|
|
238
271
|
if (this.#follower === undefined) {
|
|
239
272
|
return Promise.reject(new ClientSyncError(NOT_LEADER_CODE, 'this tab is not the leader — another tab owns the syncular ' +
|
|
@@ -621,6 +654,8 @@ async function bootLeader(config, lockName, lease, parts) {
|
|
|
621
654
|
fireConfigCallbacks(config, event);
|
|
622
655
|
handleRef.handle?.__dispatchEvent(event);
|
|
623
656
|
};
|
|
657
|
+
// A newer tab may say hello before the handle exists; yield once it does.
|
|
658
|
+
let newerTab = false;
|
|
624
659
|
const makeBridge = config.multiTab !== false
|
|
625
660
|
? (clientId, invoke) => {
|
|
626
661
|
const factory = config.channelFactory ?? broadcastChannelFactory();
|
|
@@ -629,7 +664,12 @@ async function bootLeader(config, lockName, lease, parts) {
|
|
|
629
664
|
channel,
|
|
630
665
|
epoch: parts.epoch ?? 0,
|
|
631
666
|
clientId,
|
|
667
|
+
identity: tabIdentity(config),
|
|
632
668
|
invoke,
|
|
669
|
+
onNewerTab: () => {
|
|
670
|
+
newerTab = true;
|
|
671
|
+
handleRef.handle?.__yieldToNewerTab();
|
|
672
|
+
},
|
|
633
673
|
});
|
|
634
674
|
}
|
|
635
675
|
: undefined;
|
|
@@ -653,8 +693,16 @@ async function bootLeader(config, lockName, lease, parts) {
|
|
|
653
693
|
leadershipListeners: parts.leadershipListeners,
|
|
654
694
|
});
|
|
655
695
|
handleRef.handle = handle;
|
|
696
|
+
if (newerTab)
|
|
697
|
+
handle.__yieldToNewerTab();
|
|
656
698
|
return handle;
|
|
657
699
|
}
|
|
700
|
+
function tabIdentity(config) {
|
|
701
|
+
return {
|
|
702
|
+
protocol: MULTI_TAB_PROTOCOL_VERSION,
|
|
703
|
+
schemaVersion: config.schema.version,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
658
706
|
/**
|
|
659
707
|
* Boot a follower: open the channel, bind to the leader, and race the lock in
|
|
660
708
|
* the background so this tab promotes itself the instant the leader closes.
|
|
@@ -668,6 +716,7 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
668
716
|
const follower = new FollowerLink({
|
|
669
717
|
channel,
|
|
670
718
|
fromId: newTabId(),
|
|
719
|
+
identity: tabIdentity(config),
|
|
671
720
|
onEvent: (event) => handleRef.handle?.__dispatchEvent(event),
|
|
672
721
|
onLeaderChange: (clientId) => {
|
|
673
722
|
// Learn the leader's shared client id (best-effort; the handle exposes
|
|
@@ -702,6 +751,8 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
702
751
|
const nextEpoch = follower.maxEpochSeen + 1;
|
|
703
752
|
// Unbind the link so any late leader traffic is ignored, then promote.
|
|
704
753
|
follower.unbind();
|
|
754
|
+
// A newer tab may say hello before this core is installed.
|
|
755
|
+
let newerTab = false;
|
|
705
756
|
try {
|
|
706
757
|
const core = await startWorkerCore({
|
|
707
758
|
config,
|
|
@@ -719,7 +770,12 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
719
770
|
channel: promoteChannel,
|
|
720
771
|
epoch: nextEpoch,
|
|
721
772
|
clientId,
|
|
773
|
+
identity: tabIdentity(config),
|
|
722
774
|
invoke,
|
|
775
|
+
onNewerTab: () => {
|
|
776
|
+
newerTab = true;
|
|
777
|
+
handle.__yieldToNewerTab();
|
|
778
|
+
},
|
|
723
779
|
});
|
|
724
780
|
},
|
|
725
781
|
}
|
|
@@ -733,6 +789,8 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
733
789
|
return;
|
|
734
790
|
}
|
|
735
791
|
handle.__becomeLeader(core);
|
|
792
|
+
if (newerTab)
|
|
793
|
+
handle.__yieldToNewerTab();
|
|
736
794
|
}
|
|
737
795
|
catch {
|
|
738
796
|
// Promotion failed to spawn a worker — release so the next tab tries.
|
|
@@ -775,7 +833,15 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
775
833
|
// the shared origin replica behind the same barrier before it is returned;
|
|
776
834
|
// otherwise an already-running leader would silently ignore the request.
|
|
777
835
|
if (config.securityPreflight === true) {
|
|
778
|
-
|
|
836
|
+
try {
|
|
837
|
+
await handle.beginSecurityPreflight();
|
|
838
|
+
}
|
|
839
|
+
catch (error) {
|
|
840
|
+
// The caller never receives this handle, so it must not stay queued for
|
|
841
|
+
// the lock and promote into a database nobody holds.
|
|
842
|
+
await handle.close();
|
|
843
|
+
throw error;
|
|
844
|
+
}
|
|
779
845
|
}
|
|
780
846
|
return handle;
|
|
781
847
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.1",
|
|
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.1"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.
|
|
95
|
+
"@syncular/server": "0.30.1",
|
|
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;
|