@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.
@@ -0,0 +1,70 @@
1
+ import type { SyncStatusSnapshot } from './invalidation';
2
+ import type { LeadershipState } from './multi-tab';
3
+
4
+ export type SyncAvailability =
5
+ | { readonly state: 'ready' }
6
+ | { readonly state: 'migrating'; readonly currentSchemaVersion: number }
7
+ | {
8
+ readonly state: 'blocked';
9
+ readonly reason:
10
+ | 'client-upgrade-required'
11
+ | 'server-behind'
12
+ | 'incompatible-schema'
13
+ | 'leader-unreachable';
14
+ readonly currentSchemaVersion: number;
15
+ readonly requiredSchemaVersion?: number;
16
+ readonly latestServerSchemaVersion?: number;
17
+ readonly retryable: boolean;
18
+ };
19
+
20
+ /** Classify schema and browser-ownership state without parsing diagnostics. */
21
+ export function classifySyncAvailability(
22
+ status: SyncStatusSnapshot,
23
+ leadership?: LeadershipState,
24
+ ): SyncAvailability {
25
+ const currentSchemaVersion = status.currentSchemaVersion;
26
+ if (leadership?.state === 'blocked') {
27
+ return {
28
+ state: 'blocked',
29
+ reason: 'leader-unreachable',
30
+ currentSchemaVersion,
31
+ retryable: true,
32
+ };
33
+ }
34
+ const required = status.schemaFloor?.requiredSchemaVersion;
35
+ const latest = status.schemaFloor?.latestSchemaVersion;
36
+ if (required !== undefined && required > currentSchemaVersion) {
37
+ return {
38
+ state: 'blocked',
39
+ reason: 'client-upgrade-required',
40
+ currentSchemaVersion,
41
+ requiredSchemaVersion: required,
42
+ ...(latest !== undefined ? { latestServerSchemaVersion: latest } : {}),
43
+ retryable: false,
44
+ };
45
+ }
46
+ if (latest !== undefined && latest < currentSchemaVersion) {
47
+ return {
48
+ state: 'blocked',
49
+ reason: 'server-behind',
50
+ currentSchemaVersion,
51
+ ...(required !== undefined ? { requiredSchemaVersion: required } : {}),
52
+ latestServerSchemaVersion: latest,
53
+ retryable: false,
54
+ };
55
+ }
56
+ if (status.schemaFloor !== undefined) {
57
+ return {
58
+ state: 'blocked',
59
+ reason: 'incompatible-schema',
60
+ currentSchemaVersion,
61
+ ...(required !== undefined ? { requiredSchemaVersion: required } : {}),
62
+ ...(latest !== undefined ? { latestServerSchemaVersion: latest } : {}),
63
+ retryable: false,
64
+ };
65
+ }
66
+ if (status.upgrading) {
67
+ return { state: 'migrating', currentSchemaVersion };
68
+ }
69
+ return { state: 'ready' };
70
+ }
package/src/client.ts CHANGED
@@ -597,9 +597,11 @@ export class SyncClient {
597
597
  this.#detectAndResetSchema();
598
598
  // A registration remains app intent only while its table exists in the
599
599
  // running schema. Removed-table registrations would poison every pull.
600
- this.#db.transaction(() => {
601
- pruneUnknownSubscriptions(this.#db, new Set(this.#schema.tables.keys()));
602
- });
600
+ const subscriptions = pruneUnknownSubscriptions(
601
+ this.#db,
602
+ loadSubscriptions(this.#db),
603
+ new Set(this.#schema.tables.keys()),
604
+ );
603
605
  this.#started = true;
604
606
  // A persisted active subscription needs one catch-up round on every open:
605
607
  // realtime only covers changes after the socket connects, and an
@@ -610,7 +612,7 @@ export class SyncClient {
610
612
  const startupWork =
611
613
  this.#schemaFloor === undefined &&
612
614
  (listOutbox(this.#db).length > 0 ||
613
- loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
615
+ subscriptions.some((sub) => sub.status === 'active'));
614
616
  if (startupWork) {
615
617
  this.#needsPull = true;
616
618
  this.#config.onSyncNeeded?.('startup');
@@ -833,6 +835,7 @@ export class SyncClient {
833
835
 
834
836
  #statusSnapshot(): SyncStatusSnapshot {
835
837
  return {
838
+ currentSchemaVersion: this.#config.schema.version,
836
839
  outbox: listOutbox(this.#db).length,
837
840
  upgrading: this.#upgrading,
838
841
  leaseState: this.#leaseState,
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  * import no SQLite.
10
10
  */
11
11
  export * from './apply';
12
+ export * from './availability';
12
13
  export * from './blob';
13
14
  export * from './client';
14
15
  export * from './content-type';
@@ -24,6 +24,7 @@ export interface WindowChange {
24
24
  }
25
25
 
26
26
  export interface SyncStatusSnapshot {
27
+ readonly currentSchemaVersion: number;
27
28
  readonly outbox: number;
28
29
  readonly upgrading: boolean;
29
30
  readonly leaseState: LeaseState | undefined;
package/src/multi-tab.ts CHANGED
@@ -46,6 +46,24 @@ export const DEFAULT_FOLLOWER_CALL_TIMEOUT_MS = 10_000;
46
46
  /** Max follower calls queued across a handover before we fail loudly. */
47
47
  export const DEFAULT_FOLLOWER_QUEUE_LIMIT = 256;
48
48
 
49
+ export type LeadershipState =
50
+ | { readonly state: 'leader'; readonly clientId: string }
51
+ | {
52
+ readonly state: 'follower';
53
+ readonly leaderClientId: string;
54
+ readonly epoch: number;
55
+ }
56
+ | {
57
+ readonly state: 'waiting';
58
+ readonly reason: 'handover' | 'leader-announcement';
59
+ }
60
+ | {
61
+ readonly state: 'blocked';
62
+ readonly reason: 'leader-unreachable';
63
+ readonly code: typeof FOLLOWER_TIMEOUT_CODE;
64
+ readonly retryable: true;
65
+ };
66
+
49
67
  // ---------------------------------------------------------------------------
50
68
  // Wire messages
51
69
  // ---------------------------------------------------------------------------
@@ -63,6 +81,11 @@ interface ByeMessage {
63
81
  readonly fromId: string;
64
82
  }
65
83
 
84
+ interface BoundMessage {
85
+ readonly t: 'bound';
86
+ readonly fromId: string;
87
+ }
88
+
66
89
  interface ReqMessage {
67
90
  readonly t: 'req';
68
91
  readonly epoch: number;
@@ -96,6 +119,7 @@ interface EventMessage {
96
119
  export type MultiTabMessage =
97
120
  | HelloMessage
98
121
  | ByeMessage
122
+ | BoundMessage
99
123
  | ReqMessage
100
124
  | AnnounceMessage
101
125
  | ResMessage
@@ -157,6 +181,9 @@ export class LeaderBridge {
157
181
  args: readonly unknown[],
158
182
  ) => Promise<unknown>;
159
183
  readonly #onMessage: (event: { data: MultiTabMessage }) => void;
184
+ readonly #heartbeatMs: number;
185
+ readonly #followers = new Set<string>();
186
+ #heartbeat: ReturnType<typeof setInterval> | undefined;
160
187
  #closed = false;
161
188
 
162
189
  constructor(options: {
@@ -164,11 +191,15 @@ export class LeaderBridge {
164
191
  epoch: number;
165
192
  clientId: string;
166
193
  invoke: (method: string, args: readonly unknown[]) => Promise<unknown>;
194
+ heartbeatMs?: number;
167
195
  }) {
168
196
  this.#channel = options.channel;
169
197
  this.#epoch = options.epoch;
170
198
  this.#clientId = options.clientId;
171
199
  this.#invoke = options.invoke;
200
+ this.#heartbeatMs =
201
+ options.heartbeatMs ??
202
+ Math.max(50, Math.floor(DEFAULT_FOLLOWER_CALL_TIMEOUT_MS / 3));
172
203
  this.#onMessage = (event) => this.#handle(event.data);
173
204
  this.#channel.addEventListener('message', this.#onMessage);
174
205
  this.announce();
@@ -194,9 +225,22 @@ export class LeaderBridge {
194
225
  if (this.#closed) return;
195
226
  if (message.t === 'hello') {
196
227
  // A follower joined (or is contesting) — tell it who leads.
228
+ this.#trackFollower(message.fromId);
197
229
  this.announce();
198
230
  return;
199
231
  }
232
+ if (message.t === 'bound') {
233
+ this.#trackFollower(message.fromId);
234
+ return;
235
+ }
236
+ if (message.t === 'bye') {
237
+ this.#followers.delete(message.fromId);
238
+ if (this.#followers.size === 0 && this.#heartbeat !== undefined) {
239
+ clearInterval(this.#heartbeat);
240
+ this.#heartbeat = undefined;
241
+ }
242
+ return;
243
+ }
200
244
  if (message.t !== 'req') return;
201
245
  // Ignore requests stamped for a different (dead/older) leader; that
202
246
  // follower will re-stamp once it sees our announce.
@@ -237,10 +281,19 @@ export class LeaderBridge {
237
281
  );
238
282
  }
239
283
 
284
+ #trackFollower(fromId: string): void {
285
+ this.#followers.add(fromId);
286
+ if (this.#heartbeat !== undefined) return;
287
+ this.#heartbeat = setInterval(() => this.announce(), this.#heartbeatMs);
288
+ }
289
+
240
290
  close(): void {
241
291
  if (this.#closed) return;
242
292
  this.#closed = true;
293
+ if (this.#heartbeat !== undefined) clearInterval(this.#heartbeat);
294
+ this.#heartbeat = undefined;
243
295
  this.#channel.removeEventListener('message', this.#onMessage);
296
+ this.#channel.close();
244
297
  }
245
298
  }
246
299
 
@@ -274,6 +327,7 @@ export class FollowerLink {
274
327
  readonly #fromId: string;
275
328
  readonly #onEvent: (event: SyncWorkerEvent) => void;
276
329
  readonly #onLeaderChange: (clientId: string) => void;
330
+ readonly #onStateChange: (state: LeadershipState) => void;
277
331
  readonly #callTimeoutMs: number;
278
332
  readonly #queueLimit: number;
279
333
  readonly #onMessage: (event: { data: MultiTabMessage }) => void;
@@ -287,6 +341,12 @@ export class FollowerLink {
287
341
  readonly #inFlight = new Map<number, InFlight>();
288
342
  #queue: QueuedCall[] = [];
289
343
  #closed = false;
344
+ #state: LeadershipState = {
345
+ state: 'waiting',
346
+ reason: 'leader-announcement',
347
+ };
348
+ #waitingTimer: ReturnType<typeof setTimeout> | undefined;
349
+ #blockedTimer: ReturnType<typeof setTimeout> | undefined;
290
350
  /** Resolvers waiting for the first `announce` to bind a leader. */
291
351
  #bindWaiters: Array<() => void> = [];
292
352
 
@@ -295,6 +355,7 @@ export class FollowerLink {
295
355
  fromId: string;
296
356
  onEvent: (event: SyncWorkerEvent) => void;
297
357
  onLeaderChange: (clientId: string) => void;
358
+ onStateChange?: (state: LeadershipState) => void;
298
359
  callTimeoutMs?: number;
299
360
  queueLimit?: number;
300
361
  }) {
@@ -302,6 +363,7 @@ export class FollowerLink {
302
363
  this.#fromId = options.fromId;
303
364
  this.#onEvent = options.onEvent;
304
365
  this.#onLeaderChange = options.onLeaderChange;
366
+ this.#onStateChange = options.onStateChange ?? (() => {});
305
367
  this.#callTimeoutMs =
306
368
  options.callTimeoutMs ?? DEFAULT_FOLLOWER_CALL_TIMEOUT_MS;
307
369
  this.#queueLimit = options.queueLimit ?? DEFAULT_FOLLOWER_QUEUE_LIMIT;
@@ -309,6 +371,7 @@ export class FollowerLink {
309
371
  this.#channel.addEventListener('message', this.#onMessage);
310
372
  // Ask the current leader to announce itself.
311
373
  this.#channel.postMessage({ t: 'hello', fromId: this.#fromId });
374
+ this.#armUnboundDeadline();
312
375
  }
313
376
 
314
377
  get epoch(): number {
@@ -323,6 +386,10 @@ export class FollowerLink {
323
386
  return this.#leaderClientId;
324
387
  }
325
388
 
389
+ get leadershipState(): LeadershipState {
390
+ return this.#state;
391
+ }
392
+
326
393
  /** Whether a leader is currently bound (an announce has been heard). */
327
394
  get bound(): boolean {
328
395
  return this.#epoch >= 0;
@@ -348,10 +415,12 @@ export class FollowerLink {
348
415
  return new Promise((resolve, reject) => {
349
416
  const timer = setTimeout(() => {
350
417
  this.#dropBindWaiter(settle);
418
+ this.#setBlocked();
351
419
  reject(
352
420
  new ClientSyncError(
353
421
  FOLLOWER_TIMEOUT_CODE,
354
422
  'no leader announced within the follower bind timeout',
423
+ true,
355
424
  ),
356
425
  );
357
426
  }, timeoutMs);
@@ -382,6 +451,15 @@ export class FollowerLink {
382
451
  new ClientSyncError(WORKER_FAILED_CODE, 'the follower link is closed'),
383
452
  );
384
453
  }
454
+ if (this.#state.state === 'blocked') {
455
+ return Promise.reject(
456
+ new ClientSyncError(
457
+ FOLLOWER_TIMEOUT_CODE,
458
+ 'the follower cannot reach the tab that owns the database',
459
+ true,
460
+ ),
461
+ );
462
+ }
385
463
  return new Promise((resolve, reject) => {
386
464
  const queued: QueuedCall = {
387
465
  method,
@@ -393,20 +471,24 @@ export class FollowerLink {
393
471
  if (this.#epoch < 0) {
394
472
  // No leader bound yet — queue with a deadline so we never hang.
395
473
  if (this.#queue.length >= this.#queueLimit) {
474
+ this.#setBlocked();
396
475
  reject(
397
476
  new ClientSyncError(
398
477
  FOLLOWER_TIMEOUT_CODE,
399
478
  'follower call queue overflow while awaiting a leader',
479
+ true,
400
480
  ),
401
481
  );
402
482
  return;
403
483
  }
404
484
  queued.timer = setTimeout(() => {
405
485
  this.#dropQueued(queued);
486
+ this.#setBlocked();
406
487
  reject(
407
488
  new ClientSyncError(
408
489
  FOLLOWER_TIMEOUT_CODE,
409
490
  'no leader answered within the follower call timeout',
491
+ true,
410
492
  ),
411
493
  );
412
494
  }, this.#callTimeoutMs);
@@ -424,10 +506,12 @@ export class FollowerLink {
424
506
  reject: queued.reject,
425
507
  timer: setTimeout(() => {
426
508
  this.#inFlight.delete(reqId);
509
+ this.#setBlocked();
427
510
  queued.reject(
428
511
  new ClientSyncError(
429
512
  FOLLOWER_TIMEOUT_CODE,
430
513
  'the leader did not answer within the follower call timeout',
514
+ true,
431
515
  ),
432
516
  );
433
517
  }, this.#callTimeoutMs),
@@ -462,6 +546,15 @@ export class FollowerLink {
462
546
  this.#epoch = message.epoch;
463
547
  this.#leaderClientId = message.clientId;
464
548
  if (changed) this.#onLeaderChange(message.clientId);
549
+ this.#setState({
550
+ state: 'follower',
551
+ leaderClientId: message.clientId,
552
+ epoch: message.epoch,
553
+ });
554
+ this.#armBoundDeadline();
555
+ if (changed) {
556
+ this.#channel.postMessage({ t: 'bound', fromId: this.#fromId });
557
+ }
465
558
  this.#resolveBindWaiters();
466
559
  this.#flushQueue();
467
560
  return;
@@ -516,6 +609,8 @@ export class FollowerLink {
516
609
  if (this.#closed) return;
517
610
  this.#epoch = -1;
518
611
  this.#leaderClientId = '';
612
+ this.#setState({ state: 'waiting', reason: 'handover' });
613
+ this.#armUnboundDeadline();
519
614
  this.#channel.postMessage({
520
615
  t: 'hello',
521
616
  fromId: this.#fromId,
@@ -526,6 +621,7 @@ export class FollowerLink {
526
621
  close(): void {
527
622
  if (this.#closed) return;
528
623
  this.#closed = true;
624
+ this.#clearReachabilityTimers();
529
625
  this.#channel.removeEventListener('message', this.#onMessage);
530
626
  this.#channel.postMessage({ t: 'bye', fromId: this.#fromId });
531
627
  const closedError = new ClientSyncError(
@@ -547,4 +643,66 @@ export class FollowerLink {
547
643
  this.#resolveBindWaiters();
548
644
  this.#channel.close();
549
645
  }
646
+
647
+ #setState(state: LeadershipState): void {
648
+ const previous = this.#state;
649
+ if (
650
+ previous.state === state.state &&
651
+ (state.state === 'blocked' ||
652
+ (state.state === 'waiting' &&
653
+ previous.state === 'waiting' &&
654
+ previous.reason === state.reason) ||
655
+ (state.state === 'follower' &&
656
+ previous.state === 'follower' &&
657
+ previous.epoch === state.epoch &&
658
+ previous.leaderClientId === state.leaderClientId))
659
+ ) {
660
+ return;
661
+ }
662
+ this.#state = state;
663
+ try {
664
+ this.#onStateChange(state);
665
+ } catch {
666
+ // A status listener must never break cross-tab coordination.
667
+ }
668
+ }
669
+
670
+ #setBlocked(): void {
671
+ this.#clearReachabilityTimers();
672
+ this.#setState({
673
+ state: 'blocked',
674
+ reason: 'leader-unreachable',
675
+ code: FOLLOWER_TIMEOUT_CODE,
676
+ retryable: true,
677
+ });
678
+ }
679
+
680
+ #armUnboundDeadline(): void {
681
+ this.#clearReachabilityTimers();
682
+ this.#blockedTimer = setTimeout(
683
+ () => this.#setBlocked(),
684
+ this.#callTimeoutMs,
685
+ );
686
+ }
687
+
688
+ #armBoundDeadline(): void {
689
+ this.#clearReachabilityTimers();
690
+ this.#waitingTimer = setTimeout(
691
+ () => {
692
+ this.#setState({ state: 'waiting', reason: 'leader-announcement' });
693
+ },
694
+ Math.max(1, Math.floor((this.#callTimeoutMs * 2) / 3)),
695
+ );
696
+ this.#blockedTimer = setTimeout(
697
+ () => this.#setBlocked(),
698
+ this.#callTimeoutMs,
699
+ );
700
+ }
701
+
702
+ #clearReachabilityTimers(): void {
703
+ if (this.#waitingTimer !== undefined) clearTimeout(this.#waitingTimer);
704
+ if (this.#blockedTimer !== undefined) clearTimeout(this.#blockedTimer);
705
+ this.#waitingTimer = undefined;
706
+ this.#blockedTimer = undefined;
707
+ }
550
708
  }