@syncular/client 0.25.1 → 0.26.0

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.
@@ -74,6 +74,7 @@ export function startSyncWorker(overrides = {}) {
74
74
  let client;
75
75
  let database;
76
76
  let offline = false;
77
+ let headers = {};
77
78
  let closed = false;
78
79
  // -- one sync loop: RPC-driven and auto-driven rounds serialize ----------
79
80
  let syncChain = Promise.resolve();
@@ -183,6 +184,8 @@ export function startSyncWorker(overrides = {}) {
183
184
  throw new ClientSyncError(WORKER_FAILED_CODE, 'the worker is already initialized');
184
185
  }
185
186
  autoSync = config.autoSync ?? true;
187
+ headers = config.headers ?? {};
188
+ const http = { headers: () => headers };
186
189
  for (let attempt = 0;; attempt++) {
187
190
  try {
188
191
  database =
@@ -212,15 +215,15 @@ export function startSyncWorker(overrides = {}) {
212
215
  }
213
216
  const transport = gateOffline(overrides.createTransport !== undefined
214
217
  ? overrides.createTransport(config)
215
- : httpSyncTransport(config.endpoints.syncUrl));
218
+ : httpSyncTransport(config.endpoints.syncUrl, http));
216
219
  const segments = overrides.createSegments !== undefined
217
220
  ? overrides.createSegments(config)
218
221
  : config.endpoints.segmentsUrl !== undefined
219
- ? httpSegmentDownloader(config.endpoints.segmentsUrl)
222
+ ? httpSegmentDownloader(config.endpoints.segmentsUrl, http)
220
223
  : undefined;
221
224
  // §5.9 blob transport: only when a blobs URL is configured.
222
225
  const blobs = config.endpoints.blobsUrl !== undefined
223
- ? httpBlobTransport(config.endpoints.blobsUrl)
226
+ ? httpBlobTransport(config.endpoints.blobsUrl, http)
224
227
  : undefined;
225
228
  // Realtime needs the (possibly persisted) clientId for the
226
229
  // `{clientId}` URL placeholder — start the client first, then attach
@@ -245,6 +248,9 @@ export function startSyncWorker(overrides = {}) {
245
248
  },
246
249
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
247
250
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
251
+ ...(config.realtimePolicy !== undefined
252
+ ? { realtimePolicy: config.realtimePolicy }
253
+ : {}),
248
254
  ...(config.encryption !== undefined
249
255
  ? { encryption: encryptionConfigFromKeyring(config.encryption) }
250
256
  : {}),
@@ -299,7 +305,7 @@ export function startSyncWorker(overrides = {}) {
299
305
  await started.start();
300
306
  realtimeConnector =
301
307
  overrides.createRealtime !== undefined
302
- ? overrides.createRealtime(config, started.clientId)
308
+ ? overrides.createRealtime(config, started.clientId, http.headers)
303
309
  : config.endpoints.realtimeUrl !== undefined
304
310
  ? webSocketRealtimeConnector(config.endpoints.realtimeUrl.replace('{clientId}', encodeURIComponent(started.clientId)))
305
311
  : undefined;
@@ -383,6 +389,9 @@ export function startSyncWorker(overrides = {}) {
383
389
  presence: (scopeKey) => requireClient().presence(scopeKey),
384
390
  uploadBlob: (bytes, options) => requireClient().uploadBlob(bytes, options),
385
391
  fetchBlob: (blobIdOrRef) => requireClient().fetchBlob(blobIdOrRef),
392
+ setHeaders: (next) => {
393
+ headers = next;
394
+ },
386
395
  setOffline: (value) => {
387
396
  offline = value;
388
397
  if (offline)
@@ -27,7 +27,7 @@ import type { WakeReason } from '@syncular/core';
27
27
  import type { BlobRef, CachedBlob } from './blob.js';
28
28
  import type { ConflictRecord, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, WindowState } from './client.js';
29
29
  import type { SqlRow, SqlValue } from './database.js';
30
- import { ClientDiagnosticsEmitter, type ClientDiagnosticsListener, type ClientDiagnosticsRequest, type ClientDiagnosticsSnapshot } from './diagnostics.js';
30
+ import { ClientDiagnosticsEmitter, type ClientDiagnosticsListener, type ClientDiagnosticsRequest, type ClientDiagnosticsSnapshot, type RealtimePolicy } from './diagnostics.js';
31
31
  import type { EncryptionKeyringConfig } from './encryption.js';
32
32
  import { ClientSyncError } from './errors.js';
33
33
  import { ChangeEmitter, type ClientChangeListener, InvalidationEmitter, type InvalidationListener, type LocalRevision, type SyncStatusSnapshot } from './invalidation.js';
@@ -75,12 +75,23 @@ export interface SyncClientHandleConfig {
75
75
  readonly schema: ClientSchema;
76
76
  readonly database: WorkerDatabaseInit;
77
77
  readonly endpoints: WorkerEndpoints;
78
+ /**
79
+ * Host auth headers for sync, segment, and blob requests (e.g.
80
+ * `Authorization`). Rotate with `setHeaders`. Realtime authenticates
81
+ * through `realtimeUrl`, because WebSockets cannot carry headers.
82
+ */
83
+ readonly headers?: Readonly<Record<string, string>>;
78
84
  /** Structured-clone-safe E2EE keyring installed only in the leader worker. */
79
85
  readonly encryption?: EncryptionKeyringConfig;
80
86
  /** Open the worker-owned replica behind the fail-closed security gate. */
81
87
  readonly securityPreflight?: boolean;
82
88
  readonly clientId?: string;
83
89
  readonly limits?: SyncClientLimits;
90
+ /**
91
+ * SPEC §8.8 realtime policy for the worker-owned core, forwarded into
92
+ * `WorkerInitConfig`. Absent ⇒ `optional`.
93
+ */
94
+ readonly realtimePolicy?: RealtimePolicy;
84
95
  /**
85
96
  * RFC 0005 previous-version context, forwarded into the worker's
86
97
  * `SyncClientConfig`. Absent ⇒ the feature stays off (default-off).
@@ -245,6 +256,14 @@ export declare class SyncClientHandle implements PromiseMethods<WorkerApi> {
245
256
  readonly name?: string;
246
257
  }): Promise<BlobRef>;
247
258
  fetchBlob(blobIdOrRef: string): Promise<CachedBlob>;
259
+ /**
260
+ * Replace the full host auth header set. The next HTTP request uses it;
261
+ * a live realtime socket keeps its handshake credentials. A follower
262
+ * forwards to the leader and keeps the set for its own promotion.
263
+ */
264
+ setHeaders(headers: Readonly<Record<string, string>>): Promise<void>;
265
+ /** @internal — the latest `setHeaders` set, carried into a promotion. */
266
+ __headers: Readonly<Record<string, string>> | undefined;
248
267
  /** Sever/restore transport + realtime inside the worker (demos). */
249
268
  setOffline(offline: boolean): Promise<void>;
250
269
  /** Close the core (leader) or unbind the link (follower), release leadership. */
@@ -361,6 +361,17 @@ export class SyncClientHandle {
361
361
  fetchBlob(blobIdOrRef) {
362
362
  return this.#call('fetchBlob', [blobIdOrRef]);
363
363
  }
364
+ /**
365
+ * Replace the full host auth header set. The next HTTP request uses it;
366
+ * a live realtime socket keeps its handshake credentials. A follower
367
+ * forwards to the leader and keeps the set for its own promotion.
368
+ */
369
+ setHeaders(headers) {
370
+ this.__headers = headers;
371
+ return this.#call('setHeaders', [headers]);
372
+ }
373
+ /** @internal — the latest `setHeaders` set, carried into a promotion. */
374
+ __headers;
364
375
  /** Sever/restore transport + realtime inside the worker (demos). */
365
376
  setOffline(offline) {
366
377
  return this.#call('setOffline', [offline]);
@@ -487,11 +498,12 @@ async function startWorkerCore(options) {
487
498
  throw error;
488
499
  }
489
500
  }
490
- function buildInitConfig(config) {
501
+ function buildInitConfig(config, headers = config.headers) {
491
502
  return {
492
503
  schema: config.schema,
493
504
  database: config.database,
494
505
  endpoints: config.endpoints,
506
+ ...(headers !== undefined ? { headers } : {}),
495
507
  ...(config.encryption !== undefined
496
508
  ? { encryption: config.encryption }
497
509
  : {}),
@@ -500,6 +512,9 @@ function buildInitConfig(config) {
500
512
  : {}),
501
513
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
502
514
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
515
+ ...(config.realtimePolicy !== undefined
516
+ ? { realtimePolicy: config.realtimePolicy }
517
+ : {}),
503
518
  ...(config.previousVersionContext !== undefined
504
519
  ? { previousVersionContext: config.previousVersionContext }
505
520
  : {}),
@@ -691,7 +706,7 @@ async function bootFollower(config, lockName, lock, parts) {
691
706
  try {
692
707
  const core = await startWorkerCore({
693
708
  config,
694
- initConfig: buildInitConfig(config),
709
+ initConfig: buildInitConfig(config, handle.__headers ?? config.headers),
695
710
  lease,
696
711
  dispatchEvent: (event) => {
697
712
  fireConfigCallbacks(config, event);
@@ -21,7 +21,7 @@ import type { WakeReason } from '@syncular/core';
21
21
  import type { BlobRef, CachedBlob } from './blob.js';
22
22
  import type { ConflictRecord, ClientSnapshotMethods, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, WindowState } from './client.js';
23
23
  import type { SqlRow, SqlValue } from './database.js';
24
- import type { ClientDiagnosticsSnapshot } from './diagnostics.js';
24
+ import type { ClientDiagnosticsSnapshot, RealtimePolicy } from './diagnostics.js';
25
25
  import type { EncryptionKeyringConfig } from './encryption.js';
26
26
  import type { ClientChangeBatch, LocalRevision } from './invalidation.js';
27
27
  import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge.js';
@@ -68,12 +68,23 @@ export interface WorkerInitConfig {
68
68
  readonly schema: ClientSchema;
69
69
  readonly database: WorkerDatabaseInit;
70
70
  readonly endpoints: WorkerEndpoints;
71
+ /**
72
+ * Host auth headers for sync, segment, and blob requests. `setHeaders`
73
+ * replaces the whole set at runtime. WebSockets cannot carry headers, so
74
+ * realtime authenticates through `realtimeUrl` (cookie or ticket).
75
+ */
76
+ readonly headers?: Readonly<Record<string, string>>;
71
77
  /** Portable raw keyring installed inside the worker-owned client core. */
72
78
  readonly encryption?: EncryptionKeyringConfig;
73
79
  /** Open the worker-owned replica behind the fail-closed security gate. */
74
80
  readonly securityPreflight?: boolean;
75
81
  readonly clientId?: string;
76
82
  readonly limits?: SyncClientLimits;
83
+ /**
84
+ * SPEC §8.8 realtime policy for the worker-owned core; forwarded verbatim.
85
+ * Absent ⇒ `optional` (a connected socket, otherwise `POST /sync`).
86
+ */
87
+ readonly realtimePolicy?: RealtimePolicy;
77
88
  /**
78
89
  * RFC 0005 previous-version context, forwarded verbatim into the worker's
79
90
  * `SyncClientConfig`. Absent ⇒ the feature stays off (default-off).
@@ -142,6 +153,8 @@ export interface WorkerApi extends Omit<ClientSnapshotMethods, 'querySnapshot'>
142
153
  readonly name?: string;
143
154
  }): Promise<BlobRef>;
144
155
  fetchBlob(blobIdOrRef: string): Promise<CachedBlob>;
156
+ /** Replace the host auth header set; the next HTTP request uses it. */
157
+ setHeaders(headers: Readonly<Record<string, string>>): void;
145
158
  /** Sever/restore the transport + realtime (offline simulation, demos). */
146
159
  setOffline(offline: boolean): void;
147
160
  close(): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.25.1",
3
+ "version": "0.26.0",
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",
@@ -89,10 +89,10 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
92
- "@syncular/core": "0.25.1"
92
+ "@syncular/core": "0.26.0"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.25.1",
95
+ "@syncular/server": "0.26.0",
96
96
  "playwright": "1.63.0"
97
97
  }
98
98
  }
package/src/client.ts CHANGED
@@ -82,9 +82,16 @@ import {
82
82
  MAX_DIAGNOSTIC_DOMAINS,
83
83
  MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
84
84
  MAX_DIAGNOSTIC_QUERY_FAILURES,
85
+ type RealtimePolicy,
86
+ type RealtimeState,
85
87
  } from './diagnostics';
86
88
  import type { EncryptionConfig } from './encryption';
87
- import { ClientSyncError, classifySqliteFailure } from './errors';
89
+ import {
90
+ ClientSyncError,
91
+ classifySqliteFailure,
92
+ REALTIME_LOST_CODE,
93
+ RealtimeUnavailableError,
94
+ } from './errors';
88
95
  import {
89
96
  ChangeAccumulator,
90
97
  ChangeEmitter,
@@ -344,6 +351,15 @@ export interface SyncClientConfig {
344
351
  */
345
352
  readonly blobCacheMaxBytes?: number;
346
353
  readonly realtime?: RealtimeConnector;
354
+ /**
355
+ * SPEC §8.8: how sync rounds treat the realtime binding. `optional`
356
+ * (default) rides a connected socket and otherwise uses `POST /sync`.
357
+ * `required` refuses a round with `RealtimeUnavailableError` while the
358
+ * socket is not connected and never falls back to HTTP. `off` never uses
359
+ * realtime; an explicit `connectRealtime` fails with `sync.invalid_request`.
360
+ * `required` without a `realtime` connector is refused at construction.
361
+ */
362
+ readonly realtimePolicy?: RealtimePolicy;
347
363
  /** Stable per-device id (§1.5); defaults to a persisted random UUID. */
348
364
  readonly clientId?: string;
349
365
  readonly leaderLock?: LeaderLock;
@@ -672,6 +688,12 @@ function isFinalPushResult(frame: PushResultFrame): boolean {
672
688
  );
673
689
  }
674
690
 
691
+ /** The stable `code` of a thrown realtime transport error, when it has one. */
692
+ function realtimeErrorCode(error: unknown): string | undefined {
693
+ const code = (error as { code?: unknown } | undefined)?.code;
694
+ return typeof code === 'string' ? code : undefined;
695
+ }
696
+
675
697
  /** Canonical client reads, shared by synchronous cores and promise hosts. */
676
698
  export type ClientSnapshotMethods = Pick<
677
699
  SyncClient,
@@ -725,6 +747,11 @@ export class SyncClient {
725
747
  #conflicts: ConflictRecord[] = [];
726
748
  #rejections: RejectionRecord[] = [];
727
749
  #socket: RealtimeSocket | undefined;
750
+ /** SPEC §8.8: the explicit realtime availability state and its evidence. */
751
+ readonly #realtimePolicy: RealtimePolicy;
752
+ #realtimeState: RealtimeState;
753
+ #realtimeReasonCode: string | undefined;
754
+ #realtimeRetryDelayMs: number | undefined;
728
755
  #realtimeConnectPromise: Promise<void> | undefined;
729
756
  #realtimeGeneration = 0;
730
757
  #pendingRound: PendingRound | undefined;
@@ -802,9 +829,33 @@ export class SyncClient {
802
829
  'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight',
803
830
  );
804
831
  }
805
- this.#config = config;
806
832
  this.#db = config.database;
807
833
  this.#schema = compileClientSchema(config.schema);
834
+ const realtimePolicy = config.realtimePolicy ?? 'optional';
835
+ if (
836
+ realtimePolicy !== 'required' &&
837
+ realtimePolicy !== 'optional' &&
838
+ realtimePolicy !== 'off'
839
+ ) {
840
+ throw new ClientSyncError(
841
+ 'sync.invalid_request',
842
+ "realtimePolicy must be 'required', 'optional', or 'off'",
843
+ );
844
+ }
845
+ if (realtimePolicy === 'required' && config.realtime === undefined) {
846
+ throw new ClientSyncError(
847
+ 'sync.invalid_request',
848
+ "realtimePolicy 'required' needs a realtime connector",
849
+ );
850
+ }
851
+ this.#config = config;
852
+ this.#realtimePolicy = realtimePolicy;
853
+ this.#realtimeState =
854
+ realtimePolicy === 'off'
855
+ ? 'disabled'
856
+ : config.realtime === undefined
857
+ ? 'unsupported'
858
+ : 'disconnected';
808
859
  this.#encryption = config.encryption;
809
860
  this.#securityLifecycle =
810
861
  config.securityPreflight === true ? 'preflight' : 'active';
@@ -1751,12 +1802,14 @@ export class SyncClient {
1751
1802
  kind: 'direct',
1752
1803
  role: 'single',
1753
1804
  connectivity,
1754
- realtime:
1755
- this.#config.realtime === undefined
1756
- ? 'unsupported'
1757
- : this.#socket === undefined
1758
- ? 'disconnected'
1759
- : 'connected',
1805
+ realtime: this.#realtimeState,
1806
+ realtimePolicy: this.#realtimePolicy,
1807
+ ...(this.#realtimeReasonCode !== undefined
1808
+ ? { realtimeReasonCode: this.#realtimeReasonCode }
1809
+ : {}),
1810
+ ...(this.#realtimeRetryDelayMs !== undefined
1811
+ ? { realtimeRetryDelayMs: this.#realtimeRetryDelayMs }
1812
+ : {}),
1760
1813
  },
1761
1814
  securityLifecycle: this.#securityLifecycle,
1762
1815
  schema: {
@@ -3532,9 +3585,20 @@ export class SyncClient {
3532
3585
  explicitlyRetryable === true ||
3533
3586
  (explicitlyRetryable === undefined && typeof code !== 'string');
3534
3587
  if (retryable) {
3588
+ const realtimeUnavailable = error instanceof RealtimeUnavailableError;
3589
+ const delay = realtimeUnavailable
3590
+ ? error.retryDelayMs
3591
+ : this.#retryDelayMs;
3592
+ if (
3593
+ realtimeUnavailable ||
3594
+ this.#realtimeState === 'lost' ||
3595
+ this.#realtimeState === 'refused'
3596
+ ) {
3597
+ this.#realtimeRetryDelayMs = delay;
3598
+ }
3535
3599
  const intent: SyncIntent = {
3536
3600
  kind: 'background',
3537
- delayMs: this.#retryDelayMs,
3601
+ delayMs: delay,
3538
3602
  };
3539
3603
  this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
3540
3604
  this.#emitSyncIntent(intent);
@@ -3593,50 +3657,60 @@ export class SyncClient {
3593
3657
  * is connected (the socket IS the sync-round
3594
3658
  * transport, not a fallback pair), otherwise through the configured
3595
3659
  * `SyncTransport` seam (loopback/conformance hosts, HTTP-only
3596
- * producers).
3660
+ * producers). Under `realtimePolicy: 'required'` an unconnected socket
3661
+ * refuses the round with `RealtimeUnavailableError` instead of using the
3662
+ * seam (SPEC §8.8).
3597
3663
  */
3598
3664
  #roundTrip(request: Uint8Array): Promise<Uint8Array> {
3599
3665
  const socket = this.#socket;
3600
- if (socket === undefined) {
3601
- return Promise.resolve()
3602
- .then(() => this.#config.transport(request))
3603
- .catch((error: unknown) => {
3604
- if (
3605
- error instanceof ClientSyncError ||
3606
- typeof (error as { code?: unknown })?.code === 'string'
3607
- ) {
3608
- throw error;
3609
- }
3610
- throw new ClientSyncError(
3611
- 'sync.transport_failed',
3612
- `transport round failed: ${error instanceof Error ? error.message : String(error)}`,
3613
- true,
3666
+ if (socket !== undefined && this.#realtimePolicy !== 'off') {
3667
+ return new Promise<Uint8Array>((resolve, reject) => {
3668
+ // sync() already enforces one round in flight (§8.7).
3669
+ this.#pendingRound = {
3670
+ scanner: new MessageStreamScanner(),
3671
+ resolve,
3672
+ reject,
3673
+ };
3674
+ const tagged = new Uint8Array(request.length + 1);
3675
+ tagged[0] = REALTIME_TAG_ROUND;
3676
+ tagged.set(request, 1);
3677
+ try {
3678
+ socket.sendBytes(tagged);
3679
+ } catch (error) {
3680
+ this.#pendingRound = undefined;
3681
+ // The socket could not carry the round: record the same loss the
3682
+ // Rust core records when its realtime round call fails (§8.8).
3683
+ this.#setRealtimeState('lost', 'sync.transport_failed');
3684
+ reject(
3685
+ new ClientSyncError(
3686
+ 'sync.transport_failed',
3687
+ `socket round send failed: ${error instanceof Error ? error.message : String(error)}`,
3688
+ true,
3689
+ ),
3614
3690
  );
3615
- });
3691
+ }
3692
+ });
3616
3693
  }
3617
- return new Promise<Uint8Array>((resolve, reject) => {
3618
- // sync() already enforces one round in flight (§8.7).
3619
- this.#pendingRound = {
3620
- scanner: new MessageStreamScanner(),
3621
- resolve,
3622
- reject,
3623
- };
3624
- const tagged = new Uint8Array(request.length + 1);
3625
- tagged[0] = REALTIME_TAG_ROUND;
3626
- tagged.set(request, 1);
3627
- try {
3628
- socket.sendBytes(tagged);
3629
- } catch (error) {
3630
- this.#pendingRound = undefined;
3631
- reject(
3632
- new ClientSyncError(
3633
- 'sync.transport_failed',
3634
- `socket round send failed: ${error instanceof Error ? error.message : String(error)}`,
3635
- true,
3636
- ),
3694
+ if (this.#realtimePolicy === 'required') {
3695
+ // §8.8: the designated realtime path is down. Refuse the round; the
3696
+ // HTTP binding is not an alternative for a `required` client.
3697
+ throw this.#realtimeUnavailableError();
3698
+ }
3699
+ return Promise.resolve()
3700
+ .then(() => this.#config.transport(request))
3701
+ .catch((error: unknown) => {
3702
+ if (
3703
+ error instanceof ClientSyncError ||
3704
+ typeof (error as { code?: unknown })?.code === 'string'
3705
+ ) {
3706
+ throw error;
3707
+ }
3708
+ throw new ClientSyncError(
3709
+ 'sync.transport_failed',
3710
+ `transport round failed: ${error instanceof Error ? error.message : String(error)}`,
3711
+ true,
3637
3712
  );
3638
- }
3639
- });
3713
+ });
3640
3714
  }
3641
3715
 
3642
3716
  /** Abort the in-flight socket round (socket closed or disconnected). */
@@ -3649,13 +3723,51 @@ export class SyncClient {
3649
3723
 
3650
3724
  // -- realtime (§8 client side) ----------------------------------------------
3651
3725
 
3726
+ /**
3727
+ * SPEC §8.8: record an availability transition and the evidence behind it.
3728
+ * The reason and retry delay belong to the state that is no longer
3729
+ * connected; `connected` and a policy-restricted state clear them.
3730
+ */
3731
+ #setRealtimeState(state: RealtimeState, reasonCode?: string): void {
3732
+ this.#realtimeState = state;
3733
+ this.#realtimeReasonCode =
3734
+ reasonCode === undefined ? undefined : this.#diagnosticCode(reasonCode);
3735
+ if (
3736
+ state === 'connected' ||
3737
+ state === 'disabled' ||
3738
+ state === 'unsupported'
3739
+ ) {
3740
+ this.#realtimeRetryDelayMs = undefined;
3741
+ }
3742
+ this.#emitDiagnostics();
3743
+ }
3744
+
3745
+ #realtimeUnavailableError(): RealtimeUnavailableError {
3746
+ this.#realtimeRetryDelayMs = this.#retryDelayMs;
3747
+ return new RealtimeUnavailableError(
3748
+ this.#realtimeState,
3749
+ this.#realtimeReasonCode,
3750
+ this.#retryDelayMs,
3751
+ );
3752
+ }
3753
+
3652
3754
  connectRealtime(): Promise<void> {
3653
3755
  this.#requireActive();
3756
+ if (this.#realtimePolicy === 'off') {
3757
+ return Promise.reject(
3758
+ new ClientSyncError(
3759
+ 'sync.invalid_request',
3760
+ "realtimePolicy is 'off'; realtime connect is refused",
3761
+ ),
3762
+ );
3763
+ }
3654
3764
  if (this.#socket !== undefined) return Promise.resolve();
3655
3765
  if (this.#realtimeConnectPromise !== undefined) {
3656
3766
  return this.#realtimeConnectPromise;
3657
3767
  }
3658
3768
  const generation = this.#realtimeGeneration;
3769
+ // The attempt is visible synchronously; runners of the task share it.
3770
+ this.#setRealtimeState('connecting');
3659
3771
  const task = this.#runProtectedAsync(() =>
3660
3772
  this.#connectRealtime(generation),
3661
3773
  );
@@ -3684,40 +3796,53 @@ export class SyncClient {
3684
3796
  );
3685
3797
  }
3686
3798
  let openedSocket: RealtimeSocket | undefined;
3687
- const socket = await connector({
3688
- onText: (text) => {
3689
- if (generation !== this.#realtimeGeneration || !this.#started) return;
3690
- this.#handleRealtimeText(text);
3691
- },
3692
- onBinary: (bytes) => {
3693
- if (generation !== this.#realtimeGeneration || !this.#started) return;
3694
- this.#routeRealtimeBinary(bytes);
3695
- },
3696
- onClose: () => {
3697
- if (openedSocket === undefined || this.#socket !== openedSocket) return;
3698
- this.#socket = undefined;
3699
- this.#presence.clear(); // §8.6.1: presence is per-connection
3700
- this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
3701
- this.#emitDiagnostics();
3702
- },
3703
- });
3704
- openedSocket = socket;
3705
- if (this.#securityLifecycle === 'preflight') {
3706
- socket.close();
3707
- throw new ClientSyncError(
3708
- SECURITY_PREFLIGHT_REQUIRED_CODE,
3709
- 'realtime connected after the client entered security preflight',
3710
- );
3711
- }
3712
- if (generation !== this.#realtimeGeneration || !this.#started) {
3713
- socket.close();
3714
- throw new ClientSyncError(
3715
- 'client.realtime_cancelled',
3716
- 'realtime connection was cancelled before activation',
3717
- );
3799
+ try {
3800
+ const socket = await connector({
3801
+ onText: (text) => {
3802
+ if (generation !== this.#realtimeGeneration || !this.#started) return;
3803
+ this.#handleRealtimeText(text);
3804
+ },
3805
+ onBinary: (bytes) => {
3806
+ if (generation !== this.#realtimeGeneration || !this.#started) return;
3807
+ this.#routeRealtimeBinary(bytes);
3808
+ },
3809
+ onClose: () => {
3810
+ if (openedSocket === undefined || this.#socket !== openedSocket)
3811
+ return;
3812
+ this.#socket = undefined;
3813
+ this.#presence.clear(); // §8.6.1: presence is per-connection
3814
+ // §8.8: an undesired close is a visible lost state, not a silent
3815
+ // downgrade to HTTP rounds.
3816
+ this.#setRealtimeState('lost', REALTIME_LOST_CODE);
3817
+ this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
3818
+ this.#emitDiagnostics();
3819
+ },
3820
+ });
3821
+ openedSocket = socket;
3822
+ if (this.#securityLifecycle === 'preflight') {
3823
+ socket.close();
3824
+ throw new ClientSyncError(
3825
+ SECURITY_PREFLIGHT_REQUIRED_CODE,
3826
+ 'realtime connected after the client entered security preflight',
3827
+ );
3828
+ }
3829
+ if (generation !== this.#realtimeGeneration || !this.#started) {
3830
+ socket.close();
3831
+ throw new ClientSyncError(
3832
+ 'client.realtime_cancelled',
3833
+ 'realtime connection was cancelled before activation',
3834
+ );
3835
+ }
3836
+ this.#socket = socket;
3837
+ this.#setRealtimeState('connected');
3838
+ } catch (error) {
3839
+ // A superseded attempt leaves the successor's (or the deliberate
3840
+ // disconnect's) state alone.
3841
+ if (generation === this.#realtimeGeneration) {
3842
+ this.#setRealtimeState('refused', realtimeErrorCode(error));
3843
+ }
3844
+ throw error;
3718
3845
  }
3719
- this.#socket = socket;
3720
- this.#emitDiagnostics();
3721
3846
  }
3722
3847
 
3723
3848
  disconnectRealtime(): void {
@@ -3727,7 +3852,13 @@ export class SyncClient {
3727
3852
  this.#socket = undefined;
3728
3853
  this.#presence.clear(); // §8.6.1: presence is per-connection
3729
3854
  this.#abortPendingRound('realtime socket disconnected mid-round (§8.7)');
3730
- this.#emitDiagnostics();
3855
+ this.#setRealtimeState(
3856
+ this.#realtimePolicy === 'off'
3857
+ ? 'disabled'
3858
+ : this.#config.realtime === undefined
3859
+ ? 'unsupported'
3860
+ : 'disconnected',
3861
+ );
3731
3862
  }
3732
3863
 
3733
3864
  /**
@@ -23,17 +23,32 @@ export type ClientDiagnosticsHostRole =
23
23
  | 'follower'
24
24
  | 'unknown';
25
25
  export type ClientDiagnosticsConnectivity = 'online' | 'offline' | 'unknown';
26
- export type ClientDiagnosticsRealtime =
26
+ /**
27
+ * SPEC §8.8: how a client treats the realtime binding for sync rounds. The
28
+ * default is `optional`.
29
+ */
30
+ export type RealtimePolicy = 'required' | 'optional' | 'off';
31
+ export type RealtimeState =
27
32
  | 'connected'
33
+ | 'connecting'
28
34
  | 'disconnected'
35
+ | 'lost'
36
+ | 'refused'
29
37
  | 'unsupported'
38
+ | 'disabled'
30
39
  | 'unknown';
31
40
 
32
41
  export interface ClientDiagnosticsHost {
33
42
  readonly kind: ClientDiagnosticsHostKind;
34
43
  readonly role: ClientDiagnosticsHostRole;
35
44
  readonly connectivity: ClientDiagnosticsConnectivity;
36
- readonly realtime: ClientDiagnosticsRealtime;
45
+ readonly realtime: RealtimeState;
46
+ /** Present on core-emitted snapshots; host wrappers may forward only `realtime`. */
47
+ readonly realtimePolicy?: RealtimePolicy;
48
+ /** Stable code behind a `lost` or `refused` state (SPEC §8.8). */
49
+ readonly realtimeReasonCode?: string;
50
+ /** Next retry delay in ms while the socket is not connected. */
51
+ readonly realtimeRetryDelayMs?: number;
37
52
  }
38
53
 
39
54
  export interface ExpectedDiagnosticSubscription {