@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.
@@ -1,3 +1,4 @@
1
+ import { classifySyncAvailability, } from './availability.js';
1
2
  import { windowBaseKey } from './window.js';
2
3
  function errorOf(value) {
3
4
  return value instanceof Error ? value : new Error(String(value));
@@ -62,6 +63,19 @@ function encodeCanonical(value, stack) {
62
63
  export function canonicalValue(value) {
63
64
  return encodeCanonical(value, new Set());
64
65
  }
66
+ const reactiveFunctionIds = new WeakMap();
67
+ let nextReactiveFunctionId = 1;
68
+ function reactiveFunctionId(fn) {
69
+ if (fn === undefined)
70
+ return undefined;
71
+ let id = reactiveFunctionIds.get(fn);
72
+ if (id === undefined) {
73
+ id = nextReactiveFunctionId;
74
+ nextReactiveFunctionId += 1;
75
+ reactiveFunctionIds.set(fn, id);
76
+ }
77
+ return id;
78
+ }
65
79
  function scheduleMicrotask(task) {
66
80
  if (typeof queueMicrotask === 'function')
67
81
  queueMicrotask(task);
@@ -175,6 +189,7 @@ class QueryEntry {
175
189
  revision: undefined,
176
190
  error: undefined,
177
191
  isRefreshing: false,
192
+ availability: { state: 'ready' },
178
193
  };
179
194
  #subscribers = 0;
180
195
  #scheduled = false;
@@ -182,6 +197,7 @@ class QueryEntry {
182
197
  #requested = false;
183
198
  #desiredRevision = 0n;
184
199
  #claimReady = Promise.resolve();
200
+ #offStatus;
185
201
  constructor(store, spec) {
186
202
  this.store = store;
187
203
  this.spec = spec;
@@ -191,6 +207,8 @@ class QueryEntry {
191
207
  this.#listeners.add(listener);
192
208
  this.#subscribers += 1;
193
209
  if (this.#subscribers === 1) {
210
+ this.#offStatus = this.store.status.subscribe(() => this.#onAvailabilityChange());
211
+ this.#onAvailabilityChange();
194
212
  if (this.spec.claimCoverage !== false) {
195
213
  const claims = [];
196
214
  for (const coverage of this.spec.coverage ?? []) {
@@ -204,8 +222,11 @@ class QueryEntry {
204
222
  if (!this.#listeners.delete(listener))
205
223
  return;
206
224
  this.#subscribers -= 1;
207
- if (this.#subscribers === 0)
225
+ if (this.#subscribers === 0) {
226
+ this.#offStatus?.();
227
+ this.#offStatus = undefined;
208
228
  this.store.releaseWindowClaims(this.#owner);
229
+ }
209
230
  };
210
231
  };
211
232
  refresh = () => this.#requestRead(true);
@@ -222,7 +243,9 @@ class QueryEntry {
222
243
  if (next.rows === this.#state.rows &&
223
244
  next.phase === this.#state.phase &&
224
245
  next.error === this.#state.error &&
225
- next.isRefreshing === this.#state.isRefreshing) {
246
+ next.isRefreshing === this.#state.isRefreshing &&
247
+ canonicalValue(next.availability) ===
248
+ canonicalValue(this.#state.availability)) {
226
249
  return;
227
250
  }
228
251
  this.#state = next;
@@ -244,6 +267,30 @@ class QueryEntry {
244
267
  void this.#readLoop();
245
268
  });
246
269
  }
270
+ #onAvailabilityChange() {
271
+ const availability = this.store.availabilitySnapshot();
272
+ if (availability.state === 'blocked') {
273
+ this.#publish({
274
+ ...this.#state,
275
+ phase: 'blocked',
276
+ availability,
277
+ isRefreshing: false,
278
+ });
279
+ return;
280
+ }
281
+ const wasBlocked = this.#state.phase === 'blocked';
282
+ this.#publish({
283
+ ...this.#state,
284
+ phase: wasBlocked
285
+ ? this.#state.rows.length > 0
286
+ ? 'partial'
287
+ : 'loading'
288
+ : this.#state.phase,
289
+ availability,
290
+ });
291
+ if (wasBlocked)
292
+ this.#requestRead();
293
+ }
247
294
  async #readLoop() {
248
295
  if (this.#running || this.#subscribers === 0)
249
296
  return;
@@ -251,6 +298,8 @@ class QueryEntry {
251
298
  try {
252
299
  do {
253
300
  this.#requested = false;
301
+ if (this.store.availabilitySnapshot().state === 'blocked')
302
+ break;
254
303
  await this.#claimReady;
255
304
  const snapshot = await this.store.client.querySnapshot({
256
305
  sql: this.spec.sql,
@@ -261,11 +310,24 @@ class QueryEntry {
261
310
  ? { coverage: this.spec.coverage }
262
311
  : {}),
263
312
  });
313
+ const availability = this.store.availabilitySnapshot();
314
+ if (availability.state === 'blocked') {
315
+ this.#publish({
316
+ ...this.#state,
317
+ phase: 'blocked',
318
+ availability,
319
+ isRefreshing: false,
320
+ });
321
+ break;
322
+ }
264
323
  if (snapshot.revision < this.#desiredRevision) {
265
324
  this.#requested = true;
266
325
  continue;
267
326
  }
268
- const rows = reconcileRows(this.#state.rows, snapshot.rows, this.spec.rowKey);
327
+ const mappedRows = this.spec.mapRow === undefined
328
+ ? snapshot.rows
329
+ : snapshot.rows.map(this.spec.mapRow);
330
+ const rows = reconcileRows(this.#state.rows, mappedRows, this.spec.rowKey);
269
331
  const phase = snapshot.coverage.complete
270
332
  ? 'ready'
271
333
  : rows.length > 0
@@ -277,6 +339,7 @@ class QueryEntry {
277
339
  revision: snapshot.revision,
278
340
  error: undefined,
279
341
  isRefreshing: false,
342
+ availability,
280
343
  });
281
344
  } while (this.#requested && this.#subscribers > 0);
282
345
  }
@@ -287,6 +350,7 @@ class QueryEntry {
287
350
  phase: this.#state.revision === undefined ? 'error' : this.#state.phase,
288
351
  error: wrapped,
289
352
  isRefreshing: false,
353
+ availability: this.store.availabilitySnapshot(),
290
354
  });
291
355
  }
292
356
  finally {
@@ -383,21 +447,33 @@ export class ReactiveClientStore {
383
447
  #windows = new Map();
384
448
  #windowClaims = new Map();
385
449
  #offChange;
450
+ #offLeadership;
386
451
  status;
387
452
  conflicts;
388
453
  outcomes;
389
454
  constructor(client) {
390
455
  this.client = client;
391
- const status = new ValueEntry({ status: undefined, error: undefined, isLoading: true }, async () => {
456
+ const status = new ValueEntry({
457
+ status: undefined,
458
+ leadership: client.leadershipSnapshot?.(),
459
+ error: undefined,
460
+ isLoading: true,
461
+ }, async () => {
392
462
  try {
393
463
  return {
394
464
  status: await client.statusSnapshot(),
465
+ leadership: client.leadershipSnapshot?.(),
395
466
  error: undefined,
396
467
  isLoading: false,
397
468
  };
398
469
  }
399
470
  catch (error) {
400
- return { status: undefined, error: errorOf(error), isLoading: false };
471
+ return {
472
+ status: undefined,
473
+ leadership: client.leadershipSnapshot?.(),
474
+ error: errorOf(error),
475
+ isLoading: false,
476
+ };
401
477
  }
402
478
  });
403
479
  const conflicts = new ValueEntry({ conflicts: [], rejections: [], error: undefined, isLoading: true }, async () => {
@@ -453,6 +529,7 @@ export class ReactiveClientStore {
453
529
  baseKey: windowBaseKey(item.base),
454
530
  units: [...new Set(item.units)].sort(),
455
531
  }));
532
+ const mapRowId = reactiveFunctionId(spec.mapRow);
456
533
  const key = canonicalValue({
457
534
  id: spec.id,
458
535
  sql: spec.sql,
@@ -460,6 +537,7 @@ export class ReactiveClientStore {
460
537
  dependencies,
461
538
  coverage,
462
539
  claimCoverage: spec.claimCoverage !== false,
540
+ ...(mapRowId === undefined ? {} : { mapRow: mapRowId }),
463
541
  });
464
542
  let entry = this.#queries.get(key);
465
543
  if (entry === undefined) {
@@ -468,6 +546,20 @@ export class ReactiveClientStore {
468
546
  }
469
547
  return entry;
470
548
  }
549
+ availabilitySnapshot() {
550
+ const snapshot = this.status.getSnapshot();
551
+ if (snapshot.status === undefined) {
552
+ return snapshot.leadership?.state === 'blocked'
553
+ ? {
554
+ state: 'blocked',
555
+ reason: 'leader-unreachable',
556
+ currentSchemaVersion: this.client.currentSchemaVersion ?? 0,
557
+ retryable: true,
558
+ }
559
+ : { state: 'ready' };
560
+ }
561
+ return classifySyncAvailability(snapshot.status, snapshot.leadership);
562
+ }
471
563
  /** Retain a composable window working set outside React. The returned
472
564
  * handle exposes registration completion and releases only this owner. */
473
565
  retainWindow(base, units) {
@@ -571,6 +663,7 @@ export class ReactiveClientStore {
571
663
  if (batch.status !== undefined) {
572
664
  this.status.set({
573
665
  status: batch.status,
666
+ leadership: this.client.leadershipSnapshot?.(),
574
667
  error: undefined,
575
668
  isLoading: false,
576
669
  });
@@ -581,10 +674,19 @@ export class ReactiveClientStore {
581
674
  if (batch.outcomesChanged)
582
675
  this.outcomes.refresh();
583
676
  });
677
+ this.#offLeadership = this.client.onLeadershipChange?.((leadership) => {
678
+ const previous = this.status.getSnapshot();
679
+ this.status.set({
680
+ ...previous,
681
+ leadership,
682
+ });
683
+ });
584
684
  }
585
685
  dispose() {
586
686
  this.#offChange?.();
587
687
  this.#offChange = undefined;
688
+ this.#offLeadership?.();
689
+ this.#offLeadership = undefined;
588
690
  for (const group of this.#windowClaims.values()) {
589
691
  void Promise.resolve(this.client.setWindow(group.base, []));
590
692
  }
package/dist/state.d.ts CHANGED
@@ -42,9 +42,10 @@ export declare function resetSubscriptionsForBump(db: ClientDatabase): void;
42
42
  * Remove registrations whose table no longer exists in the running schema.
43
43
  * Keeping one would make every subsequent pull fail with
44
44
  * `sync.unknown_table`. Window bookkeeping belongs to the registration and
45
- * is removed with it.
45
+ * is removed with it. The caller supplies its startup subscription snapshot
46
+ * so pruning and startup-work detection stay a single read.
46
47
  */
47
- export declare function pruneUnknownSubscriptions(db: ClientDatabase, tableNames: ReadonlySet<string>): void;
48
+ export declare function pruneUnknownSubscriptions(db: ClientDatabase, subscriptions: readonly SubscriptionRecord[], tableNames: ReadonlySet<string>): SubscriptionRecord[];
48
49
  export declare function getMeta(db: ClientDatabase, key: string): string | undefined;
49
50
  export declare function setMeta(db: ClientDatabase, key: string, value: string): void;
50
51
  /** Read the durable client-local observer revision (SPEC §7.5). */
package/dist/state.js CHANGED
@@ -69,20 +69,30 @@ export function resetSubscriptionsForBump(db) {
69
69
  * Remove registrations whose table no longer exists in the running schema.
70
70
  * Keeping one would make every subsequent pull fail with
71
71
  * `sync.unknown_table`. Window bookkeeping belongs to the registration and
72
- * is removed with it.
72
+ * is removed with it. The caller supplies its startup subscription snapshot
73
+ * so pruning and startup-work detection stay a single read.
73
74
  */
74
- export function pruneUnknownSubscriptions(db, tableNames) {
75
- const staleIds = db
76
- .query('SELECT id, tbl FROM _syncular_subscriptions')
77
- .filter((row) => !tableNames.has(String(row.tbl)))
78
- .map((row) => String(row.id));
79
- for (const id of staleIds) {
80
- db.exec('DELETE FROM _syncular_windows WHERE sub_id = ?', [id]);
81
- db.exec('DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?', [
82
- id,
83
- ]);
84
- db.exec('DELETE FROM _syncular_subscriptions WHERE id = ?', [id]);
85
- }
75
+ export function pruneUnknownSubscriptions(db, subscriptions, tableNames) {
76
+ const retained = subscriptions.filter((record) => tableNames.has(record.table));
77
+ const staleIds = subscriptions
78
+ .filter((record) => !tableNames.has(record.table))
79
+ .map((record) => record.id);
80
+ // A redundant subscription scan plus an empty write transaction cut the
81
+ // fresh-client bootstrap lane by more than half on bun:sqlite. The supplied
82
+ // snapshot and this branch are synchronous, so no application work can
83
+ // interleave before a real pruning transaction begins.
84
+ if (staleIds.length === 0)
85
+ return retained;
86
+ db.transaction(() => {
87
+ for (const id of staleIds) {
88
+ db.exec('DELETE FROM _syncular_windows WHERE sub_id = ?', [id]);
89
+ db.exec('DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?', [
90
+ id,
91
+ ]);
92
+ db.exec('DELETE FROM _syncular_subscriptions WHERE id = ?', [id]);
93
+ }
94
+ });
95
+ return retained;
86
96
  }
87
97
  export function getMeta(db, key) {
88
98
  const row = db.query('SELECT value FROM _syncular_meta WHERE key = ?', [
@@ -29,7 +29,7 @@ import type { EncryptionKeyringConfig } from './encryption.js';
29
29
  import { ChangeEmitter, type ClientChangeListener, InvalidationEmitter, type InvalidationListener, type LocalRevision, type SyncStatusSnapshot } from './invalidation.js';
30
30
  import { type LeaderLease, type LeaderLock } from './leader-lock.js';
31
31
  import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge.js';
32
- import { type CrossTabChannel, FollowerLink, LeaderBridge } from './multi-tab.js';
32
+ import { type CrossTabChannel, FollowerLink, LeaderBridge, type LeadershipState } from './multi-tab.js';
33
33
  import type { OutboxCommit } from './outbox.js';
34
34
  import type { CommitOutcome, CommitOutcomeQuery, ResolveCommitOutcomeInput } from './outcomes.js';
35
35
  import type { ClientSchema } from './schema.js';
@@ -37,6 +37,25 @@ import type { SubscriptionRecord } from './state.js';
37
37
  import type { WindowBase } from './window.js';
38
38
  import { type SyncWorkerEvent, type WorkerDatabaseInit, type WorkerEndpoints, type WorkerErrorShape } from './worker-protocol.js';
39
39
  export type HandleRole = 'leader' | 'follower';
40
+ export type BrowserReplicaMode = {
41
+ readonly mode: 'shared';
42
+ } | {
43
+ readonly mode: 'isolated';
44
+ readonly id: string;
45
+ };
46
+ export interface IsolatedReplicaNames {
47
+ readonly databaseName: string;
48
+ readonly databaseDirectory: string;
49
+ readonly lockName: string;
50
+ readonly channelName: string;
51
+ }
52
+ /** Derive the complete ownership tuple for an independently owned replica. */
53
+ export declare function isolatedReplicaNames(options: {
54
+ readonly databaseName: string;
55
+ readonly databaseDirectory?: string;
56
+ readonly lockName?: string;
57
+ readonly replicaId: string;
58
+ }): IsolatedReplicaNames;
40
59
  export interface SyncClientHandleConfig {
41
60
  /**
42
61
  * Spawns the worker running `startSyncWorker()` (a factory so bundlers
@@ -57,6 +76,8 @@ export interface SyncClientHandleConfig {
57
76
  /** Default: Web Locks when available, else single-owner. */
58
77
  readonly leaderLock?: LeaderLock;
59
78
  readonly lockName?: string;
79
+ /** Shared by default; isolated derives the database/lock/channel tuple. */
80
+ readonly replica?: BrowserReplicaMode;
60
81
  /**
61
82
  * Multi-tab followers (TODO 3.2). On by default: a tab that loses the
62
83
  * leader election becomes a FOLLOWER that proxies to the leader over a
@@ -71,6 +92,8 @@ export interface SyncClientHandleConfig {
71
92
  readonly followerCallTimeoutMs?: number;
72
93
  /** Fires when this handle's role changes (follower → leader on promotion). */
73
94
  readonly onRoleChange?: (role: HandleRole) => void;
95
+ /** Fires when reachability or ownership changes without replacing the handle. */
96
+ readonly onLeadershipChange?: (state: LeadershipState) => void;
74
97
  readonly onSyncNeeded?: (reason: 'startup' | 'hello' | WakeReason) => void;
75
98
  readonly onConflict?: (conflict: ConflictRecord) => void;
76
99
  /** A worker-side autoSync round finished (or failed). */
@@ -107,19 +130,27 @@ export declare class SyncClientHandle {
107
130
  get role(): HandleRole;
108
131
  /** Resolved client id — the leader's; shared by all tabs on this origin. */
109
132
  get clientId(): string;
133
+ get currentSchemaVersion(): number;
134
+ get leadership(): LeadershipState;
135
+ leadershipSnapshot(): LeadershipState;
110
136
  /** @internal — use {@link createSyncClientHandle}. */
111
137
  constructor(internals: {
112
138
  role: HandleRole;
113
139
  clientId: string;
140
+ currentSchemaVersion: number;
114
141
  core?: LeaderCore;
115
142
  follower?: FollowerLink;
116
143
  invalidation: InvalidationEmitter;
117
144
  changes: ChangeEmitter;
118
145
  presence: Set<(scopeKey: string) => void>;
119
146
  roleListeners?: Set<(role: HandleRole) => void>;
147
+ leadershipListeners?: Set<(state: LeadershipState) => void>;
148
+ leadership?: LeadershipState;
120
149
  });
121
150
  /** @internal — swap this handle from follower to leader (promotion). */
122
151
  __becomeLeader(core: LeaderCore): void;
152
+ /** @internal — apply a follower reachability snapshot in place. */
153
+ __setLeadership(state: LeadershipState): void;
123
154
  /** @internal — dispatch a worker/relayed event to handle-local listeners. */
124
155
  __dispatchEvent(event: SyncWorkerEvent): void;
125
156
  /**
@@ -137,6 +168,7 @@ export declare class SyncClientHandle {
137
168
  onPresence(listener: (scopeKey: string) => void): () => void;
138
169
  /** Subscribe to role transitions (follower → leader on promotion). */
139
170
  onRoleChange(listener: (role: HandleRole) => void): () => void;
171
+ onLeadershipChange(listener: (state: LeadershipState) => void): () => void;
140
172
  subscribe(input: SubscribeInput): Promise<void>;
141
173
  unsubscribe(id: string): Promise<void>;
142
174
  setWindow(base: WindowBase, units: readonly string[]): Promise<void>;
@@ -4,6 +4,21 @@ import { ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './i
4
4
  import { singleOwnerLock, webLocksLeaderLock, } from './leader-lock.js';
5
5
  import { broadcastChannelFactory, FollowerLink, LeaderBridge, multiTabChannelName, newTabId, } from './multi-tab.js';
6
6
  import { NOT_LEADER_CODE, WORKER_FAILED_CODE, } from './worker-protocol.js';
7
+ /** Derive the complete ownership tuple for an independently owned replica. */
8
+ export function isolatedReplicaNames(options) {
9
+ if (!/^[A-Za-z0-9._-]+$/.test(options.replicaId)) {
10
+ throw new ClientSyncError('sync.invalid_request', 'an isolated replica id must contain only letters, numbers, dot, underscore, or dash');
11
+ }
12
+ const suffix = `--replica-${options.replicaId}`;
13
+ const databaseName = `${options.databaseName}${suffix}`;
14
+ const lockName = `${options.lockName ?? 'syncular-leader'}${suffix}`;
15
+ return {
16
+ databaseName,
17
+ databaseDirectory: `${options.databaseDirectory ?? `.syncular/${options.databaseName}`}${suffix}`,
18
+ lockName,
19
+ channelName: multiTabChannelName(lockName),
20
+ };
21
+ }
7
22
  function defaultLeaderLock() {
8
23
  const nav = globalThis.navigator;
9
24
  return nav?.locks !== undefined
@@ -27,26 +42,50 @@ export class SyncClientHandle {
27
42
  get clientId() {
28
43
  return this.#clientId;
29
44
  }
45
+ get currentSchemaVersion() {
46
+ return this.#currentSchemaVersion;
47
+ }
48
+ get leadership() {
49
+ return this.#leadership;
50
+ }
51
+ leadershipSnapshot() {
52
+ return this.#leadership;
53
+ }
30
54
  #role;
31
55
  #clientId;
56
+ #currentSchemaVersion;
57
+ #leadership;
32
58
  #core;
33
59
  #follower;
34
60
  #invalidation;
35
61
  #changes;
36
62
  #presence;
37
63
  #roleListeners;
64
+ #leadershipListeners;
38
65
  #devtoolsUnregister;
39
66
  #closed = false;
40
67
  /** @internal — use {@link createSyncClientHandle}. */
41
68
  constructor(internals) {
42
69
  this.#role = internals.role;
43
70
  this.#clientId = internals.clientId;
71
+ this.#currentSchemaVersion = internals.currentSchemaVersion;
72
+ this.#leadership =
73
+ internals.leadership ??
74
+ (internals.role === 'leader'
75
+ ? { state: 'leader', clientId: internals.clientId }
76
+ : (internals.follower?.leadershipState ?? {
77
+ state: 'blocked',
78
+ reason: 'leader-unreachable',
79
+ code: 'client.follower_timeout',
80
+ retryable: true,
81
+ }));
44
82
  this.#core = internals.core;
45
83
  this.#follower = internals.follower;
46
84
  this.#invalidation = internals.invalidation;
47
85
  this.#changes = internals.changes;
48
86
  this.#presence = internals.presence;
49
87
  this.#roleListeners = internals.roleListeners ?? new Set();
88
+ this.#leadershipListeners = internals.leadershipListeners ?? new Set();
50
89
  // RFC 0002 §3.2: console introspection — a no-op outside a dev page.
51
90
  this.#devtoolsUnregister = registerDevtools({
52
91
  kind: 'handle',
@@ -69,6 +108,7 @@ export class SyncClientHandle {
69
108
  this.#core = core;
70
109
  this.#clientId = core.clientId;
71
110
  this.#role = 'leader';
111
+ this.__setLeadership({ state: 'leader', clientId: core.clientId });
72
112
  for (const listener of this.#roleListeners) {
73
113
  try {
74
114
  listener('leader');
@@ -78,6 +118,20 @@ export class SyncClientHandle {
78
118
  }
79
119
  }
80
120
  }
121
+ /** @internal — apply a follower reachability snapshot in place. */
122
+ __setLeadership(state) {
123
+ this.#leadership = state;
124
+ if (state.state === 'follower')
125
+ this.#clientId = state.leaderClientId;
126
+ for (const listener of this.#leadershipListeners) {
127
+ try {
128
+ listener(state);
129
+ }
130
+ catch {
131
+ /* a UI listener must never break leadership transitions */
132
+ }
133
+ }
134
+ }
81
135
  /** @internal — dispatch a worker/relayed event to handle-local listeners. */
82
136
  __dispatchEvent(event) {
83
137
  if (event.kind === 'presence') {
@@ -126,6 +180,12 @@ export class SyncClientHandle {
126
180
  this.#roleListeners.delete(listener);
127
181
  };
128
182
  }
183
+ onLeadershipChange(listener) {
184
+ this.#leadershipListeners.add(listener);
185
+ return () => {
186
+ this.#leadershipListeners.delete(listener);
187
+ };
188
+ }
129
189
  #call(method, args) {
130
190
  if (this.#closed) {
131
191
  return Promise.reject(new ClientSyncError(WORKER_FAILED_CODE, 'the handle is closed'));
@@ -407,14 +467,19 @@ function fireConfigCallbacks(config, event) {
407
467
  * `multiTab: false`: a losing tab resolves to a dead not-leader handle.
408
468
  */
409
469
  export async function createSyncClientHandle(config) {
410
- const lock = config.leaderLock ?? defaultLeaderLock();
411
- const lockName = config.lockName ?? 'syncular-leader';
470
+ const resolvedConfig = resolveReplicaConfig(config);
471
+ const lock = resolvedConfig.leaderLock ?? defaultLeaderLock();
472
+ const lockName = resolvedConfig.lockName ?? 'syncular-leader';
412
473
  const invalidation = new InvalidationEmitter();
413
474
  const changes = new ChangeEmitter();
414
475
  const presence = new Set();
415
476
  const roleListeners = new Set();
416
- if (config.onRoleChange !== undefined)
417
- roleListeners.add(config.onRoleChange);
477
+ if (resolvedConfig.onRoleChange !== undefined)
478
+ roleListeners.add(resolvedConfig.onRoleChange);
479
+ const leadershipListeners = new Set();
480
+ if (resolvedConfig.onLeadershipChange !== undefined) {
481
+ leadershipListeners.add(resolvedConfig.onLeadershipChange);
482
+ }
418
483
  // Leadership BEFORE the worker exists: one core per origin, and a losing
419
484
  // tab never boots a database it must not own.
420
485
  const lease = lock.tryAcquire !== undefined
@@ -425,32 +490,36 @@ export async function createSyncClientHandle(config) {
425
490
  // Epoch derivation for a fresh boot: epoch 0. A promoter (below) reads
426
491
  // the highest epoch it has seen and adds one, so leaders monotonically
427
492
  // increase it across handovers.
428
- return await bootLeader(config, lockName, lease, {
493
+ return await bootLeader(resolvedConfig, lockName, lease, {
429
494
  epoch: 0,
430
495
  invalidation,
431
496
  changes,
432
497
  presence,
433
498
  roleListeners,
499
+ leadershipListeners,
434
500
  });
435
501
  }
436
502
  // ---- Lost the election. ----
437
- if (config.multiTab === false) {
503
+ if (resolvedConfig.multiTab === false) {
438
504
  // Opted-out single-tab contract: a dead not-leader handle.
439
505
  return new SyncClientHandle({
440
506
  role: 'follower',
441
507
  clientId: '',
508
+ currentSchemaVersion: resolvedConfig.schema.version,
442
509
  invalidation,
443
510
  changes,
444
511
  presence,
445
512
  roleListeners,
513
+ leadershipListeners,
446
514
  });
447
515
  }
448
516
  // ---- Follower: proxy to the leader; contest + promote on its close. ----
449
- return await bootFollower(config, lockName, lock, {
517
+ return await bootFollower(resolvedConfig, lockName, lock, {
450
518
  invalidation,
451
519
  changes,
452
520
  presence,
453
521
  roleListeners,
522
+ leadershipListeners,
454
523
  });
455
524
  }
456
525
  /** Boot (or promote to) a leader: spawn the worker, wire the bridge. */
@@ -471,6 +540,7 @@ async function bootLeader(config, lockName, lease, parts) {
471
540
  epoch: parts.epoch ?? 0,
472
541
  clientId,
473
542
  invoke,
543
+ heartbeatMs: Math.max(10, Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3)),
474
544
  });
475
545
  }
476
546
  : undefined;
@@ -484,11 +554,13 @@ async function bootLeader(config, lockName, lease, parts) {
484
554
  const handle = new SyncClientHandle({
485
555
  role: 'leader',
486
556
  clientId: core.clientId,
557
+ currentSchemaVersion: config.schema.version,
487
558
  core,
488
559
  invalidation: parts.invalidation,
489
560
  changes: parts.changes,
490
561
  presence: parts.presence,
491
562
  roleListeners: parts.roleListeners,
563
+ leadershipListeners: parts.leadershipListeners,
492
564
  });
493
565
  handleRef.handle = handle;
494
566
  return handle;
@@ -512,6 +584,7 @@ async function bootFollower(config, lockName, lock, parts) {
512
584
  // it after binding). Nothing else to do — calls already flush.
513
585
  void clientId;
514
586
  },
587
+ onStateChange: (state) => handleRef.handle?.__setLeadership(state),
515
588
  ...(config.followerCallTimeoutMs !== undefined
516
589
  ? { callTimeoutMs: config.followerCallTimeoutMs }
517
590
  : {}),
@@ -549,6 +622,7 @@ async function bootFollower(config, lockName, lock, parts) {
549
622
  epoch: nextEpoch,
550
623
  clientId,
551
624
  invoke,
625
+ heartbeatMs: Math.max(10, Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3)),
552
626
  });
553
627
  },
554
628
  }
@@ -568,11 +642,13 @@ async function bootFollower(config, lockName, lock, parts) {
568
642
  // leave '' until promotion (the shared id is the leader's — hooks that
569
643
  // need it read it after a round). Followers rarely need clientId directly.
570
644
  clientId: '',
645
+ currentSchemaVersion: config.schema.version,
571
646
  follower,
572
647
  invalidation: parts.invalidation,
573
648
  changes: parts.changes,
574
649
  presence: parts.presence,
575
650
  roleListeners: parts.roleListeners,
651
+ leadershipListeners: parts.leadershipListeners,
576
652
  });
577
653
  handleRef.handle = handle;
578
654
  // Do not hand back a follower until its link has bound to the leader (the
@@ -591,3 +667,27 @@ async function bootFollower(config, lockName, lock, parts) {
591
667
  }
592
668
  return handle;
593
669
  }
670
+ function resolveReplicaConfig(config) {
671
+ if (config.replica?.mode !== 'isolated')
672
+ return config;
673
+ if (config.database.mode !== 'persistent') {
674
+ throw new ClientSyncError('sync.invalid_request', 'isolated browser replicas require a named persistent database');
675
+ }
676
+ const names = isolatedReplicaNames({
677
+ databaseName: config.database.name,
678
+ ...(config.database.directory !== undefined
679
+ ? { databaseDirectory: config.database.directory }
680
+ : {}),
681
+ ...(config.lockName !== undefined ? { lockName: config.lockName } : {}),
682
+ replicaId: config.replica.id,
683
+ });
684
+ return {
685
+ ...config,
686
+ lockName: names.lockName,
687
+ database: {
688
+ ...config.database,
689
+ name: names.databaseName,
690
+ directory: names.databaseDirectory,
691
+ },
692
+ };
693
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.15.12",
3
+ "version": "0.15.14",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -81,7 +81,7 @@
81
81
  },
82
82
  "dependencies": {
83
83
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
84
- "@syncular/core": "0.15.12"
84
+ "@syncular/core": "0.15.14"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "better-sqlite3": ">=11"
@@ -92,7 +92,7 @@
92
92
  }
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.15.12",
95
+ "@syncular/server": "0.15.14",
96
96
  "@types/better-sqlite3": "^7.6.13",
97
97
  "better-sqlite3": "^12.11.1"
98
98
  }