@syncular/client 0.15.12 → 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.
@@ -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 lock = config.leaderLock ?? defaultLeaderLock();
676
- const lockName = config.lockName ?? 'syncular-leader';
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 (config.onRoleChange !== undefined) roleListeners.add(config.onRoleChange);
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(config, lockName, lease, {
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 (config.multiTab === false) {
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(config, lockName, lock, {
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
+ }