@syncular/client 0.25.2 → 0.26.1

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.
package/README.md CHANGED
@@ -195,14 +195,42 @@ flushed to the new leader on its announce; past the deadline they fail
195
195
  loudly with `client.follower_timeout` (never a silent hang), and an
196
196
  overflowing queue rejects rather than growing unbounded.
197
197
 
198
- Leader announcements continue as heartbeats while followers are attached. If
199
- a tab can acquire neither a response nor a new lock grant before the configured
200
- `followerCallTimeoutMs`, `handle.leadership` becomes
198
+ **Leader liveness.** Followers probe the leader; the leader runs no liveness
199
+ timer. A bound follower that has heard no announcement for a third of
200
+ `followerCallTimeoutMs` (default 10 s) posts a `hello`, and the leader answers
201
+ every `hello` with an announcement from its message handler. Browsers throttle
202
+ the timers of hidden tabs (Chrome's intensive throttling wakes them about once
203
+ a minute) but still dispatch their `BroadcastChannel` messages, so a leader in
204
+ a background tab keeps its followers bound. When a probe stays unanswered for
205
+ the rest of `followerCallTimeoutMs`, `handle.leadership` becomes
201
206
  `{ state: 'blocked', reason: 'leader-unreachable', code:
202
- 'client.follower_timeout', retryable: true }`. Calls then reject immediately.
203
- A later announcement rebinds the same handle; a granted Web Lock promotes it.
204
- An unreachable `BroadcastChannel` is never treated as evidence that the lock
205
- owner is stale, so it never authorizes a second worker or database owner.
207
+ 'client.follower_timeout', retryable: true }` and calls reject immediately.
208
+ A leader tab that processes no messages (hung or frozen) therefore blocks its
209
+ followers within `followerCallTimeoutMs`; a closed leader releases its Web
210
+ Lock and hands over without waiting for that deadline. A blocked follower keeps
211
+ probing at the same interval: an answer rebinds the same handle, and a granted
212
+ Web Lock promotes it. A single call that runs past `followerCallTimeoutMs`
213
+ rejects with `client.follower_timeout` and leaves the handle bound, because
214
+ reachability is decided by the probe alone. An unreachable `BroadcastChannel`
215
+ is never treated as evidence that the lock owner is stale, so it never
216
+ authorizes a second worker or database owner.
217
+
218
+ **Upgrade note: reload every open tab.** Followers running 0.26.0 or older
219
+ wait for a leader heartbeat that current leaders no longer send. Such a tab
220
+ next to a current leader reports `leader-unreachable` with
221
+ `client.follower_timeout` after `followerCallTimeoutMs`, unless a current
222
+ follower's probe happens to make the leader announce, and stays that way
223
+ until it reloads. A current follower next to an older leader works, because
224
+ older leaders answer every `hello`. After upgrading, reload every open tab of
225
+ the origin.
226
+
227
+ **A hidden leader keeps leadership.** A visible tab does not take leadership
228
+ from a hidden leader. Web Locks `steal` revokes the lease while the leader's
229
+ worker still holds the OPFS database and may be mid-commit, so a transfer
230
+ needs a cooperative close-and-release handshake, and every focus change
231
+ between tabs would restart the worker, reopen the database, and reconnect the
232
+ realtime socket. A hidden leader answers its followers from its message
233
+ handler, so leadership moves only when the leader tab closes.
206
234
 
207
235
  **Presence semantics — one device, one peer.** All tabs share the leader's
208
236
  single connection, so a device is exactly ONE presence peer collectively:
package/dist/client.d.ts CHANGED
@@ -11,7 +11,7 @@ import { type SyncProgress, type SyncProgressListener } from './progress.js';
11
11
  import { type ScopeMap, type WakeReason } from '@syncular/core';
12
12
  import { type BlobRef, type BlobTransport, type CachedBlob } from './blob.js';
13
13
  import type { ClientDatabase, SqlRow, SqlValue } from './database.js';
14
- import { type ClientDiagnosticsListener, type ClientDiagnosticsRequest, type ClientDiagnosticsSnapshot } from './diagnostics.js';
14
+ import { type ClientDiagnosticsListener, type ClientDiagnosticsRequest, type ClientDiagnosticsSnapshot, type RealtimePolicy } from './diagnostics.js';
15
15
  import type { EncryptionConfig } from './encryption.js';
16
16
  import { type ClientChangeListener, type CommandResult, type InvalidationListener, type LocalRevision, type SyncIntent, type SyncStatusSnapshot } from './invalidation.js';
17
17
  import { type LeaderLock } from './leader-lock.js';
@@ -119,6 +119,15 @@ export interface SyncClientConfig {
119
119
  */
120
120
  readonly blobCacheMaxBytes?: number;
121
121
  readonly realtime?: RealtimeConnector;
122
+ /**
123
+ * SPEC §8.8: how sync rounds treat the realtime binding. `optional`
124
+ * (default) rides a connected socket and otherwise uses `POST /sync`.
125
+ * `required` refuses a round with `RealtimeUnavailableError` while the
126
+ * socket is not connected and never falls back to HTTP. `off` never uses
127
+ * realtime; an explicit `connectRealtime` fails with `sync.invalid_request`.
128
+ * `required` without a `realtime` connector is refused at construction.
129
+ */
130
+ readonly realtimePolicy?: RealtimePolicy;
122
131
  /** Stable per-device id (§1.5); defaults to a persisted random UUID. */
123
132
  readonly clientId?: string;
124
133
  readonly leaderLock?: LeaderLock;
package/dist/client.js CHANGED
@@ -14,7 +14,7 @@ import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow,
14
14
  import { clearPendingUpload, computeBlobId, deleteUnreferencedCachedBlobs, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
15
15
  import { registerDevtools } from './devtools.js';
16
16
  import { CLIENT_DIAGNOSTICS_VERSION, ClientDiagnosticsEmitter, MAX_DIAGNOSTIC_DOMAINS, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS, MAX_DIAGNOSTIC_QUERY_FAILURES, } from './diagnostics.js';
17
- import { ClientSyncError, classifySqliteFailure } from './errors.js';
17
+ import { ClientSyncError, classifySqliteFailure, REALTIME_LOST_CODE, RealtimeUnavailableError, } from './errors.js';
18
18
  import { ChangeAccumulator, ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './invalidation.js';
19
19
  import { singleOwnerLock, } from './leader-lock.js';
20
20
  import { compileLocalDataPurge, localDataPurgeMetaKey, localDataPurgeTargetMatches, } from './local-purge.js';
@@ -117,6 +117,11 @@ function isFinalPushResult(frame) {
117
117
  result.code === 'sync.idempotency_cache_miss' &&
118
118
  result.retryable));
119
119
  }
120
+ /** The stable `code` of a thrown realtime transport error, when it has one. */
121
+ function realtimeErrorCode(error) {
122
+ const code = error?.code;
123
+ return typeof code === 'string' ? code : undefined;
124
+ }
120
125
  export class SyncClient {
121
126
  #config;
122
127
  #db;
@@ -139,6 +144,11 @@ export class SyncClient {
139
144
  #conflicts = [];
140
145
  #rejections = [];
141
146
  #socket;
147
+ /** SPEC §8.8: the explicit realtime availability state and its evidence. */
148
+ #realtimePolicy;
149
+ #realtimeState;
150
+ #realtimeReasonCode;
151
+ #realtimeRetryDelayMs;
142
152
  #realtimeConnectPromise;
143
153
  #realtimeGeneration = 0;
144
154
  #pendingRound;
@@ -210,9 +220,25 @@ export class SyncClient {
210
220
  if (config.securityPreflight === true && config.encryption !== undefined) {
211
221
  throw new ClientSyncError('sync.invalid_request', 'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight');
212
222
  }
213
- this.#config = config;
214
223
  this.#db = config.database;
215
224
  this.#schema = compileClientSchema(config.schema);
225
+ const realtimePolicy = config.realtimePolicy ?? 'optional';
226
+ if (realtimePolicy !== 'required' &&
227
+ realtimePolicy !== 'optional' &&
228
+ realtimePolicy !== 'off') {
229
+ throw new ClientSyncError('sync.invalid_request', "realtimePolicy must be 'required', 'optional', or 'off'");
230
+ }
231
+ if (realtimePolicy === 'required' && config.realtime === undefined) {
232
+ throw new ClientSyncError('sync.invalid_request', "realtimePolicy 'required' needs a realtime connector");
233
+ }
234
+ this.#config = config;
235
+ this.#realtimePolicy = realtimePolicy;
236
+ this.#realtimeState =
237
+ realtimePolicy === 'off'
238
+ ? 'disabled'
239
+ : config.realtime === undefined
240
+ ? 'unsupported'
241
+ : 'disconnected';
216
242
  this.#encryption = config.encryption;
217
243
  this.#securityLifecycle =
218
244
  config.securityPreflight === true ? 'preflight' : 'active';
@@ -1013,11 +1039,14 @@ export class SyncClient {
1013
1039
  kind: 'direct',
1014
1040
  role: 'single',
1015
1041
  connectivity,
1016
- realtime: this.#config.realtime === undefined
1017
- ? 'unsupported'
1018
- : this.#socket === undefined
1019
- ? 'disconnected'
1020
- : 'connected',
1042
+ realtime: this.#realtimeState,
1043
+ realtimePolicy: this.#realtimePolicy,
1044
+ ...(this.#realtimeReasonCode !== undefined
1045
+ ? { realtimeReasonCode: this.#realtimeReasonCode }
1046
+ : {}),
1047
+ ...(this.#realtimeRetryDelayMs !== undefined
1048
+ ? { realtimeRetryDelayMs: this.#realtimeRetryDelayMs }
1049
+ : {}),
1021
1050
  },
1022
1051
  securityLifecycle: this.#securityLifecycle,
1023
1052
  schema: {
@@ -2465,9 +2494,18 @@ export class SyncClient {
2465
2494
  const retryable = explicitlyRetryable === true ||
2466
2495
  (explicitlyRetryable === undefined && typeof code !== 'string');
2467
2496
  if (retryable) {
2497
+ const realtimeUnavailable = error instanceof RealtimeUnavailableError;
2498
+ const delay = realtimeUnavailable
2499
+ ? error.retryDelayMs
2500
+ : this.#retryDelayMs;
2501
+ if (realtimeUnavailable ||
2502
+ this.#realtimeState === 'lost' ||
2503
+ this.#realtimeState === 'refused') {
2504
+ this.#realtimeRetryDelayMs = delay;
2505
+ }
2468
2506
  const intent = {
2469
2507
  kind: 'background',
2470
- delayMs: this.#retryDelayMs,
2508
+ delayMs: delay,
2471
2509
  };
2472
2510
  this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
2473
2511
  this.#emitSyncIntent(intent);
@@ -2515,38 +2553,48 @@ export class SyncClient {
2515
2553
  * is connected (the socket IS the sync-round
2516
2554
  * transport, not a fallback pair), otherwise through the configured
2517
2555
  * `SyncTransport` seam (loopback/conformance hosts, HTTP-only
2518
- * producers).
2556
+ * producers). Under `realtimePolicy: 'required'` an unconnected socket
2557
+ * refuses the round with `RealtimeUnavailableError` instead of using the
2558
+ * seam (SPEC §8.8).
2519
2559
  */
2520
2560
  #roundTrip(request) {
2521
2561
  const socket = this.#socket;
2522
- if (socket === undefined) {
2523
- return Promise.resolve()
2524
- .then(() => this.#config.transport(request))
2525
- .catch((error) => {
2526
- if (error instanceof ClientSyncError ||
2527
- typeof error?.code === 'string') {
2528
- throw error;
2562
+ if (socket !== undefined && this.#realtimePolicy !== 'off') {
2563
+ return new Promise((resolve, reject) => {
2564
+ // sync() already enforces one round in flight (§8.7).
2565
+ this.#pendingRound = {
2566
+ scanner: new MessageStreamScanner(),
2567
+ resolve,
2568
+ reject,
2569
+ };
2570
+ const tagged = new Uint8Array(request.length + 1);
2571
+ tagged[0] = REALTIME_TAG_ROUND;
2572
+ tagged.set(request, 1);
2573
+ try {
2574
+ socket.sendBytes(tagged);
2575
+ }
2576
+ catch (error) {
2577
+ this.#pendingRound = undefined;
2578
+ // The socket could not carry the round: record the same loss the
2579
+ // Rust core records when its realtime round call fails (§8.8).
2580
+ this.#setRealtimeState('lost', 'sync.transport_failed');
2581
+ reject(new ClientSyncError('sync.transport_failed', `socket round send failed: ${error instanceof Error ? error.message : String(error)}`, true));
2529
2582
  }
2530
- throw new ClientSyncError('sync.transport_failed', `transport round failed: ${error instanceof Error ? error.message : String(error)}`, true);
2531
2583
  });
2532
2584
  }
2533
- return new Promise((resolve, reject) => {
2534
- // sync() already enforces one round in flight (§8.7).
2535
- this.#pendingRound = {
2536
- scanner: new MessageStreamScanner(),
2537
- resolve,
2538
- reject,
2539
- };
2540
- const tagged = new Uint8Array(request.length + 1);
2541
- tagged[0] = REALTIME_TAG_ROUND;
2542
- tagged.set(request, 1);
2543
- try {
2544
- socket.sendBytes(tagged);
2545
- }
2546
- catch (error) {
2547
- this.#pendingRound = undefined;
2548
- reject(new ClientSyncError('sync.transport_failed', `socket round send failed: ${error instanceof Error ? error.message : String(error)}`, true));
2585
+ if (this.#realtimePolicy === 'required') {
2586
+ // §8.8: the designated realtime path is down. Refuse the round; the
2587
+ // HTTP binding is not an alternative for a `required` client.
2588
+ throw this.#realtimeUnavailableError();
2589
+ }
2590
+ return Promise.resolve()
2591
+ .then(() => this.#config.transport(request))
2592
+ .catch((error) => {
2593
+ if (error instanceof ClientSyncError ||
2594
+ typeof error?.code === 'string') {
2595
+ throw error;
2549
2596
  }
2597
+ throw new ClientSyncError('sync.transport_failed', `transport round failed: ${error instanceof Error ? error.message : String(error)}`, true);
2550
2598
  });
2551
2599
  }
2552
2600
  /** Abort the in-flight socket round (socket closed or disconnected). */
@@ -2558,14 +2606,39 @@ export class SyncClient {
2558
2606
  round.reject(new ClientSyncError('sync.transport_failed', reason, true));
2559
2607
  }
2560
2608
  // -- realtime (§8 client side) ----------------------------------------------
2609
+ /**
2610
+ * SPEC §8.8: record an availability transition and the evidence behind it.
2611
+ * The reason and retry delay belong to the state that is no longer
2612
+ * connected; `connected` and a policy-restricted state clear them.
2613
+ */
2614
+ #setRealtimeState(state, reasonCode) {
2615
+ this.#realtimeState = state;
2616
+ this.#realtimeReasonCode =
2617
+ reasonCode === undefined ? undefined : this.#diagnosticCode(reasonCode);
2618
+ if (state === 'connected' ||
2619
+ state === 'disabled' ||
2620
+ state === 'unsupported') {
2621
+ this.#realtimeRetryDelayMs = undefined;
2622
+ }
2623
+ this.#emitDiagnostics();
2624
+ }
2625
+ #realtimeUnavailableError() {
2626
+ this.#realtimeRetryDelayMs = this.#retryDelayMs;
2627
+ return new RealtimeUnavailableError(this.#realtimeState, this.#realtimeReasonCode, this.#retryDelayMs);
2628
+ }
2561
2629
  connectRealtime() {
2562
2630
  this.#requireActive();
2631
+ if (this.#realtimePolicy === 'off') {
2632
+ return Promise.reject(new ClientSyncError('sync.invalid_request', "realtimePolicy is 'off'; realtime connect is refused"));
2633
+ }
2563
2634
  if (this.#socket !== undefined)
2564
2635
  return Promise.resolve();
2565
2636
  if (this.#realtimeConnectPromise !== undefined) {
2566
2637
  return this.#realtimeConnectPromise;
2567
2638
  }
2568
2639
  const generation = this.#realtimeGeneration;
2640
+ // The attempt is visible synchronously; runners of the task share it.
2641
+ this.#setRealtimeState('connecting');
2569
2642
  const task = this.#runProtectedAsync(() => this.#connectRealtime(generation));
2570
2643
  this.#realtimeConnectPromise = task;
2571
2644
  void task.then(() => {
@@ -2585,37 +2658,50 @@ export class SyncClient {
2585
2658
  throw new ClientSyncError('sync.invalid_request', 'no realtime connector configured');
2586
2659
  }
2587
2660
  let openedSocket;
2588
- const socket = await connector({
2589
- onText: (text) => {
2590
- if (generation !== this.#realtimeGeneration || !this.#started)
2591
- return;
2592
- this.#handleRealtimeText(text);
2593
- },
2594
- onBinary: (bytes) => {
2595
- if (generation !== this.#realtimeGeneration || !this.#started)
2596
- return;
2597
- this.#routeRealtimeBinary(bytes);
2598
- },
2599
- onClose: () => {
2600
- if (openedSocket === undefined || this.#socket !== openedSocket)
2601
- return;
2602
- this.#socket = undefined;
2603
- this.#presence.clear(); // §8.6.1: presence is per-connection
2604
- this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
2605
- this.#emitDiagnostics();
2606
- },
2607
- });
2608
- openedSocket = socket;
2609
- if (this.#securityLifecycle === 'preflight') {
2610
- socket.close();
2611
- throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'realtime connected after the client entered security preflight');
2661
+ try {
2662
+ const socket = await connector({
2663
+ onText: (text) => {
2664
+ if (generation !== this.#realtimeGeneration || !this.#started)
2665
+ return;
2666
+ this.#handleRealtimeText(text);
2667
+ },
2668
+ onBinary: (bytes) => {
2669
+ if (generation !== this.#realtimeGeneration || !this.#started)
2670
+ return;
2671
+ this.#routeRealtimeBinary(bytes);
2672
+ },
2673
+ onClose: () => {
2674
+ if (openedSocket === undefined || this.#socket !== openedSocket)
2675
+ return;
2676
+ this.#socket = undefined;
2677
+ this.#presence.clear(); // §8.6.1: presence is per-connection
2678
+ // §8.8: an undesired close is a visible lost state, not a silent
2679
+ // downgrade to HTTP rounds.
2680
+ this.#setRealtimeState('lost', REALTIME_LOST_CODE);
2681
+ this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
2682
+ this.#emitDiagnostics();
2683
+ },
2684
+ });
2685
+ openedSocket = socket;
2686
+ if (this.#securityLifecycle === 'preflight') {
2687
+ socket.close();
2688
+ throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'realtime connected after the client entered security preflight');
2689
+ }
2690
+ if (generation !== this.#realtimeGeneration || !this.#started) {
2691
+ socket.close();
2692
+ throw new ClientSyncError('client.realtime_cancelled', 'realtime connection was cancelled before activation');
2693
+ }
2694
+ this.#socket = socket;
2695
+ this.#setRealtimeState('connected');
2612
2696
  }
2613
- if (generation !== this.#realtimeGeneration || !this.#started) {
2614
- socket.close();
2615
- throw new ClientSyncError('client.realtime_cancelled', 'realtime connection was cancelled before activation');
2697
+ catch (error) {
2698
+ // A superseded attempt leaves the successor's (or the deliberate
2699
+ // disconnect's) state alone.
2700
+ if (generation === this.#realtimeGeneration) {
2701
+ this.#setRealtimeState('refused', realtimeErrorCode(error));
2702
+ }
2703
+ throw error;
2616
2704
  }
2617
- this.#socket = socket;
2618
- this.#emitDiagnostics();
2619
2705
  }
2620
2706
  disconnectRealtime() {
2621
2707
  this.#realtimeGeneration += 1;
@@ -2624,7 +2710,11 @@ export class SyncClient {
2624
2710
  this.#socket = undefined;
2625
2711
  this.#presence.clear(); // §8.6.1: presence is per-connection
2626
2712
  this.#abortPendingRound('realtime socket disconnected mid-round (§8.7)');
2627
- this.#emitDiagnostics();
2713
+ this.#setRealtimeState(this.#realtimePolicy === 'off'
2714
+ ? 'disabled'
2715
+ : this.#config.realtime === undefined
2716
+ ? 'unsupported'
2717
+ : 'disconnected');
2628
2718
  }
2629
2719
  /**
2630
2720
  * §8.7 channel-tag routing (synchronous, so chunk order is preserved):
@@ -13,12 +13,23 @@ export declare const MAX_DIAGNOSTIC_QUERY_FAILURES = 256;
13
13
  export type ClientDiagnosticsHostKind = 'direct' | 'worker' | 'tauri' | 'react-native';
14
14
  export type ClientDiagnosticsHostRole = 'single' | 'leader' | 'follower' | 'unknown';
15
15
  export type ClientDiagnosticsConnectivity = 'online' | 'offline' | 'unknown';
16
- export type ClientDiagnosticsRealtime = 'connected' | 'disconnected' | 'unsupported' | 'unknown';
16
+ /**
17
+ * SPEC §8.8: how a client treats the realtime binding for sync rounds. The
18
+ * default is `optional`.
19
+ */
20
+ export type RealtimePolicy = 'required' | 'optional' | 'off';
21
+ export type RealtimeState = 'connected' | 'connecting' | 'disconnected' | 'lost' | 'refused' | 'unsupported' | 'disabled' | 'unknown';
17
22
  export interface ClientDiagnosticsHost {
18
23
  readonly kind: ClientDiagnosticsHostKind;
19
24
  readonly role: ClientDiagnosticsHostRole;
20
25
  readonly connectivity: ClientDiagnosticsConnectivity;
21
- readonly realtime: ClientDiagnosticsRealtime;
26
+ readonly realtime: RealtimeState;
27
+ /** Present on core-emitted snapshots; host wrappers may forward only `realtime`. */
28
+ readonly realtimePolicy?: RealtimePolicy;
29
+ /** Stable code behind a `lost` or `refused` state (SPEC §8.8). */
30
+ readonly realtimeReasonCode?: string;
31
+ /** Next retry delay in ms while the socket is not connected. */
32
+ readonly realtimeRetryDelayMs?: number;
22
33
  }
23
34
  export interface ExpectedDiagnosticSubscription {
24
35
  /** Application-owned stable identifier. It must never contain PHI. */
package/dist/errors.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { RealtimeState } from './diagnostics.js';
1
2
  /** A persistent local store is temporarily owned by another live engine. */
2
3
  export declare const STORAGE_BUSY_CODE = "client.storage_busy";
3
4
  /** The browser cannot provide the persistent storage APIs Syncular requires. */
@@ -6,17 +7,35 @@ export declare const STORAGE_UNAVAILABLE_CODE = "client.storage_unavailable";
6
7
  export declare const STORAGE_CORRUPT_CODE = "client.storage_corrupt";
7
8
  /** A local read hit SQLITE_IOERR (SPEC §7.5). */
8
9
  export declare const STORAGE_IO_CODE = "client.storage_io";
10
+ /** SPEC §8.8: a round under `realtimePolicy: 'required'` found no socket. */
11
+ export declare const REALTIME_UNAVAILABLE_CODE = "sync.realtime_unavailable";
12
+ /** SPEC §8.8: a connected socket ended without a deliberate disconnect. */
13
+ export declare const REALTIME_LOST_CODE = "client.realtime_lost";
9
14
  /**
10
15
  * Client-side errors. Protocol codes from the SPEC.md §10 catalog are surfaced
11
16
  * unchanged. Host/runtime-only conditions may use the separate `client.*`
12
17
  * namespace and never travel on the wire.
13
18
  */
14
19
  export declare class ClientSyncError extends Error {
15
- readonly name = "ClientSyncError";
20
+ readonly name: string;
16
21
  readonly code: string;
17
22
  readonly retryable: boolean;
18
23
  constructor(code: string, message: string, retryable?: boolean);
19
24
  }
25
+ /**
26
+ * SPEC §8.8: the `required` realtime policy refused a round because the
27
+ * socket is not connected. `state` is the availability state that refused
28
+ * (`connecting`, `lost`, `refused`, or `disconnected`), `reasonCode` is the
29
+ * stable code behind a `lost` or `refused` state, and `retryDelayMs` is the
30
+ * delay of the background retry intent the client scheduled with this throw.
31
+ */
32
+ export declare class RealtimeUnavailableError extends ClientSyncError {
33
+ readonly name = "RealtimeUnavailableError";
34
+ readonly state: RealtimeState;
35
+ readonly reasonCode: string | undefined;
36
+ readonly retryDelayMs: number;
37
+ constructor(state: RealtimeState, reasonCode: string | undefined, retryDelayMs: number);
38
+ }
20
39
  /**
21
40
  * SPEC §7.5: classify a local SQLite read failure by the numeric result code
22
41
  * the driver exposes (bun:sqlite `errno`, node:sqlite `errcode`, sqlite-wasm
package/dist/errors.js CHANGED
@@ -6,6 +6,10 @@ export const STORAGE_UNAVAILABLE_CODE = 'client.storage_unavailable';
6
6
  export const STORAGE_CORRUPT_CODE = 'client.storage_corrupt';
7
7
  /** A local read hit SQLITE_IOERR (SPEC §7.5). */
8
8
  export const STORAGE_IO_CODE = 'client.storage_io';
9
+ /** SPEC §8.8: a round under `realtimePolicy: 'required'` found no socket. */
10
+ export const REALTIME_UNAVAILABLE_CODE = 'sync.realtime_unavailable';
11
+ /** SPEC §8.8: a connected socket ended without a deliberate disconnect. */
12
+ export const REALTIME_LOST_CODE = 'client.realtime_lost';
9
13
  /**
10
14
  * Client-side errors. Protocol codes from the SPEC.md §10 catalog are surfaced
11
15
  * unchanged. Host/runtime-only conditions may use the separate `client.*`
@@ -21,6 +25,25 @@ export class ClientSyncError extends Error {
21
25
  this.retryable = retryable;
22
26
  }
23
27
  }
28
+ /**
29
+ * SPEC §8.8: the `required` realtime policy refused a round because the
30
+ * socket is not connected. `state` is the availability state that refused
31
+ * (`connecting`, `lost`, `refused`, or `disconnected`), `reasonCode` is the
32
+ * stable code behind a `lost` or `refused` state, and `retryDelayMs` is the
33
+ * delay of the background retry intent the client scheduled with this throw.
34
+ */
35
+ export class RealtimeUnavailableError extends ClientSyncError {
36
+ name = 'RealtimeUnavailableError';
37
+ state;
38
+ reasonCode;
39
+ retryDelayMs;
40
+ constructor(state, reasonCode, retryDelayMs) {
41
+ super(REALTIME_UNAVAILABLE_CODE, 'realtime is required and the realtime socket is not connected', true);
42
+ this.state = state;
43
+ this.reasonCode = reasonCode;
44
+ this.retryDelayMs = retryDelayMs;
45
+ }
46
+ }
24
47
  /**
25
48
  * SPEC §7.5: classify a local SQLite read failure by the numeric result code
26
49
  * the driver exposes (bun:sqlite `errno`, node:sqlite `errcode`, sqlite-wasm
package/dist/http.d.ts CHANGED
@@ -7,7 +7,12 @@
7
7
  import type { BlobTransport } from './blob.js';
8
8
  import type { RealtimeConnector, RemoteOperationTransport, RemoteOperationRealtimeConnector, SegmentDownloader, SyncTransport } from './transport.js';
9
9
  export interface HttpTransportOptions {
10
- readonly headers?: Readonly<Record<string, string>>;
10
+ /**
11
+ * Host auth headers. A function is read on every request, so a rotated
12
+ * token applies from the next request on. Signed-URL fetches never
13
+ * carry them (§5.4, §5.9.5).
14
+ */
15
+ readonly headers?: Readonly<Record<string, string>> | (() => Readonly<Record<string, string>>);
11
16
  readonly fetch?: typeof fetch;
12
17
  }
13
18
  /** POST `<mount>/sync` with SSP2 bodies (§1.1). */
package/dist/http.js CHANGED
@@ -18,6 +18,10 @@ async function throwHttpError(response) {
18
18
  }
19
19
  throw new ClientSyncError(code, message, retryable);
20
20
  }
21
+ function hostHeaders(options) {
22
+ const headers = options?.headers;
23
+ return typeof headers === 'function' ? headers() : headers;
24
+ }
21
25
  /** POST `<mount>/sync` with SSP2 bodies (§1.1). */
22
26
  export function httpSyncTransport(syncUrl, options) {
23
27
  const doFetch = options?.fetch ?? fetch;
@@ -26,7 +30,7 @@ export function httpSyncTransport(syncUrl, options) {
26
30
  method: 'POST',
27
31
  headers: {
28
32
  'Content-Type': SSP2_CONTENT_TYPE,
29
- ...options?.headers,
33
+ ...hostHeaders(options),
30
34
  },
31
35
  body: request.slice().buffer,
32
36
  });
@@ -43,7 +47,7 @@ export function httpRemoteOperationTransport(operationsUrl, options) {
43
47
  method: 'POST',
44
48
  headers: {
45
49
  'Content-Type': 'application/vnd.syncular.operations.v1+json',
46
- ...options?.headers,
50
+ ...hostHeaders(options),
47
51
  },
48
52
  body: request.slice().buffer,
49
53
  });
@@ -136,7 +140,7 @@ export function httpSegmentDownloader(segmentsBaseUrl, options) {
136
140
  const response = await doFetch(`${segmentsBaseUrl}/${encodeURIComponent(request.segmentId)}`, {
137
141
  headers: {
138
142
  'X-Syncular-Scopes': request.requestedScopesJson,
139
- ...options?.headers,
143
+ ...hostHeaders(options),
140
144
  },
141
145
  });
142
146
  if (!response.ok)
@@ -168,7 +172,7 @@ export function httpBlobTransport(blobsBaseUrl, options) {
168
172
  method: 'PUT',
169
173
  headers: {
170
174
  'Content-Type': mediaType ?? 'application/octet-stream',
171
- ...options?.headers,
175
+ ...hostHeaders(options),
172
176
  },
173
177
  body: bytes.slice().buffer,
174
178
  });
@@ -177,7 +181,7 @@ export function httpBlobTransport(blobsBaseUrl, options) {
177
181
  },
178
182
  download: async (blobId) => {
179
183
  const response = await doFetch(blobUrl(blobId), {
180
- headers: { ...options?.headers },
184
+ headers: { ...hostHeaders(options) },
181
185
  });
182
186
  if (!response.ok)
183
187
  await throwHttpError(response);
@@ -215,7 +219,7 @@ export function httpBlobTransport(blobsBaseUrl, options) {
215
219
  method: 'POST',
216
220
  headers: {
217
221
  'Content-Type': 'application/json',
218
- ...options?.headers,
222
+ ...hostHeaders(options),
219
223
  },
220
224
  body: JSON.stringify({
221
225
  byteLength,
@@ -28,14 +28,6 @@ interface HelloMessage {
28
28
  * advance even if it never saw the previous leader's announce). */
29
29
  readonly epoch?: number;
30
30
  }
31
- interface ByeMessage {
32
- readonly t: 'bye';
33
- readonly fromId: string;
34
- }
35
- interface BoundMessage {
36
- readonly t: 'bound';
37
- readonly fromId: string;
38
- }
39
31
  interface ReqMessage {
40
32
  readonly t: 'req';
41
33
  readonly epoch: number;
@@ -66,7 +58,7 @@ interface EventMessage {
66
58
  readonly epoch: number;
67
59
  readonly event: SyncWorkerEvent;
68
60
  }
69
- export type MultiTabMessage = HelloMessage | ByeMessage | BoundMessage | ReqMessage | AnnounceMessage | ResMessage | EventMessage;
61
+ export type MultiTabMessage = HelloMessage | ReqMessage | AnnounceMessage | ResMessage | EventMessage;
70
62
  /**
71
63
  * The tiny cross-tab channel surface we depend on — the DOM `BroadcastChannel`
72
64
  * satisfies it. Injectable so bun tests can pair two instances by name.
@@ -100,20 +92,26 @@ export declare class LeaderBridge {
100
92
  epoch: number;
101
93
  clientId: string;
102
94
  invoke: (method: string, args: readonly unknown[]) => Promise<unknown>;
103
- heartbeatMs?: number;
104
95
  });
105
- /** Re-announce leadership (on promotion and on a follower `hello`). */
96
+ /** Announce leadership (on promotion and in answer to every `hello`). */
106
97
  announce(): void;
107
98
  /** Fan a worker event out to all followers on the current epoch. */
108
99
  broadcastEvent(event: SyncWorkerEvent): void;
109
100
  close(): void;
110
101
  }
102
+ /**
103
+ * Timer seam for {@link FollowerLink}: run `callback` after `delayMs` and
104
+ * return a function that cancels it. Tests inject a manual clock.
105
+ */
106
+ type FollowerSchedule = (callback: () => void, delayMs: number) => () => void;
111
107
  /**
112
108
  * Runs on a follower tab. Sends `req`s to the leader and settles them on the
113
109
  * matching `res`; queues calls (bounded, timed) while no leader is bound (the
114
110
  * handover gap) and flushes them once an `announce` binds a new leader.
115
111
  * Feeds fanned-out events to `onEvent`. Learns leadership changes and hands
116
- * the resolved leader `clientId` back through `onLeaderChange`.
112
+ * the resolved leader `clientId` back through `onLeaderChange`. Probes the
113
+ * bound leader with `hello` after a quiet period and reports `blocked` only
114
+ * when a probe goes unanswered (see the module comment).
117
115
  */
118
116
  export declare class FollowerLink {
119
117
  #private;
@@ -125,6 +123,7 @@ export declare class FollowerLink {
125
123
  onStateChange?: (state: LeadershipState) => void;
126
124
  callTimeoutMs?: number;
127
125
  queueLimit?: number;
126
+ schedule?: FollowerSchedule;
128
127
  });
129
128
  get epoch(): number;
130
129
  get maxEpochSeen(): number;