@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.
@@ -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
- 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
- }
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();
@@ -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;
@@ -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
- await handle.beginSecurityPreflight();
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.29.1",
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.29.1"
92
+ "@syncular/core": "0.30.0"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.29.1",
95
+ "@syncular/server": "0.30.0",
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;