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