@syncular/client 0.15.13 → 0.15.14
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 +66 -2
- package/dist/availability.d.ts +17 -0
- package/dist/availability.js +48 -0
- package/dist/client.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/invalidation.d.ts +1 -0
- package/dist/multi-tab.d.ts +24 -1
- package/dist/multi-tab.js +113 -4
- package/dist/reactive-store.d.ts +10 -1
- package/dist/reactive-store.js +107 -5
- package/dist/worker-host.d.ts +33 -1
- package/dist/worker-host.js +107 -7
- package/package.json +3 -3
- package/src/availability.ts +70 -0
- package/src/client.ts +1 -0
- package/src/index.ts +1 -0
- package/src/invalidation.ts +1 -0
- package/src/multi-tab.ts +158 -0
- package/src/reactive-store.ts +138 -7
- package/src/worker-host.ts +148 -6
package/src/worker-host.ts
CHANGED
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
type CrossTabChannel,
|
|
63
63
|
FollowerLink,
|
|
64
64
|
LeaderBridge,
|
|
65
|
+
type LeadershipState,
|
|
65
66
|
multiTabChannelName,
|
|
66
67
|
newTabId,
|
|
67
68
|
} from './multi-tab';
|
|
@@ -91,6 +92,41 @@ import {
|
|
|
91
92
|
|
|
92
93
|
export type HandleRole = 'leader' | 'follower';
|
|
93
94
|
|
|
95
|
+
export type BrowserReplicaMode =
|
|
96
|
+
| { readonly mode: 'shared' }
|
|
97
|
+
| { readonly mode: 'isolated'; readonly id: string };
|
|
98
|
+
|
|
99
|
+
export interface IsolatedReplicaNames {
|
|
100
|
+
readonly databaseName: string;
|
|
101
|
+
readonly databaseDirectory: string;
|
|
102
|
+
readonly lockName: string;
|
|
103
|
+
readonly channelName: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Derive the complete ownership tuple for an independently owned replica. */
|
|
107
|
+
export function isolatedReplicaNames(options: {
|
|
108
|
+
readonly databaseName: string;
|
|
109
|
+
readonly databaseDirectory?: string;
|
|
110
|
+
readonly lockName?: string;
|
|
111
|
+
readonly replicaId: string;
|
|
112
|
+
}): IsolatedReplicaNames {
|
|
113
|
+
if (!/^[A-Za-z0-9._-]+$/.test(options.replicaId)) {
|
|
114
|
+
throw new ClientSyncError(
|
|
115
|
+
'sync.invalid_request',
|
|
116
|
+
'an isolated replica id must contain only letters, numbers, dot, underscore, or dash',
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const suffix = `--replica-${options.replicaId}`;
|
|
120
|
+
const databaseName = `${options.databaseName}${suffix}`;
|
|
121
|
+
const lockName = `${options.lockName ?? 'syncular-leader'}${suffix}`;
|
|
122
|
+
return {
|
|
123
|
+
databaseName,
|
|
124
|
+
databaseDirectory: `${options.databaseDirectory ?? `.syncular/${options.databaseName}`}${suffix}`,
|
|
125
|
+
lockName,
|
|
126
|
+
channelName: multiTabChannelName(lockName),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
94
130
|
export interface SyncClientHandleConfig {
|
|
95
131
|
/**
|
|
96
132
|
* Spawns the worker running `startSyncWorker()` (a factory so bundlers
|
|
@@ -111,6 +147,8 @@ export interface SyncClientHandleConfig {
|
|
|
111
147
|
/** Default: Web Locks when available, else single-owner. */
|
|
112
148
|
readonly leaderLock?: LeaderLock;
|
|
113
149
|
readonly lockName?: string;
|
|
150
|
+
/** Shared by default; isolated derives the database/lock/channel tuple. */
|
|
151
|
+
readonly replica?: BrowserReplicaMode;
|
|
114
152
|
/**
|
|
115
153
|
* Multi-tab followers (TODO 3.2). On by default: a tab that loses the
|
|
116
154
|
* leader election becomes a FOLLOWER that proxies to the leader over a
|
|
@@ -125,6 +163,8 @@ export interface SyncClientHandleConfig {
|
|
|
125
163
|
readonly followerCallTimeoutMs?: number;
|
|
126
164
|
/** Fires when this handle's role changes (follower → leader on promotion). */
|
|
127
165
|
readonly onRoleChange?: (role: HandleRole) => void;
|
|
166
|
+
/** Fires when reachability or ownership changes without replacing the handle. */
|
|
167
|
+
readonly onLeadershipChange?: (state: LeadershipState) => void;
|
|
128
168
|
readonly onSyncNeeded?: (reason: 'startup' | 'hello' | WakeReason) => void;
|
|
129
169
|
readonly onConflict?: (conflict: ConflictRecord) => void;
|
|
130
170
|
/** A worker-side autoSync round finished (or failed). */
|
|
@@ -183,15 +223,28 @@ export class SyncClientHandle {
|
|
|
183
223
|
get clientId(): string {
|
|
184
224
|
return this.#clientId;
|
|
185
225
|
}
|
|
226
|
+
get currentSchemaVersion(): number {
|
|
227
|
+
return this.#currentSchemaVersion;
|
|
228
|
+
}
|
|
229
|
+
get leadership(): LeadershipState {
|
|
230
|
+
return this.#leadership;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
leadershipSnapshot(): LeadershipState {
|
|
234
|
+
return this.#leadership;
|
|
235
|
+
}
|
|
186
236
|
|
|
187
237
|
#role: HandleRole;
|
|
188
238
|
#clientId: string;
|
|
239
|
+
readonly #currentSchemaVersion: number;
|
|
240
|
+
#leadership: LeadershipState;
|
|
189
241
|
#core: LeaderCore | undefined;
|
|
190
242
|
#follower: FollowerLink | undefined;
|
|
191
243
|
readonly #invalidation: InvalidationEmitter;
|
|
192
244
|
readonly #changes: ChangeEmitter;
|
|
193
245
|
readonly #presence: Set<(scopeKey: string) => void>;
|
|
194
246
|
readonly #roleListeners: Set<(role: HandleRole) => void>;
|
|
247
|
+
readonly #leadershipListeners: Set<(state: LeadershipState) => void>;
|
|
195
248
|
readonly #devtoolsUnregister: () => void;
|
|
196
249
|
#closed = false;
|
|
197
250
|
|
|
@@ -199,21 +252,36 @@ export class SyncClientHandle {
|
|
|
199
252
|
constructor(internals: {
|
|
200
253
|
role: HandleRole;
|
|
201
254
|
clientId: string;
|
|
255
|
+
currentSchemaVersion: number;
|
|
202
256
|
core?: LeaderCore;
|
|
203
257
|
follower?: FollowerLink;
|
|
204
258
|
invalidation: InvalidationEmitter;
|
|
205
259
|
changes: ChangeEmitter;
|
|
206
260
|
presence: Set<(scopeKey: string) => void>;
|
|
207
261
|
roleListeners?: Set<(role: HandleRole) => void>;
|
|
262
|
+
leadershipListeners?: Set<(state: LeadershipState) => void>;
|
|
263
|
+
leadership?: LeadershipState;
|
|
208
264
|
}) {
|
|
209
265
|
this.#role = internals.role;
|
|
210
266
|
this.#clientId = internals.clientId;
|
|
267
|
+
this.#currentSchemaVersion = internals.currentSchemaVersion;
|
|
268
|
+
this.#leadership =
|
|
269
|
+
internals.leadership ??
|
|
270
|
+
(internals.role === 'leader'
|
|
271
|
+
? { state: 'leader', clientId: internals.clientId }
|
|
272
|
+
: (internals.follower?.leadershipState ?? {
|
|
273
|
+
state: 'blocked',
|
|
274
|
+
reason: 'leader-unreachable',
|
|
275
|
+
code: 'client.follower_timeout',
|
|
276
|
+
retryable: true,
|
|
277
|
+
}));
|
|
211
278
|
this.#core = internals.core;
|
|
212
279
|
this.#follower = internals.follower;
|
|
213
280
|
this.#invalidation = internals.invalidation;
|
|
214
281
|
this.#changes = internals.changes;
|
|
215
282
|
this.#presence = internals.presence;
|
|
216
283
|
this.#roleListeners = internals.roleListeners ?? new Set();
|
|
284
|
+
this.#leadershipListeners = internals.leadershipListeners ?? new Set();
|
|
217
285
|
// RFC 0002 §3.2: console introspection — a no-op outside a dev page.
|
|
218
286
|
this.#devtoolsUnregister = registerDevtools({
|
|
219
287
|
kind: 'handle',
|
|
@@ -237,6 +305,7 @@ export class SyncClientHandle {
|
|
|
237
305
|
this.#core = core;
|
|
238
306
|
this.#clientId = core.clientId;
|
|
239
307
|
this.#role = 'leader';
|
|
308
|
+
this.__setLeadership({ state: 'leader', clientId: core.clientId });
|
|
240
309
|
for (const listener of this.#roleListeners) {
|
|
241
310
|
try {
|
|
242
311
|
listener('leader');
|
|
@@ -246,6 +315,19 @@ export class SyncClientHandle {
|
|
|
246
315
|
}
|
|
247
316
|
}
|
|
248
317
|
|
|
318
|
+
/** @internal — apply a follower reachability snapshot in place. */
|
|
319
|
+
__setLeadership(state: LeadershipState): void {
|
|
320
|
+
this.#leadership = state;
|
|
321
|
+
if (state.state === 'follower') this.#clientId = state.leaderClientId;
|
|
322
|
+
for (const listener of this.#leadershipListeners) {
|
|
323
|
+
try {
|
|
324
|
+
listener(state);
|
|
325
|
+
} catch {
|
|
326
|
+
/* a UI listener must never break leadership transitions */
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
249
331
|
/** @internal — dispatch a worker/relayed event to handle-local listeners. */
|
|
250
332
|
__dispatchEvent(event: SyncWorkerEvent): void {
|
|
251
333
|
if (event.kind === 'presence') {
|
|
@@ -296,6 +378,13 @@ export class SyncClientHandle {
|
|
|
296
378
|
};
|
|
297
379
|
}
|
|
298
380
|
|
|
381
|
+
onLeadershipChange(listener: (state: LeadershipState) => void): () => void {
|
|
382
|
+
this.#leadershipListeners.add(listener);
|
|
383
|
+
return () => {
|
|
384
|
+
this.#leadershipListeners.delete(listener);
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
299
388
|
#call<M extends WorkerMethod>(
|
|
300
389
|
method: M,
|
|
301
390
|
args: Parameters<WorkerApi[M]>,
|
|
@@ -672,13 +761,19 @@ function fireConfigCallbacks(
|
|
|
672
761
|
export async function createSyncClientHandle(
|
|
673
762
|
config: SyncClientHandleConfig,
|
|
674
763
|
): Promise<SyncClientHandle> {
|
|
675
|
-
const
|
|
676
|
-
const
|
|
764
|
+
const resolvedConfig = resolveReplicaConfig(config);
|
|
765
|
+
const lock = resolvedConfig.leaderLock ?? defaultLeaderLock();
|
|
766
|
+
const lockName = resolvedConfig.lockName ?? 'syncular-leader';
|
|
677
767
|
const invalidation = new InvalidationEmitter();
|
|
678
768
|
const changes = new ChangeEmitter();
|
|
679
769
|
const presence = new Set<(scopeKey: string) => void>();
|
|
680
770
|
const roleListeners = new Set<(role: HandleRole) => void>();
|
|
681
|
-
if (
|
|
771
|
+
if (resolvedConfig.onRoleChange !== undefined)
|
|
772
|
+
roleListeners.add(resolvedConfig.onRoleChange);
|
|
773
|
+
const leadershipListeners = new Set<(state: LeadershipState) => void>();
|
|
774
|
+
if (resolvedConfig.onLeadershipChange !== undefined) {
|
|
775
|
+
leadershipListeners.add(resolvedConfig.onLeadershipChange);
|
|
776
|
+
}
|
|
682
777
|
|
|
683
778
|
// Leadership BEFORE the worker exists: one core per origin, and a losing
|
|
684
779
|
// tab never boots a database it must not own.
|
|
@@ -692,34 +787,38 @@ export async function createSyncClientHandle(
|
|
|
692
787
|
// Epoch derivation for a fresh boot: epoch 0. A promoter (below) reads
|
|
693
788
|
// the highest epoch it has seen and adds one, so leaders monotonically
|
|
694
789
|
// increase it across handovers.
|
|
695
|
-
return await bootLeader(
|
|
790
|
+
return await bootLeader(resolvedConfig, lockName, lease, {
|
|
696
791
|
epoch: 0,
|
|
697
792
|
invalidation,
|
|
698
793
|
changes,
|
|
699
794
|
presence,
|
|
700
795
|
roleListeners,
|
|
796
|
+
leadershipListeners,
|
|
701
797
|
});
|
|
702
798
|
}
|
|
703
799
|
|
|
704
800
|
// ---- Lost the election. ----
|
|
705
|
-
if (
|
|
801
|
+
if (resolvedConfig.multiTab === false) {
|
|
706
802
|
// Opted-out single-tab contract: a dead not-leader handle.
|
|
707
803
|
return new SyncClientHandle({
|
|
708
804
|
role: 'follower',
|
|
709
805
|
clientId: '',
|
|
806
|
+
currentSchemaVersion: resolvedConfig.schema.version,
|
|
710
807
|
invalidation,
|
|
711
808
|
changes,
|
|
712
809
|
presence,
|
|
713
810
|
roleListeners,
|
|
811
|
+
leadershipListeners,
|
|
714
812
|
});
|
|
715
813
|
}
|
|
716
814
|
|
|
717
815
|
// ---- Follower: proxy to the leader; contest + promote on its close. ----
|
|
718
|
-
return await bootFollower(
|
|
816
|
+
return await bootFollower(resolvedConfig, lockName, lock, {
|
|
719
817
|
invalidation,
|
|
720
818
|
changes,
|
|
721
819
|
presence,
|
|
722
820
|
roleListeners,
|
|
821
|
+
leadershipListeners,
|
|
723
822
|
});
|
|
724
823
|
}
|
|
725
824
|
|
|
@@ -729,6 +828,7 @@ interface HandleParts {
|
|
|
729
828
|
changes: ChangeEmitter;
|
|
730
829
|
presence: Set<(scopeKey: string) => void>;
|
|
731
830
|
roleListeners: Set<(role: HandleRole) => void>;
|
|
831
|
+
leadershipListeners: Set<(state: LeadershipState) => void>;
|
|
732
832
|
}
|
|
733
833
|
|
|
734
834
|
/** Boot (or promote to) a leader: spawn the worker, wire the bridge. */
|
|
@@ -761,6 +861,10 @@ async function bootLeader(
|
|
|
761
861
|
epoch: parts.epoch ?? 0,
|
|
762
862
|
clientId,
|
|
763
863
|
invoke,
|
|
864
|
+
heartbeatMs: Math.max(
|
|
865
|
+
10,
|
|
866
|
+
Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3),
|
|
867
|
+
),
|
|
764
868
|
});
|
|
765
869
|
}
|
|
766
870
|
: undefined;
|
|
@@ -776,11 +880,13 @@ async function bootLeader(
|
|
|
776
880
|
const handle = new SyncClientHandle({
|
|
777
881
|
role: 'leader',
|
|
778
882
|
clientId: core.clientId,
|
|
883
|
+
currentSchemaVersion: config.schema.version,
|
|
779
884
|
core,
|
|
780
885
|
invalidation: parts.invalidation,
|
|
781
886
|
changes: parts.changes,
|
|
782
887
|
presence: parts.presence,
|
|
783
888
|
roleListeners: parts.roleListeners,
|
|
889
|
+
leadershipListeners: parts.leadershipListeners,
|
|
784
890
|
});
|
|
785
891
|
handleRef.handle = handle;
|
|
786
892
|
return handle;
|
|
@@ -811,6 +917,7 @@ async function bootFollower(
|
|
|
811
917
|
// it after binding). Nothing else to do — calls already flush.
|
|
812
918
|
void clientId;
|
|
813
919
|
},
|
|
920
|
+
onStateChange: (state) => handleRef.handle?.__setLeadership(state),
|
|
814
921
|
...(config.followerCallTimeoutMs !== undefined
|
|
815
922
|
? { callTimeoutMs: config.followerCallTimeoutMs }
|
|
816
923
|
: {}),
|
|
@@ -855,6 +962,10 @@ async function bootFollower(
|
|
|
855
962
|
epoch: nextEpoch,
|
|
856
963
|
clientId,
|
|
857
964
|
invoke,
|
|
965
|
+
heartbeatMs: Math.max(
|
|
966
|
+
10,
|
|
967
|
+
Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3),
|
|
968
|
+
),
|
|
858
969
|
});
|
|
859
970
|
},
|
|
860
971
|
}
|
|
@@ -874,11 +985,13 @@ async function bootFollower(
|
|
|
874
985
|
// leave '' until promotion (the shared id is the leader's — hooks that
|
|
875
986
|
// need it read it after a round). Followers rarely need clientId directly.
|
|
876
987
|
clientId: '',
|
|
988
|
+
currentSchemaVersion: config.schema.version,
|
|
877
989
|
follower,
|
|
878
990
|
invalidation: parts.invalidation,
|
|
879
991
|
changes: parts.changes,
|
|
880
992
|
presence: parts.presence,
|
|
881
993
|
roleListeners: parts.roleListeners,
|
|
994
|
+
leadershipListeners: parts.leadershipListeners,
|
|
882
995
|
});
|
|
883
996
|
handleRef.handle = handle;
|
|
884
997
|
// Do not hand back a follower until its link has bound to the leader (the
|
|
@@ -896,3 +1009,32 @@ async function bootFollower(
|
|
|
896
1009
|
}
|
|
897
1010
|
return handle;
|
|
898
1011
|
}
|
|
1012
|
+
|
|
1013
|
+
function resolveReplicaConfig(
|
|
1014
|
+
config: SyncClientHandleConfig,
|
|
1015
|
+
): SyncClientHandleConfig {
|
|
1016
|
+
if (config.replica?.mode !== 'isolated') return config;
|
|
1017
|
+
if (config.database.mode !== 'persistent') {
|
|
1018
|
+
throw new ClientSyncError(
|
|
1019
|
+
'sync.invalid_request',
|
|
1020
|
+
'isolated browser replicas require a named persistent database',
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
const names = isolatedReplicaNames({
|
|
1024
|
+
databaseName: config.database.name,
|
|
1025
|
+
...(config.database.directory !== undefined
|
|
1026
|
+
? { databaseDirectory: config.database.directory }
|
|
1027
|
+
: {}),
|
|
1028
|
+
...(config.lockName !== undefined ? { lockName: config.lockName } : {}),
|
|
1029
|
+
replicaId: config.replica.id,
|
|
1030
|
+
});
|
|
1031
|
+
return {
|
|
1032
|
+
...config,
|
|
1033
|
+
lockName: names.lockName,
|
|
1034
|
+
database: {
|
|
1035
|
+
...config.database,
|
|
1036
|
+
name: names.databaseName,
|
|
1037
|
+
directory: names.databaseDirectory,
|
|
1038
|
+
},
|
|
1039
|
+
};
|
|
1040
|
+
}
|