@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.
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
package/src/http.ts CHANGED
@@ -35,10 +35,24 @@ async function throwHttpError(response: Response): Promise<never> {
35
35
  }
36
36
 
37
37
  export interface HttpTransportOptions {
38
- readonly headers?: Readonly<Record<string, string>>;
38
+ /**
39
+ * Host auth headers. A function is read on every request, so a rotated
40
+ * token applies from the next request on. Signed-URL fetches never
41
+ * carry them (§5.4, §5.9.5).
42
+ */
43
+ readonly headers?:
44
+ | Readonly<Record<string, string>>
45
+ | (() => Readonly<Record<string, string>>);
39
46
  readonly fetch?: typeof fetch;
40
47
  }
41
48
 
49
+ function hostHeaders(
50
+ options: HttpTransportOptions | undefined,
51
+ ): Readonly<Record<string, string>> | undefined {
52
+ const headers = options?.headers;
53
+ return typeof headers === 'function' ? headers() : headers;
54
+ }
55
+
42
56
  /** POST `<mount>/sync` with SSP2 bodies (§1.1). */
43
57
  export function httpSyncTransport(
44
58
  syncUrl: string,
@@ -50,7 +64,7 @@ export function httpSyncTransport(
50
64
  method: 'POST',
51
65
  headers: {
52
66
  'Content-Type': SSP2_CONTENT_TYPE,
53
- ...options?.headers,
67
+ ...hostHeaders(options),
54
68
  },
55
69
  body: request.slice().buffer as ArrayBuffer,
56
70
  });
@@ -70,7 +84,7 @@ export function httpRemoteOperationTransport(
70
84
  method: 'POST',
71
85
  headers: {
72
86
  'Content-Type': 'application/vnd.syncular.operations.v1+json',
73
- ...options?.headers,
87
+ ...hostHeaders(options),
74
88
  },
75
89
  body: request.slice().buffer as ArrayBuffer,
76
90
  });
@@ -188,7 +202,7 @@ export function httpSegmentDownloader(
188
202
  {
189
203
  headers: {
190
204
  'X-Syncular-Scopes': request.requestedScopesJson,
191
- ...options?.headers,
205
+ ...hostHeaders(options),
192
206
  },
193
207
  },
194
208
  );
@@ -232,7 +246,7 @@ export function httpBlobTransport(
232
246
  method: 'PUT',
233
247
  headers: {
234
248
  'Content-Type': mediaType ?? 'application/octet-stream',
235
- ...options?.headers,
249
+ ...hostHeaders(options),
236
250
  },
237
251
  body: bytes.slice().buffer as ArrayBuffer,
238
252
  });
@@ -240,7 +254,7 @@ export function httpBlobTransport(
240
254
  },
241
255
  download: async (blobId) => {
242
256
  const response = await doFetch(blobUrl(blobId), {
243
- headers: { ...options?.headers },
257
+ headers: { ...hostHeaders(options) },
244
258
  });
245
259
  if (!response.ok) await throwHttpError(response);
246
260
  // §5.9.5 always-issue: a JSON body with `url` means presigned delivery;
@@ -284,7 +298,7 @@ export function httpBlobTransport(
284
298
  method: 'POST',
285
299
  headers: {
286
300
  'Content-Type': 'application/json',
287
- ...options?.headers,
301
+ ...hostHeaders(options),
288
302
  },
289
303
  body: JSON.stringify({
290
304
  byteLength,
@@ -360,17 +360,38 @@ class ObservationCache {
360
360
  }
361
361
  }
362
362
 
363
+ const EMPTY_ROWS: readonly never[] = Object.freeze([]);
364
+ const unreadQuerySnapshots = new Map<string, LiveQueryResult<never>>();
365
+
366
+ /** One frozen snapshot per phase and availability for entries that have no
367
+ * successful read, so switching between unread entries keeps the snapshot and
368
+ * `rows` identity. */
369
+ function unreadQuerySnapshot(
370
+ phase: LiveQueryPhase,
371
+ availability: SyncAvailability,
372
+ ): LiveQueryResult<never> {
373
+ const key = canonicalValue([phase, availability]);
374
+ let snapshot = unreadQuerySnapshots.get(key);
375
+ if (snapshot === undefined) {
376
+ snapshot = Object.freeze({
377
+ rows: EMPTY_ROWS,
378
+ phase,
379
+ revision: undefined,
380
+ error: undefined,
381
+ isRefreshing: false,
382
+ availability,
383
+ });
384
+ unreadQuerySnapshots.set(key, snapshot);
385
+ }
386
+ return snapshot;
387
+ }
388
+
363
389
  class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
364
390
  readonly #owner = Symbol('query-window-claim');
365
391
  readonly #listeners = new Set<() => void>();
366
- #state: LiveQueryResult<Row> = {
367
- rows: [],
368
- phase: 'loading',
369
- revision: undefined,
370
- error: undefined,
371
- isRefreshing: false,
372
- availability: { state: 'ready' },
373
- };
392
+ #state: LiveQueryResult<Row> = unreadQuerySnapshot('loading', {
393
+ state: 'ready',
394
+ });
374
395
  #delegate: QueryEntry<Row> | undefined;
375
396
  #generation = 0;
376
397
  #scheduled = false;
@@ -476,14 +497,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
476
497
  this.#desiredRevision = 0n;
477
498
  this.#claimError = undefined;
478
499
  this.#claimPending = false;
479
- this.#state = {
480
- rows: [],
481
- phase: 'loading',
482
- revision: undefined,
483
- error: undefined,
484
- isRefreshing: false,
485
- availability: { state: 'ready' },
486
- };
500
+ this.#state = unreadQuerySnapshot('loading', { state: 'ready' });
487
501
  }
488
502
 
489
503
  dispose(): void {
@@ -513,7 +527,13 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
513
527
  ) {
514
528
  return;
515
529
  }
516
- this.#state = next;
530
+ this.#state =
531
+ next.revision === undefined &&
532
+ next.rows.length === 0 &&
533
+ next.error === undefined &&
534
+ !next.isRefreshing
535
+ ? unreadQuerySnapshot(next.phase, next.availability)
536
+ : next;
517
537
  for (const listener of this.#listeners) listener();
518
538
  }
519
539
 
@@ -529,7 +529,10 @@ export class RealtimeSupervisor {
529
529
  if (this.#stopped) return;
530
530
  this.#diagnosticConnectivity = snapshot.host.connectivity;
531
531
  this.#diagnosticSecurity = snapshot.securityLifecycle;
532
- if (snapshot.host.realtime === 'unsupported') {
532
+ if (
533
+ snapshot.host.realtime === 'unsupported' ||
534
+ snapshot.host.realtime === 'disabled'
535
+ ) {
533
536
  const disconnect =
534
537
  this.#connected || this.#transportConnected || this.#connecting;
535
538
  this.#realtimeSupported = false;
@@ -563,7 +566,18 @@ export class RealtimeSupervisor {
563
566
  void this.#adoptConnectedTransport();
564
567
  return;
565
568
  }
566
- if (snapshot.host.realtime === 'disconnected') {
569
+ if (snapshot.host.realtime === 'connecting') {
570
+ // An attempt is already in flight; the supervisor's own #connect (or
571
+ // the host's direct call) owns the outcome.
572
+ this.#transportConnected = false;
573
+ this.#connected = false;
574
+ return;
575
+ }
576
+ if (
577
+ snapshot.host.realtime === 'disconnected' ||
578
+ snapshot.host.realtime === 'lost' ||
579
+ snapshot.host.realtime === 'refused'
580
+ ) {
567
581
  this.#transportConnected = false;
568
582
  this.#connected = false;
569
583
  if (this.#connecting) return;
@@ -57,9 +57,14 @@ export interface SyncWorkerOverrides {
57
57
  readonly createSegments?: (
58
58
  config: WorkerInitConfig,
59
59
  ) => SegmentDownloader | undefined;
60
+ /**
61
+ * Custom realtime connector, e.g. one that mints a fresh ticket per
62
+ * connection attempt. `headers` reads the current host auth set.
63
+ */
60
64
  readonly createRealtime?: (
61
65
  config: WorkerInitConfig,
62
66
  clientId: string,
67
+ headers: () => Readonly<Record<string, string>>,
63
68
  ) => RealtimeConnector | undefined;
64
69
  }
65
70
 
@@ -129,6 +134,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
129
134
  let client: SyncClient | undefined;
130
135
  let database: ClientDatabase | undefined;
131
136
  let offline = false;
137
+ let headers: Readonly<Record<string, string>> = {};
132
138
  let closed = false;
133
139
 
134
140
  // -- one sync loop: RPC-driven and auto-driven rounds serialize ----------
@@ -266,6 +272,8 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
266
272
  );
267
273
  }
268
274
  autoSync = config.autoSync ?? true;
275
+ headers = config.headers ?? {};
276
+ const http = { headers: () => headers };
269
277
 
270
278
  for (let attempt = 0; ; attempt++) {
271
279
  try {
@@ -297,18 +305,18 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
297
305
  const transport = gateOffline(
298
306
  overrides.createTransport !== undefined
299
307
  ? overrides.createTransport(config)
300
- : httpSyncTransport(config.endpoints.syncUrl),
308
+ : httpSyncTransport(config.endpoints.syncUrl, http),
301
309
  );
302
310
  const segments =
303
311
  overrides.createSegments !== undefined
304
312
  ? overrides.createSegments(config)
305
313
  : config.endpoints.segmentsUrl !== undefined
306
- ? httpSegmentDownloader(config.endpoints.segmentsUrl)
314
+ ? httpSegmentDownloader(config.endpoints.segmentsUrl, http)
307
315
  : undefined;
308
316
  // §5.9 blob transport: only when a blobs URL is configured.
309
317
  const blobs =
310
318
  config.endpoints.blobsUrl !== undefined
311
- ? httpBlobTransport(config.endpoints.blobsUrl)
319
+ ? httpBlobTransport(config.endpoints.blobsUrl, http)
312
320
  : undefined;
313
321
 
314
322
  // Realtime needs the (possibly persisted) clientId for the
@@ -341,6 +349,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
341
349
  },
342
350
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
343
351
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
352
+ ...(config.realtimePolicy !== undefined
353
+ ? { realtimePolicy: config.realtimePolicy }
354
+ : {}),
344
355
  ...(config.encryption !== undefined
345
356
  ? { encryption: encryptionConfigFromKeyring(config.encryption) }
346
357
  : {}),
@@ -392,7 +403,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
392
403
  await started.start();
393
404
  realtimeConnector =
394
405
  overrides.createRealtime !== undefined
395
- ? overrides.createRealtime(config, started.clientId)
406
+ ? overrides.createRealtime(config, started.clientId, http.headers)
396
407
  : config.endpoints.realtimeUrl !== undefined
397
408
  ? webSocketRealtimeConnector(
398
409
  config.endpoints.realtimeUrl.replace(
@@ -490,6 +501,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
490
501
  presence: (scopeKey) => requireClient().presence(scopeKey),
491
502
  uploadBlob: (bytes, options) => requireClient().uploadBlob(bytes, options),
492
503
  fetchBlob: (blobIdOrRef) => requireClient().fetchBlob(blobIdOrRef),
504
+ setHeaders: (next) => {
505
+ headers = next;
506
+ },
493
507
  setOffline: (value) => {
494
508
  offline = value;
495
509
  if (offline) client?.disconnectRealtime();
@@ -50,6 +50,7 @@ import {
50
50
  type ClientDiagnosticsListener,
51
51
  type ClientDiagnosticsRequest,
52
52
  type ClientDiagnosticsSnapshot,
53
+ type RealtimePolicy,
53
54
  withClientDiagnosticsHost,
54
55
  } from './diagnostics';
55
56
  import type { EncryptionKeyringConfig } from './encryption';
@@ -180,12 +181,23 @@ export interface SyncClientHandleConfig {
180
181
  readonly schema: ClientSchema;
181
182
  readonly database: WorkerDatabaseInit;
182
183
  readonly endpoints: WorkerEndpoints;
184
+ /**
185
+ * Host auth headers for sync, segment, and blob requests (e.g.
186
+ * `Authorization`). Rotate with `setHeaders`. Realtime authenticates
187
+ * through `realtimeUrl`, because WebSockets cannot carry headers.
188
+ */
189
+ readonly headers?: Readonly<Record<string, string>>;
183
190
  /** Structured-clone-safe E2EE keyring installed only in the leader worker. */
184
191
  readonly encryption?: EncryptionKeyringConfig;
185
192
  /** Open the worker-owned replica behind the fail-closed security gate. */
186
193
  readonly securityPreflight?: boolean;
187
194
  readonly clientId?: string;
188
195
  readonly limits?: SyncClientLimits;
196
+ /**
197
+ * SPEC §8.8 realtime policy for the worker-owned core, forwarded into
198
+ * `WorkerInitConfig`. Absent ⇒ `optional`.
199
+ */
200
+ readonly realtimePolicy?: RealtimePolicy;
189
201
  /**
190
202
  * RFC 0005 previous-version context, forwarded into the worker's
191
203
  * `SyncClientConfig`. Absent ⇒ the feature stays off (default-off).
@@ -689,6 +701,19 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
689
701
  return this.#call('fetchBlob', [blobIdOrRef]);
690
702
  }
691
703
 
704
+ /**
705
+ * Replace the full host auth header set. The next HTTP request uses it;
706
+ * a live realtime socket keeps its handshake credentials. A follower
707
+ * forwards to the leader and keeps the set for its own promotion.
708
+ */
709
+ setHeaders(headers: Readonly<Record<string, string>>): Promise<void> {
710
+ this.__headers = headers;
711
+ return this.#call('setHeaders', [headers]);
712
+ }
713
+
714
+ /** @internal — the latest `setHeaders` set, carried into a promotion. */
715
+ __headers: Readonly<Record<string, string>> | undefined;
716
+
692
717
  /** Sever/restore transport + realtime inside the worker (demos). */
693
718
  setOffline(offline: boolean): Promise<void> {
694
719
  return this.#call('setOffline', [offline]);
@@ -839,11 +864,15 @@ async function startWorkerCore(options: {
839
864
  }
840
865
  }
841
866
 
842
- function buildInitConfig(config: SyncClientHandleConfig): WorkerInitConfig {
867
+ function buildInitConfig(
868
+ config: SyncClientHandleConfig,
869
+ headers = config.headers,
870
+ ): WorkerInitConfig {
843
871
  return {
844
872
  schema: config.schema,
845
873
  database: config.database,
846
874
  endpoints: config.endpoints,
875
+ ...(headers !== undefined ? { headers } : {}),
847
876
  ...(config.encryption !== undefined
848
877
  ? { encryption: config.encryption }
849
878
  : {}),
@@ -852,6 +881,9 @@ function buildInitConfig(config: SyncClientHandleConfig): WorkerInitConfig {
852
881
  : {}),
853
882
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
854
883
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
884
+ ...(config.realtimePolicy !== undefined
885
+ ? { realtimePolicy: config.realtimePolicy }
886
+ : {}),
855
887
  ...(config.previousVersionContext !== undefined
856
888
  ? { previousVersionContext: config.previousVersionContext }
857
889
  : {}),
@@ -1086,7 +1118,7 @@ async function bootFollower(
1086
1118
  try {
1087
1119
  const core = await startWorkerCore({
1088
1120
  config,
1089
- initConfig: buildInitConfig(config),
1121
+ initConfig: buildInitConfig(config, handle.__headers ?? config.headers),
1090
1122
  lease,
1091
1123
  dispatchEvent: (event) => {
1092
1124
  fireConfigCallbacks(config, event);
@@ -34,7 +34,7 @@ import type {
34
34
  WindowState,
35
35
  } from './client';
36
36
  import type { SqlRow, SqlValue } from './database';
37
- import type { ClientDiagnosticsSnapshot } from './diagnostics';
37
+ import type { ClientDiagnosticsSnapshot, RealtimePolicy } from './diagnostics';
38
38
  import type { EncryptionKeyringConfig } from './encryption';
39
39
  import type { ClientChangeBatch, LocalRevision } from './invalidation';
40
40
  import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge';
@@ -102,12 +102,23 @@ export interface WorkerInitConfig {
102
102
  readonly schema: ClientSchema;
103
103
  readonly database: WorkerDatabaseInit;
104
104
  readonly endpoints: WorkerEndpoints;
105
+ /**
106
+ * Host auth headers for sync, segment, and blob requests. `setHeaders`
107
+ * replaces the whole set at runtime. WebSockets cannot carry headers, so
108
+ * realtime authenticates through `realtimeUrl` (cookie or ticket).
109
+ */
110
+ readonly headers?: Readonly<Record<string, string>>;
105
111
  /** Portable raw keyring installed inside the worker-owned client core. */
106
112
  readonly encryption?: EncryptionKeyringConfig;
107
113
  /** Open the worker-owned replica behind the fail-closed security gate. */
108
114
  readonly securityPreflight?: boolean;
109
115
  readonly clientId?: string;
110
116
  readonly limits?: SyncClientLimits;
117
+ /**
118
+ * SPEC §8.8 realtime policy for the worker-owned core; forwarded verbatim.
119
+ * Absent ⇒ `optional` (a connected socket, otherwise `POST /sync`).
120
+ */
121
+ readonly realtimePolicy?: RealtimePolicy;
111
122
  /**
112
123
  * RFC 0005 previous-version context, forwarded verbatim into the worker's
113
124
  * `SyncClientConfig`. Absent ⇒ the feature stays off (default-off).
@@ -190,6 +201,8 @@ export interface WorkerApi extends Omit<
190
201
  options?: { readonly mediaType?: string; readonly name?: string },
191
202
  ): Promise<BlobRef>;
192
203
  fetchBlob(blobIdOrRef: string): Promise<CachedBlob>;
204
+ /** Replace the host auth header set; the next HTTP request uses it. */
205
+ setHeaders(headers: Readonly<Record<string, string>>): void;
193
206
  /** Sever/restore the transport + realtime (offline simulation, demos). */
194
207
  setOffline(offline: boolean): void;
195
208
  close(): Promise<void>;