@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.
@@ -80,10 +80,13 @@ import {
80
80
  broadcastChannelFactory,
81
81
  type CrossTabChannel,
82
82
  FollowerLink,
83
+ LEADER_INCOMPATIBLE_CODE,
83
84
  LeaderBridge,
84
85
  type LeadershipState,
86
+ MULTI_TAB_PROTOCOL_VERSION,
85
87
  multiTabChannelName,
86
88
  newTabId,
89
+ type TabIdentity,
87
90
  } from './multi-tab';
88
91
  import type { OutboxCommit } from './outbox';
89
92
  import type {
@@ -382,7 +385,8 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
382
385
 
383
386
  /** @internal — swap this handle from follower to leader (promotion). */
384
387
  __becomeLeader(core: LeaderCore): void {
385
- this.#follower?.close();
388
+ // Calls queued for the next leader run on this tab's own core.
389
+ this.#follower?.handOver(core.invoke);
386
390
  this.#follower = undefined;
387
391
  this.#core = core;
388
392
  this.#clientId = core.clientId;
@@ -397,6 +401,33 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
397
401
  }
398
402
  }
399
403
 
404
+ /**
405
+ * @internal — a newer tab asked for the database: close the core and
406
+ * release the lock so that tab promotes. This tab stays blocked until it
407
+ * reloads with the newer build.
408
+ */
409
+ __yieldToNewerTab(): void {
410
+ const core = this.#core;
411
+ if (core === undefined) return;
412
+ this.#core = undefined;
413
+ this.#role = 'follower';
414
+ this.__setLeadership({
415
+ state: 'blocked',
416
+ reason: 'leader-incompatible',
417
+ code: LEADER_INCOMPATIBLE_CODE,
418
+ leader: 'newer',
419
+ retryable: true,
420
+ });
421
+ for (const listener of this.#roleListeners) {
422
+ try {
423
+ listener('follower');
424
+ } catch {
425
+ /* a UI listener must never break the handover */
426
+ }
427
+ }
428
+ void core.close(true);
429
+ }
430
+
400
431
  /** @internal — apply a follower reachability snapshot in place. */
401
432
  __setLeadership(state: LeadershipState): void {
402
433
  this.#leadership = state;
@@ -497,6 +528,18 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
497
528
  new ClientSyncError(WORKER_FAILED_CODE, 'the handle is closed'),
498
529
  );
499
530
  }
531
+ if (
532
+ this.#leadership.state === 'blocked' &&
533
+ this.#leadership.reason === 'leader-incompatible'
534
+ ) {
535
+ return Promise.reject(
536
+ new ClientSyncError(
537
+ LEADER_INCOMPATIBLE_CODE,
538
+ 'the tab that owns the database runs a different protocol or schema version',
539
+ true,
540
+ ),
541
+ );
542
+ }
500
543
  if (this.#role === 'follower') {
501
544
  if (this.#follower === undefined) {
502
545
  return Promise.reject(
@@ -1017,6 +1060,8 @@ async function bootLeader(
1017
1060
  fireConfigCallbacks(config, event);
1018
1061
  handleRef.handle?.__dispatchEvent(event);
1019
1062
  };
1063
+ // A newer tab may say hello before the handle exists; yield once it does.
1064
+ let newerTab = false;
1020
1065
  const makeBridge =
1021
1066
  config.multiTab !== false
1022
1067
  ? (
@@ -1032,7 +1077,12 @@ async function bootLeader(
1032
1077
  channel,
1033
1078
  epoch: parts.epoch ?? 0,
1034
1079
  clientId,
1080
+ identity: tabIdentity(config),
1035
1081
  invoke,
1082
+ onNewerTab: () => {
1083
+ newerTab = true;
1084
+ handleRef.handle?.__yieldToNewerTab();
1085
+ },
1036
1086
  });
1037
1087
  }
1038
1088
  : undefined;
@@ -1058,9 +1108,17 @@ async function bootLeader(
1058
1108
  leadershipListeners: parts.leadershipListeners,
1059
1109
  });
1060
1110
  handleRef.handle = handle;
1111
+ if (newerTab) handle.__yieldToNewerTab();
1061
1112
  return handle;
1062
1113
  }
1063
1114
 
1115
+ function tabIdentity(config: SyncClientHandleConfig): TabIdentity {
1116
+ return {
1117
+ protocol: MULTI_TAB_PROTOCOL_VERSION,
1118
+ schemaVersion: config.schema.version,
1119
+ };
1120
+ }
1121
+
1064
1122
  /**
1065
1123
  * Boot a follower: open the channel, bind to the leader, and race the lock in
1066
1124
  * the background so this tab promotes itself the instant the leader closes.
@@ -1080,6 +1138,7 @@ async function bootFollower(
1080
1138
  const follower = new FollowerLink({
1081
1139
  channel,
1082
1140
  fromId: newTabId(),
1141
+ identity: tabIdentity(config),
1083
1142
  onEvent: (event) => handleRef.handle?.__dispatchEvent(event),
1084
1143
  onLeaderChange: (clientId) => {
1085
1144
  // Learn the leader's shared client id (best-effort; the handle exposes
@@ -1115,6 +1174,8 @@ async function bootFollower(
1115
1174
  const nextEpoch = follower.maxEpochSeen + 1;
1116
1175
  // Unbind the link so any late leader traffic is ignored, then promote.
1117
1176
  follower.unbind();
1177
+ // A newer tab may say hello before this core is installed.
1178
+ let newerTab = false;
1118
1179
  try {
1119
1180
  const core = await startWorkerCore({
1120
1181
  config,
@@ -1138,7 +1199,12 @@ async function bootFollower(
1138
1199
  channel: promoteChannel,
1139
1200
  epoch: nextEpoch,
1140
1201
  clientId,
1202
+ identity: tabIdentity(config),
1141
1203
  invoke,
1204
+ onNewerTab: () => {
1205
+ newerTab = true;
1206
+ handle.__yieldToNewerTab();
1207
+ },
1142
1208
  });
1143
1209
  },
1144
1210
  }
@@ -1152,6 +1218,7 @@ async function bootFollower(
1152
1218
  return;
1153
1219
  }
1154
1220
  handle.__becomeLeader(core);
1221
+ if (newerTab) handle.__yieldToNewerTab();
1155
1222
  } catch {
1156
1223
  // Promotion failed to spawn a worker — release so the next tab tries.
1157
1224
  await lease.release();
@@ -1193,7 +1260,14 @@ async function bootFollower(
1193
1260
  // the shared origin replica behind the same barrier before it is returned;
1194
1261
  // otherwise an already-running leader would silently ignore the request.
1195
1262
  if (config.securityPreflight === true) {
1196
- await handle.beginSecurityPreflight();
1263
+ try {
1264
+ await handle.beginSecurityPreflight();
1265
+ } catch (error) {
1266
+ // The caller never receives this handle, so it must not stay queued for
1267
+ // the lock and promote into a database nobody holds.
1268
+ await handle.close();
1269
+ throw error;
1270
+ }
1197
1271
  }
1198
1272
  return handle;
1199
1273
  }