@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.
@@ -23,20 +23,39 @@
23
23
  */
24
24
  import type { WakeReason } from '@syncular/core';
25
25
  import type { BlobRef, CachedBlob } from './blob.js';
26
- import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
26
+ import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SecurityLifecycle, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
27
27
  import type { SqlRow, SqlValue } from './database.js';
28
28
  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';
36
36
  import type { SubscriptionRecord } from './state.js';
37
37
  import type { WindowBase } from './window.js';
38
- import { type SyncWorkerEvent, type WorkerDatabaseInit, type WorkerEndpoints, type WorkerErrorShape } from './worker-protocol.js';
38
+ import { type SyncWorkerEvent, type WorkerDatabaseInit, type WorkerEndpoints, type WorkerErrorShape, type WorkerSecurityActivation } 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
@@ -50,6 +69,8 @@ export interface SyncClientHandleConfig {
50
69
  readonly endpoints: WorkerEndpoints;
51
70
  /** Structured-clone-safe E2EE keyring installed only in the leader worker. */
52
71
  readonly encryption?: EncryptionKeyringConfig;
72
+ /** Open the worker-owned replica behind the fail-closed security gate. */
73
+ readonly securityPreflight?: boolean;
53
74
  readonly clientId?: string;
54
75
  readonly limits?: SyncClientLimits;
55
76
  /** Worker-side host loop (§8.4); default true. */
@@ -57,6 +78,8 @@ export interface SyncClientHandleConfig {
57
78
  /** Default: Web Locks when available, else single-owner. */
58
79
  readonly leaderLock?: LeaderLock;
59
80
  readonly lockName?: string;
81
+ /** Shared by default; isolated derives the database/lock/channel tuple. */
82
+ readonly replica?: BrowserReplicaMode;
60
83
  /**
61
84
  * Multi-tab followers (TODO 3.2). On by default: a tab that loses the
62
85
  * leader election becomes a FOLLOWER that proxies to the leader over a
@@ -71,6 +94,8 @@ export interface SyncClientHandleConfig {
71
94
  readonly followerCallTimeoutMs?: number;
72
95
  /** Fires when this handle's role changes (follower → leader on promotion). */
73
96
  readonly onRoleChange?: (role: HandleRole) => void;
97
+ /** Fires when reachability or ownership changes without replacing the handle. */
98
+ readonly onLeadershipChange?: (state: LeadershipState) => void;
74
99
  readonly onSyncNeeded?: (reason: 'startup' | 'hello' | WakeReason) => void;
75
100
  readonly onConflict?: (conflict: ConflictRecord) => void;
76
101
  /** A worker-side autoSync round finished (or failed). */
@@ -107,19 +132,27 @@ export declare class SyncClientHandle {
107
132
  get role(): HandleRole;
108
133
  /** Resolved client id — the leader's; shared by all tabs on this origin. */
109
134
  get clientId(): string;
135
+ get currentSchemaVersion(): number;
136
+ get leadership(): LeadershipState;
137
+ leadershipSnapshot(): LeadershipState;
110
138
  /** @internal — use {@link createSyncClientHandle}. */
111
139
  constructor(internals: {
112
140
  role: HandleRole;
113
141
  clientId: string;
142
+ currentSchemaVersion: number;
114
143
  core?: LeaderCore;
115
144
  follower?: FollowerLink;
116
145
  invalidation: InvalidationEmitter;
117
146
  changes: ChangeEmitter;
118
147
  presence: Set<(scopeKey: string) => void>;
119
148
  roleListeners?: Set<(role: HandleRole) => void>;
149
+ leadershipListeners?: Set<(state: LeadershipState) => void>;
150
+ leadership?: LeadershipState;
120
151
  });
121
152
  /** @internal — swap this handle from follower to leader (promotion). */
122
153
  __becomeLeader(core: LeaderCore): void;
154
+ /** @internal — apply a follower reachability snapshot in place. */
155
+ __setLeadership(state: LeadershipState): void;
123
156
  /** @internal — dispatch a worker/relayed event to handle-local listeners. */
124
157
  __dispatchEvent(event: SyncWorkerEvent): void;
125
158
  /**
@@ -137,7 +170,11 @@ export declare class SyncClientHandle {
137
170
  onPresence(listener: (scopeKey: string) => void): () => void;
138
171
  /** Subscribe to role transitions (follower → leader on promotion). */
139
172
  onRoleChange(listener: (role: HandleRole) => void): () => void;
173
+ onLeadershipChange(listener: (state: LeadershipState) => void): () => void;
140
174
  subscribe(input: SubscribeInput): Promise<void>;
175
+ securityLifecycle(): Promise<SecurityLifecycle>;
176
+ beginSecurityPreflight(): Promise<void>;
177
+ activateSecurity(options?: WorkerSecurityActivation): Promise<void>;
141
178
  unsubscribe(id: string): Promise<void>;
142
179
  setWindow(base: WindowBase, units: readonly string[]): Promise<void>;
143
180
  windowState(base: WindowBase): Promise<WindowState>;
@@ -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'));
@@ -146,6 +206,15 @@ export class SyncClientHandle {
146
206
  subscribe(input) {
147
207
  return this.#call('subscribe', [input]);
148
208
  }
209
+ securityLifecycle() {
210
+ return this.#call('securityLifecycle', []);
211
+ }
212
+ beginSecurityPreflight() {
213
+ return this.#call('beginSecurityPreflight', []);
214
+ }
215
+ activateSecurity(options = {}) {
216
+ return this.#call('activateSecurity', [options]);
217
+ }
149
218
  unsubscribe(id) {
150
219
  return this.#call('unsubscribe', [id]);
151
220
  }
@@ -373,6 +442,9 @@ function buildInitConfig(config) {
373
442
  ...(config.encryption !== undefined
374
443
  ? { encryption: config.encryption }
375
444
  : {}),
445
+ ...(config.securityPreflight !== undefined
446
+ ? { securityPreflight: config.securityPreflight }
447
+ : {}),
376
448
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
377
449
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
378
450
  ...(config.autoSync !== undefined ? { autoSync: config.autoSync } : {}),
@@ -407,14 +479,19 @@ function fireConfigCallbacks(config, event) {
407
479
  * `multiTab: false`: a losing tab resolves to a dead not-leader handle.
408
480
  */
409
481
  export async function createSyncClientHandle(config) {
410
- const lock = config.leaderLock ?? defaultLeaderLock();
411
- const lockName = config.lockName ?? 'syncular-leader';
482
+ const resolvedConfig = resolveReplicaConfig(config);
483
+ const lock = resolvedConfig.leaderLock ?? defaultLeaderLock();
484
+ const lockName = resolvedConfig.lockName ?? 'syncular-leader';
412
485
  const invalidation = new InvalidationEmitter();
413
486
  const changes = new ChangeEmitter();
414
487
  const presence = new Set();
415
488
  const roleListeners = new Set();
416
- if (config.onRoleChange !== undefined)
417
- roleListeners.add(config.onRoleChange);
489
+ if (resolvedConfig.onRoleChange !== undefined)
490
+ roleListeners.add(resolvedConfig.onRoleChange);
491
+ const leadershipListeners = new Set();
492
+ if (resolvedConfig.onLeadershipChange !== undefined) {
493
+ leadershipListeners.add(resolvedConfig.onLeadershipChange);
494
+ }
418
495
  // Leadership BEFORE the worker exists: one core per origin, and a losing
419
496
  // tab never boots a database it must not own.
420
497
  const lease = lock.tryAcquire !== undefined
@@ -425,32 +502,36 @@ export async function createSyncClientHandle(config) {
425
502
  // Epoch derivation for a fresh boot: epoch 0. A promoter (below) reads
426
503
  // the highest epoch it has seen and adds one, so leaders monotonically
427
504
  // increase it across handovers.
428
- return await bootLeader(config, lockName, lease, {
505
+ return await bootLeader(resolvedConfig, lockName, lease, {
429
506
  epoch: 0,
430
507
  invalidation,
431
508
  changes,
432
509
  presence,
433
510
  roleListeners,
511
+ leadershipListeners,
434
512
  });
435
513
  }
436
514
  // ---- Lost the election. ----
437
- if (config.multiTab === false) {
515
+ if (resolvedConfig.multiTab === false) {
438
516
  // Opted-out single-tab contract: a dead not-leader handle.
439
517
  return new SyncClientHandle({
440
518
  role: 'follower',
441
519
  clientId: '',
520
+ currentSchemaVersion: resolvedConfig.schema.version,
442
521
  invalidation,
443
522
  changes,
444
523
  presence,
445
524
  roleListeners,
525
+ leadershipListeners,
446
526
  });
447
527
  }
448
528
  // ---- Follower: proxy to the leader; contest + promote on its close. ----
449
- return await bootFollower(config, lockName, lock, {
529
+ return await bootFollower(resolvedConfig, lockName, lock, {
450
530
  invalidation,
451
531
  changes,
452
532
  presence,
453
533
  roleListeners,
534
+ leadershipListeners,
454
535
  });
455
536
  }
456
537
  /** Boot (or promote to) a leader: spawn the worker, wire the bridge. */
@@ -471,6 +552,7 @@ async function bootLeader(config, lockName, lease, parts) {
471
552
  epoch: parts.epoch ?? 0,
472
553
  clientId,
473
554
  invoke,
555
+ heartbeatMs: Math.max(10, Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3)),
474
556
  });
475
557
  }
476
558
  : undefined;
@@ -484,11 +566,13 @@ async function bootLeader(config, lockName, lease, parts) {
484
566
  const handle = new SyncClientHandle({
485
567
  role: 'leader',
486
568
  clientId: core.clientId,
569
+ currentSchemaVersion: config.schema.version,
487
570
  core,
488
571
  invalidation: parts.invalidation,
489
572
  changes: parts.changes,
490
573
  presence: parts.presence,
491
574
  roleListeners: parts.roleListeners,
575
+ leadershipListeners: parts.leadershipListeners,
492
576
  });
493
577
  handleRef.handle = handle;
494
578
  return handle;
@@ -512,6 +596,7 @@ async function bootFollower(config, lockName, lock, parts) {
512
596
  // it after binding). Nothing else to do — calls already flush.
513
597
  void clientId;
514
598
  },
599
+ onStateChange: (state) => handleRef.handle?.__setLeadership(state),
515
600
  ...(config.followerCallTimeoutMs !== undefined
516
601
  ? { callTimeoutMs: config.followerCallTimeoutMs }
517
602
  : {}),
@@ -549,6 +634,7 @@ async function bootFollower(config, lockName, lock, parts) {
549
634
  epoch: nextEpoch,
550
635
  clientId,
551
636
  invoke,
637
+ heartbeatMs: Math.max(10, Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3)),
552
638
  });
553
639
  },
554
640
  }
@@ -568,11 +654,13 @@ async function bootFollower(config, lockName, lock, parts) {
568
654
  // leave '' until promotion (the shared id is the leader's — hooks that
569
655
  // need it read it after a round). Followers rarely need clientId directly.
570
656
  clientId: '',
657
+ currentSchemaVersion: config.schema.version,
571
658
  follower,
572
659
  invalidation: parts.invalidation,
573
660
  changes: parts.changes,
574
661
  presence: parts.presence,
575
662
  roleListeners: parts.roleListeners,
663
+ leadershipListeners: parts.leadershipListeners,
576
664
  });
577
665
  handleRef.handle = handle;
578
666
  // Do not hand back a follower until its link has bound to the leader (the
@@ -589,5 +677,36 @@ async function bootFollower(config, lockName, lock, parts) {
589
677
  catch {
590
678
  /* bind timed out — return the (degraded but functional) handle anyway */
591
679
  }
680
+ // Init configuration belongs to the one leader worker. A newly opened
681
+ // follower that explicitly requests security preflight must therefore put
682
+ // the shared origin replica behind the same barrier before it is returned;
683
+ // otherwise an already-running leader would silently ignore the request.
684
+ if (config.securityPreflight === true) {
685
+ await handle.beginSecurityPreflight();
686
+ }
592
687
  return handle;
593
688
  }
689
+ function resolveReplicaConfig(config) {
690
+ if (config.replica?.mode !== 'isolated')
691
+ return config;
692
+ if (config.database.mode !== 'persistent') {
693
+ throw new ClientSyncError('sync.invalid_request', 'isolated browser replicas require a named persistent database');
694
+ }
695
+ const names = isolatedReplicaNames({
696
+ databaseName: config.database.name,
697
+ ...(config.database.directory !== undefined
698
+ ? { databaseDirectory: config.database.directory }
699
+ : {}),
700
+ ...(config.lockName !== undefined ? { lockName: config.lockName } : {}),
701
+ replicaId: config.replica.id,
702
+ });
703
+ return {
704
+ ...config,
705
+ lockName: names.lockName,
706
+ database: {
707
+ ...config.database,
708
+ name: names.databaseName,
709
+ directory: names.databaseDirectory,
710
+ },
711
+ };
712
+ }
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import type { WakeReason } from '@syncular/core';
20
20
  import type { BlobRef, CachedBlob } from './blob.js';
21
- import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
21
+ import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SecurityLifecycle, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
22
22
  import type { SqlRow, SqlValue } from './database.js';
23
23
  import type { EncryptionKeyringConfig } from './encryption.js';
24
24
  import type { ClientChangeBatch, LocalRevision, SyncStatusSnapshot } from './invalidation.js';
@@ -65,6 +65,8 @@ export interface WorkerInitConfig {
65
65
  readonly endpoints: WorkerEndpoints;
66
66
  /** Portable raw keyring installed inside the worker-owned client core. */
67
67
  readonly encryption?: EncryptionKeyringConfig;
68
+ /** Open the worker-owned replica behind the fail-closed security gate. */
69
+ readonly securityPreflight?: boolean;
68
70
  readonly clientId?: string;
69
71
  readonly limits?: SyncClientLimits;
70
72
  /**
@@ -78,7 +80,14 @@ export interface WorkerInitConfig {
78
80
  export interface WorkerInitResult {
79
81
  readonly clientId: string;
80
82
  }
83
+ /** Structured-clone-safe key material installed at security activation. */
84
+ export interface WorkerSecurityActivation {
85
+ readonly encryption?: EncryptionKeyringConfig;
86
+ }
81
87
  export interface WorkerApi {
88
+ securityLifecycle(): SecurityLifecycle;
89
+ beginSecurityPreflight(): Promise<void>;
90
+ activateSecurity(options?: WorkerSecurityActivation): Promise<void>;
82
91
  subscribe(input: SubscribeInput): void;
83
92
  unsubscribe(id: string): void;
84
93
  /** §4.8 windowed subscriptions: set the live units for a window base. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.15.13",
3
+ "version": "0.15.15",
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.13"
84
+ "@syncular/core": "0.15.15"
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.13",
95
+ "@syncular/server": "0.15.15",
96
96
  "@types/better-sqlite3": "^7.6.13",
97
97
  "better-sqlite3": "^12.11.1"
98
98
  }
@@ -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
+ }