@syncular/client 0.15.13 → 0.15.15

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.
@@ -143,7 +143,13 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
143
143
 
144
144
  function runAutoSync(): void {
145
145
  autoSyncScheduled = false;
146
- if (closed || client === undefined) return;
146
+ if (
147
+ closed ||
148
+ client === undefined ||
149
+ client.securityLifecycle === 'preflight'
150
+ ) {
151
+ return;
152
+ }
147
153
  const running = client;
148
154
  void serializedSync(() => running.syncUntilIdle())
149
155
  .then((summary) => {
@@ -160,7 +166,13 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
160
166
  }
161
167
 
162
168
  function consumeSyncIntent(intent: SyncIntent): void {
163
- if (!autoSync || closed || client === undefined || intent.kind === 'none') {
169
+ if (
170
+ !autoSync ||
171
+ closed ||
172
+ client === undefined ||
173
+ client.securityLifecycle === 'preflight' ||
174
+ intent.kind === 'none'
175
+ ) {
164
176
  return;
165
177
  }
166
178
  if (intent.kind === 'background') {
@@ -303,6 +315,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
303
315
  ...(config.encryption !== undefined
304
316
  ? { encryption: encryptionConfigFromKeyring(config.encryption) }
305
317
  : {}),
318
+ ...(config.securityPreflight !== undefined
319
+ ? { securityPreflight: config.securityPreflight }
320
+ : {}),
306
321
  onSyncNeeded: (reason) => {
307
322
  post({ t: 'event', event: { kind: 'sync-needed', reason } });
308
323
  consumeSyncIntent({ kind: 'interactive' });
@@ -344,6 +359,21 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
344
359
  }
345
360
 
346
361
  const api: WorkerApi = {
362
+ securityLifecycle: () => requireClient().securityLifecycle,
363
+ beginSecurityPreflight: async () => {
364
+ if (backgroundTimer !== undefined) clearTimeout(backgroundTimer);
365
+ backgroundTimer = undefined;
366
+ backgroundDue = Number.POSITIVE_INFINITY;
367
+ autoSyncScheduled = false;
368
+ await requireClient().beginSecurityPreflight();
369
+ },
370
+ activateSecurity: async (options = {}) => {
371
+ await requireClient().activateSecurity({
372
+ ...(options.encryption !== undefined
373
+ ? { encryption: encryptionConfigFromKeyring(options.encryption) }
374
+ : {}),
375
+ });
376
+ },
347
377
  subscribe: (input) => requireClient().subscribe(input),
348
378
  unsubscribe: (id) => requireClient().unsubscribe(id),
349
379
  setWindow: async (base, units) => {
@@ -32,6 +32,7 @@ import type {
32
32
  QuerySnapshot,
33
33
  RejectionRecord,
34
34
  SchemaFloor,
35
+ SecurityLifecycle,
35
36
  SubscribeInput,
36
37
  SyncClientLimits,
37
38
  SyncSummary,
@@ -62,6 +63,7 @@ import {
62
63
  type CrossTabChannel,
63
64
  FollowerLink,
64
65
  LeaderBridge,
66
+ type LeadershipState,
65
67
  multiTabChannelName,
66
68
  newTabId,
67
69
  } from './multi-tab';
@@ -86,11 +88,47 @@ import {
86
88
  type WorkerInitConfig,
87
89
  type WorkerInitResult,
88
90
  type WorkerMethod,
91
+ type WorkerSecurityActivation,
89
92
  type WorkerToMainMessage,
90
93
  } from './worker-protocol';
91
94
 
92
95
  export type HandleRole = 'leader' | 'follower';
93
96
 
97
+ export type BrowserReplicaMode =
98
+ | { readonly mode: 'shared' }
99
+ | { readonly mode: 'isolated'; readonly id: string };
100
+
101
+ export interface IsolatedReplicaNames {
102
+ readonly databaseName: string;
103
+ readonly databaseDirectory: string;
104
+ readonly lockName: string;
105
+ readonly channelName: string;
106
+ }
107
+
108
+ /** Derive the complete ownership tuple for an independently owned replica. */
109
+ export function isolatedReplicaNames(options: {
110
+ readonly databaseName: string;
111
+ readonly databaseDirectory?: string;
112
+ readonly lockName?: string;
113
+ readonly replicaId: string;
114
+ }): IsolatedReplicaNames {
115
+ if (!/^[A-Za-z0-9._-]+$/.test(options.replicaId)) {
116
+ throw new ClientSyncError(
117
+ 'sync.invalid_request',
118
+ 'an isolated replica id must contain only letters, numbers, dot, underscore, or dash',
119
+ );
120
+ }
121
+ const suffix = `--replica-${options.replicaId}`;
122
+ const databaseName = `${options.databaseName}${suffix}`;
123
+ const lockName = `${options.lockName ?? 'syncular-leader'}${suffix}`;
124
+ return {
125
+ databaseName,
126
+ databaseDirectory: `${options.databaseDirectory ?? `.syncular/${options.databaseName}`}${suffix}`,
127
+ lockName,
128
+ channelName: multiTabChannelName(lockName),
129
+ };
130
+ }
131
+
94
132
  export interface SyncClientHandleConfig {
95
133
  /**
96
134
  * Spawns the worker running `startSyncWorker()` (a factory so bundlers
@@ -104,6 +142,8 @@ export interface SyncClientHandleConfig {
104
142
  readonly endpoints: WorkerEndpoints;
105
143
  /** Structured-clone-safe E2EE keyring installed only in the leader worker. */
106
144
  readonly encryption?: EncryptionKeyringConfig;
145
+ /** Open the worker-owned replica behind the fail-closed security gate. */
146
+ readonly securityPreflight?: boolean;
107
147
  readonly clientId?: string;
108
148
  readonly limits?: SyncClientLimits;
109
149
  /** Worker-side host loop (§8.4); default true. */
@@ -111,6 +151,8 @@ export interface SyncClientHandleConfig {
111
151
  /** Default: Web Locks when available, else single-owner. */
112
152
  readonly leaderLock?: LeaderLock;
113
153
  readonly lockName?: string;
154
+ /** Shared by default; isolated derives the database/lock/channel tuple. */
155
+ readonly replica?: BrowserReplicaMode;
114
156
  /**
115
157
  * Multi-tab followers (TODO 3.2). On by default: a tab that loses the
116
158
  * leader election becomes a FOLLOWER that proxies to the leader over a
@@ -125,6 +167,8 @@ export interface SyncClientHandleConfig {
125
167
  readonly followerCallTimeoutMs?: number;
126
168
  /** Fires when this handle's role changes (follower → leader on promotion). */
127
169
  readonly onRoleChange?: (role: HandleRole) => void;
170
+ /** Fires when reachability or ownership changes without replacing the handle. */
171
+ readonly onLeadershipChange?: (state: LeadershipState) => void;
128
172
  readonly onSyncNeeded?: (reason: 'startup' | 'hello' | WakeReason) => void;
129
173
  readonly onConflict?: (conflict: ConflictRecord) => void;
130
174
  /** A worker-side autoSync round finished (or failed). */
@@ -183,15 +227,28 @@ export class SyncClientHandle {
183
227
  get clientId(): string {
184
228
  return this.#clientId;
185
229
  }
230
+ get currentSchemaVersion(): number {
231
+ return this.#currentSchemaVersion;
232
+ }
233
+ get leadership(): LeadershipState {
234
+ return this.#leadership;
235
+ }
236
+
237
+ leadershipSnapshot(): LeadershipState {
238
+ return this.#leadership;
239
+ }
186
240
 
187
241
  #role: HandleRole;
188
242
  #clientId: string;
243
+ readonly #currentSchemaVersion: number;
244
+ #leadership: LeadershipState;
189
245
  #core: LeaderCore | undefined;
190
246
  #follower: FollowerLink | undefined;
191
247
  readonly #invalidation: InvalidationEmitter;
192
248
  readonly #changes: ChangeEmitter;
193
249
  readonly #presence: Set<(scopeKey: string) => void>;
194
250
  readonly #roleListeners: Set<(role: HandleRole) => void>;
251
+ readonly #leadershipListeners: Set<(state: LeadershipState) => void>;
195
252
  readonly #devtoolsUnregister: () => void;
196
253
  #closed = false;
197
254
 
@@ -199,21 +256,36 @@ export class SyncClientHandle {
199
256
  constructor(internals: {
200
257
  role: HandleRole;
201
258
  clientId: string;
259
+ currentSchemaVersion: number;
202
260
  core?: LeaderCore;
203
261
  follower?: FollowerLink;
204
262
  invalidation: InvalidationEmitter;
205
263
  changes: ChangeEmitter;
206
264
  presence: Set<(scopeKey: string) => void>;
207
265
  roleListeners?: Set<(role: HandleRole) => void>;
266
+ leadershipListeners?: Set<(state: LeadershipState) => void>;
267
+ leadership?: LeadershipState;
208
268
  }) {
209
269
  this.#role = internals.role;
210
270
  this.#clientId = internals.clientId;
271
+ this.#currentSchemaVersion = internals.currentSchemaVersion;
272
+ this.#leadership =
273
+ internals.leadership ??
274
+ (internals.role === 'leader'
275
+ ? { state: 'leader', clientId: internals.clientId }
276
+ : (internals.follower?.leadershipState ?? {
277
+ state: 'blocked',
278
+ reason: 'leader-unreachable',
279
+ code: 'client.follower_timeout',
280
+ retryable: true,
281
+ }));
211
282
  this.#core = internals.core;
212
283
  this.#follower = internals.follower;
213
284
  this.#invalidation = internals.invalidation;
214
285
  this.#changes = internals.changes;
215
286
  this.#presence = internals.presence;
216
287
  this.#roleListeners = internals.roleListeners ?? new Set();
288
+ this.#leadershipListeners = internals.leadershipListeners ?? new Set();
217
289
  // RFC 0002 §3.2: console introspection — a no-op outside a dev page.
218
290
  this.#devtoolsUnregister = registerDevtools({
219
291
  kind: 'handle',
@@ -237,6 +309,7 @@ export class SyncClientHandle {
237
309
  this.#core = core;
238
310
  this.#clientId = core.clientId;
239
311
  this.#role = 'leader';
312
+ this.__setLeadership({ state: 'leader', clientId: core.clientId });
240
313
  for (const listener of this.#roleListeners) {
241
314
  try {
242
315
  listener('leader');
@@ -246,6 +319,19 @@ export class SyncClientHandle {
246
319
  }
247
320
  }
248
321
 
322
+ /** @internal — apply a follower reachability snapshot in place. */
323
+ __setLeadership(state: LeadershipState): void {
324
+ this.#leadership = state;
325
+ if (state.state === 'follower') this.#clientId = state.leaderClientId;
326
+ for (const listener of this.#leadershipListeners) {
327
+ try {
328
+ listener(state);
329
+ } catch {
330
+ /* a UI listener must never break leadership transitions */
331
+ }
332
+ }
333
+ }
334
+
249
335
  /** @internal — dispatch a worker/relayed event to handle-local listeners. */
250
336
  __dispatchEvent(event: SyncWorkerEvent): void {
251
337
  if (event.kind === 'presence') {
@@ -296,6 +382,13 @@ export class SyncClientHandle {
296
382
  };
297
383
  }
298
384
 
385
+ onLeadershipChange(listener: (state: LeadershipState) => void): () => void {
386
+ this.#leadershipListeners.add(listener);
387
+ return () => {
388
+ this.#leadershipListeners.delete(listener);
389
+ };
390
+ }
391
+
299
392
  #call<M extends WorkerMethod>(
300
393
  method: M,
301
394
  args: Parameters<WorkerApi[M]>,
@@ -334,6 +427,18 @@ export class SyncClientHandle {
334
427
  return this.#call('subscribe', [input]);
335
428
  }
336
429
 
430
+ securityLifecycle(): Promise<SecurityLifecycle> {
431
+ return this.#call('securityLifecycle', []);
432
+ }
433
+
434
+ beginSecurityPreflight(): Promise<void> {
435
+ return this.#call('beginSecurityPreflight', []);
436
+ }
437
+
438
+ activateSecurity(options: WorkerSecurityActivation = {}): Promise<void> {
439
+ return this.#call('activateSecurity', [options]);
440
+ }
441
+
337
442
  unsubscribe(id: string): Promise<void> {
338
443
  return this.#call('unsubscribe', [id]);
339
444
  }
@@ -635,6 +740,9 @@ function buildInitConfig(config: SyncClientHandleConfig): WorkerInitConfig {
635
740
  ...(config.encryption !== undefined
636
741
  ? { encryption: config.encryption }
637
742
  : {}),
743
+ ...(config.securityPreflight !== undefined
744
+ ? { securityPreflight: config.securityPreflight }
745
+ : {}),
638
746
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
639
747
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
640
748
  ...(config.autoSync !== undefined ? { autoSync: config.autoSync } : {}),
@@ -672,13 +780,19 @@ function fireConfigCallbacks(
672
780
  export async function createSyncClientHandle(
673
781
  config: SyncClientHandleConfig,
674
782
  ): Promise<SyncClientHandle> {
675
- const lock = config.leaderLock ?? defaultLeaderLock();
676
- const lockName = config.lockName ?? 'syncular-leader';
783
+ const resolvedConfig = resolveReplicaConfig(config);
784
+ const lock = resolvedConfig.leaderLock ?? defaultLeaderLock();
785
+ const lockName = resolvedConfig.lockName ?? 'syncular-leader';
677
786
  const invalidation = new InvalidationEmitter();
678
787
  const changes = new ChangeEmitter();
679
788
  const presence = new Set<(scopeKey: string) => void>();
680
789
  const roleListeners = new Set<(role: HandleRole) => void>();
681
- if (config.onRoleChange !== undefined) roleListeners.add(config.onRoleChange);
790
+ if (resolvedConfig.onRoleChange !== undefined)
791
+ roleListeners.add(resolvedConfig.onRoleChange);
792
+ const leadershipListeners = new Set<(state: LeadershipState) => void>();
793
+ if (resolvedConfig.onLeadershipChange !== undefined) {
794
+ leadershipListeners.add(resolvedConfig.onLeadershipChange);
795
+ }
682
796
 
683
797
  // Leadership BEFORE the worker exists: one core per origin, and a losing
684
798
  // tab never boots a database it must not own.
@@ -692,34 +806,38 @@ export async function createSyncClientHandle(
692
806
  // Epoch derivation for a fresh boot: epoch 0. A promoter (below) reads
693
807
  // the highest epoch it has seen and adds one, so leaders monotonically
694
808
  // increase it across handovers.
695
- return await bootLeader(config, lockName, lease, {
809
+ return await bootLeader(resolvedConfig, lockName, lease, {
696
810
  epoch: 0,
697
811
  invalidation,
698
812
  changes,
699
813
  presence,
700
814
  roleListeners,
815
+ leadershipListeners,
701
816
  });
702
817
  }
703
818
 
704
819
  // ---- Lost the election. ----
705
- if (config.multiTab === false) {
820
+ if (resolvedConfig.multiTab === false) {
706
821
  // Opted-out single-tab contract: a dead not-leader handle.
707
822
  return new SyncClientHandle({
708
823
  role: 'follower',
709
824
  clientId: '',
825
+ currentSchemaVersion: resolvedConfig.schema.version,
710
826
  invalidation,
711
827
  changes,
712
828
  presence,
713
829
  roleListeners,
830
+ leadershipListeners,
714
831
  });
715
832
  }
716
833
 
717
834
  // ---- Follower: proxy to the leader; contest + promote on its close. ----
718
- return await bootFollower(config, lockName, lock, {
835
+ return await bootFollower(resolvedConfig, lockName, lock, {
719
836
  invalidation,
720
837
  changes,
721
838
  presence,
722
839
  roleListeners,
840
+ leadershipListeners,
723
841
  });
724
842
  }
725
843
 
@@ -729,6 +847,7 @@ interface HandleParts {
729
847
  changes: ChangeEmitter;
730
848
  presence: Set<(scopeKey: string) => void>;
731
849
  roleListeners: Set<(role: HandleRole) => void>;
850
+ leadershipListeners: Set<(state: LeadershipState) => void>;
732
851
  }
733
852
 
734
853
  /** Boot (or promote to) a leader: spawn the worker, wire the bridge. */
@@ -761,6 +880,10 @@ async function bootLeader(
761
880
  epoch: parts.epoch ?? 0,
762
881
  clientId,
763
882
  invoke,
883
+ heartbeatMs: Math.max(
884
+ 10,
885
+ Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3),
886
+ ),
764
887
  });
765
888
  }
766
889
  : undefined;
@@ -776,11 +899,13 @@ async function bootLeader(
776
899
  const handle = new SyncClientHandle({
777
900
  role: 'leader',
778
901
  clientId: core.clientId,
902
+ currentSchemaVersion: config.schema.version,
779
903
  core,
780
904
  invalidation: parts.invalidation,
781
905
  changes: parts.changes,
782
906
  presence: parts.presence,
783
907
  roleListeners: parts.roleListeners,
908
+ leadershipListeners: parts.leadershipListeners,
784
909
  });
785
910
  handleRef.handle = handle;
786
911
  return handle;
@@ -811,6 +936,7 @@ async function bootFollower(
811
936
  // it after binding). Nothing else to do — calls already flush.
812
937
  void clientId;
813
938
  },
939
+ onStateChange: (state) => handleRef.handle?.__setLeadership(state),
814
940
  ...(config.followerCallTimeoutMs !== undefined
815
941
  ? { callTimeoutMs: config.followerCallTimeoutMs }
816
942
  : {}),
@@ -855,6 +981,10 @@ async function bootFollower(
855
981
  epoch: nextEpoch,
856
982
  clientId,
857
983
  invoke,
984
+ heartbeatMs: Math.max(
985
+ 10,
986
+ Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3),
987
+ ),
858
988
  });
859
989
  },
860
990
  }
@@ -874,11 +1004,13 @@ async function bootFollower(
874
1004
  // leave '' until promotion (the shared id is the leader's — hooks that
875
1005
  // need it read it after a round). Followers rarely need clientId directly.
876
1006
  clientId: '',
1007
+ currentSchemaVersion: config.schema.version,
877
1008
  follower,
878
1009
  invalidation: parts.invalidation,
879
1010
  changes: parts.changes,
880
1011
  presence: parts.presence,
881
1012
  roleListeners: parts.roleListeners,
1013
+ leadershipListeners: parts.leadershipListeners,
882
1014
  });
883
1015
  handleRef.handle = handle;
884
1016
  // Do not hand back a follower until its link has bound to the leader (the
@@ -894,5 +1026,41 @@ async function bootFollower(
894
1026
  } catch {
895
1027
  /* bind timed out — return the (degraded but functional) handle anyway */
896
1028
  }
1029
+ // Init configuration belongs to the one leader worker. A newly opened
1030
+ // follower that explicitly requests security preflight must therefore put
1031
+ // the shared origin replica behind the same barrier before it is returned;
1032
+ // otherwise an already-running leader would silently ignore the request.
1033
+ if (config.securityPreflight === true) {
1034
+ await handle.beginSecurityPreflight();
1035
+ }
897
1036
  return handle;
898
1037
  }
1038
+
1039
+ function resolveReplicaConfig(
1040
+ config: SyncClientHandleConfig,
1041
+ ): SyncClientHandleConfig {
1042
+ if (config.replica?.mode !== 'isolated') return config;
1043
+ if (config.database.mode !== 'persistent') {
1044
+ throw new ClientSyncError(
1045
+ 'sync.invalid_request',
1046
+ 'isolated browser replicas require a named persistent database',
1047
+ );
1048
+ }
1049
+ const names = isolatedReplicaNames({
1050
+ databaseName: config.database.name,
1051
+ ...(config.database.directory !== undefined
1052
+ ? { databaseDirectory: config.database.directory }
1053
+ : {}),
1054
+ ...(config.lockName !== undefined ? { lockName: config.lockName } : {}),
1055
+ replicaId: config.replica.id,
1056
+ });
1057
+ return {
1058
+ ...config,
1059
+ lockName: names.lockName,
1060
+ database: {
1061
+ ...config.database,
1062
+ name: names.databaseName,
1063
+ directory: names.databaseDirectory,
1064
+ },
1065
+ };
1066
+ }
@@ -27,6 +27,7 @@ import type {
27
27
  QuerySnapshot,
28
28
  RejectionRecord,
29
29
  SchemaFloor,
30
+ SecurityLifecycle,
30
31
  SubscribeInput,
31
32
  SyncClientLimits,
32
33
  SyncSummary,
@@ -100,6 +101,8 @@ export interface WorkerInitConfig {
100
101
  readonly endpoints: WorkerEndpoints;
101
102
  /** Portable raw keyring installed inside the worker-owned client core. */
102
103
  readonly encryption?: EncryptionKeyringConfig;
104
+ /** Open the worker-owned replica behind the fail-closed security gate. */
105
+ readonly securityPreflight?: boolean;
103
106
  readonly clientId?: string;
104
107
  readonly limits?: SyncClientLimits;
105
108
  /**
@@ -115,11 +118,19 @@ export interface WorkerInitResult {
115
118
  readonly clientId: string;
116
119
  }
117
120
 
121
+ /** Structured-clone-safe key material installed at security activation. */
122
+ export interface WorkerSecurityActivation {
123
+ readonly encryption?: EncryptionKeyringConfig;
124
+ }
125
+
118
126
  // ---------------------------------------------------------------------------
119
127
  // The logical API — the one shared shape (worker implements, handle projects)
120
128
  // ---------------------------------------------------------------------------
121
129
 
122
130
  export interface WorkerApi {
131
+ securityLifecycle(): SecurityLifecycle;
132
+ beginSecurityPreflight(): Promise<void>;
133
+ activateSecurity(options?: WorkerSecurityActivation): Promise<void>;
123
134
  subscribe(input: SubscribeInput): void;
124
135
  unsubscribe(id: string): void;
125
136
  /** §4.8 windowed subscriptions: set the live units for a window base. */