@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.
@@ -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
- if (this.spec.claimCoverage !== false) {
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();
@@ -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. */
@@ -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.#follower?.close();
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
- await handle.beginSecurityPreflight();
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.29.1",
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.29.1"
92
+ "@syncular/core": "0.30.1"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.29.1",
95
+ "@syncular/server": "0.30.1",
96
96
  "playwright": "1.63.0"
97
97
  }
98
98
  }
@@ -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;