@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/src/worker-host.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|