@syncular/client 0.25.2 → 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.
@@ -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.2",
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.2"
92
+ "@syncular/core": "0.26.0"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.25.2",
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 {
package/src/errors.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { RealtimeState } from './diagnostics';
2
+
1
3
  /** A persistent local store is temporarily owned by another live engine. */
2
4
  export const STORAGE_BUSY_CODE = 'client.storage_busy';
3
5
 
@@ -10,13 +12,19 @@ export const STORAGE_CORRUPT_CODE = 'client.storage_corrupt';
10
12
  /** A local read hit SQLITE_IOERR (SPEC §7.5). */
11
13
  export const STORAGE_IO_CODE = 'client.storage_io';
12
14
 
15
+ /** SPEC §8.8: a round under `realtimePolicy: 'required'` found no socket. */
16
+ export const REALTIME_UNAVAILABLE_CODE = 'sync.realtime_unavailable';
17
+
18
+ /** SPEC §8.8: a connected socket ended without a deliberate disconnect. */
19
+ export const REALTIME_LOST_CODE = 'client.realtime_lost';
20
+
13
21
  /**
14
22
  * Client-side errors. Protocol codes from the SPEC.md §10 catalog are surfaced
15
23
  * unchanged. Host/runtime-only conditions may use the separate `client.*`
16
24
  * namespace and never travel on the wire.
17
25
  */
18
26
  export class ClientSyncError extends Error {
19
- override readonly name = 'ClientSyncError';
27
+ override readonly name: string = 'ClientSyncError';
20
28
  readonly code: string;
21
29
  readonly retryable: boolean;
22
30
 
@@ -27,6 +35,35 @@ export class ClientSyncError extends Error {
27
35
  }
28
36
  }
29
37
 
38
+ /**
39
+ * SPEC §8.8: the `required` realtime policy refused a round because the
40
+ * socket is not connected. `state` is the availability state that refused
41
+ * (`connecting`, `lost`, `refused`, or `disconnected`), `reasonCode` is the
42
+ * stable code behind a `lost` or `refused` state, and `retryDelayMs` is the
43
+ * delay of the background retry intent the client scheduled with this throw.
44
+ */
45
+ export class RealtimeUnavailableError extends ClientSyncError {
46
+ override readonly name = 'RealtimeUnavailableError';
47
+ readonly state: RealtimeState;
48
+ readonly reasonCode: string | undefined;
49
+ readonly retryDelayMs: number;
50
+
51
+ constructor(
52
+ state: RealtimeState,
53
+ reasonCode: string | undefined,
54
+ retryDelayMs: number,
55
+ ) {
56
+ super(
57
+ REALTIME_UNAVAILABLE_CODE,
58
+ 'realtime is required and the realtime socket is not connected',
59
+ true,
60
+ );
61
+ this.state = state;
62
+ this.reasonCode = reasonCode;
63
+ this.retryDelayMs = retryDelayMs;
64
+ }
65
+ }
66
+
30
67
  /**
31
68
  * SPEC §7.5: classify a local SQLite read failure by the numeric result code
32
69
  * the driver exposes (bun:sqlite `errno`, node:sqlite `errcode`, sqlite-wasm