@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/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,
@@ -240,6 +240,27 @@ class ObservationCache {
240
240
  this.#active.clear();
241
241
  }
242
242
  }
243
+ const EMPTY_ROWS = Object.freeze([]);
244
+ const unreadQuerySnapshots = new Map();
245
+ /** One frozen snapshot per phase and availability for entries that have no
246
+ * successful read, so switching between unread entries keeps the snapshot and
247
+ * `rows` identity. */
248
+ function unreadQuerySnapshot(phase, availability) {
249
+ const key = canonicalValue([phase, availability]);
250
+ let snapshot = unreadQuerySnapshots.get(key);
251
+ if (snapshot === undefined) {
252
+ snapshot = Object.freeze({
253
+ rows: EMPTY_ROWS,
254
+ phase,
255
+ revision: undefined,
256
+ error: undefined,
257
+ isRefreshing: false,
258
+ availability,
259
+ });
260
+ unreadQuerySnapshots.set(key, snapshot);
261
+ }
262
+ return snapshot;
263
+ }
243
264
  class QueryEntry {
244
265
  store;
245
266
  spec;
@@ -247,14 +268,9 @@ class QueryEntry {
247
268
  cache;
248
269
  #owner = Symbol('query-window-claim');
249
270
  #listeners = new Set();
250
- #state = {
251
- rows: [],
252
- phase: 'loading',
253
- revision: undefined,
254
- error: undefined,
255
- isRefreshing: false,
256
- availability: { state: 'ready' },
257
- };
271
+ #state = unreadQuerySnapshot('loading', {
272
+ state: 'ready',
273
+ });
258
274
  #delegate;
259
275
  #generation = 0;
260
276
  #scheduled = false;
@@ -351,14 +367,7 @@ class QueryEntry {
351
367
  this.#desiredRevision = 0n;
352
368
  this.#claimError = undefined;
353
369
  this.#claimPending = false;
354
- this.#state = {
355
- rows: [],
356
- phase: 'loading',
357
- revision: undefined,
358
- error: undefined,
359
- isRefreshing: false,
360
- availability: { state: 'ready' },
361
- };
370
+ this.#state = unreadQuerySnapshot('loading', { state: 'ready' });
362
371
  }
363
372
  dispose() {
364
373
  this.#listeners.clear();
@@ -385,7 +394,13 @@ class QueryEntry {
385
394
  canonicalValue(this.#state.availability)) {
386
395
  return;
387
396
  }
388
- this.#state = next;
397
+ this.#state =
398
+ next.revision === undefined &&
399
+ next.rows.length === 0 &&
400
+ next.error === undefined &&
401
+ !next.isRefreshing
402
+ ? unreadQuerySnapshot(next.phase, next.availability)
403
+ : next;
389
404
  for (const listener of this.#listeners)
390
405
  listener();
391
406
  }
@@ -384,7 +384,8 @@ export class RealtimeSupervisor {
384
384
  return;
385
385
  this.#diagnosticConnectivity = snapshot.host.connectivity;
386
386
  this.#diagnosticSecurity = snapshot.securityLifecycle;
387
- if (snapshot.host.realtime === 'unsupported') {
387
+ if (snapshot.host.realtime === 'unsupported' ||
388
+ snapshot.host.realtime === 'disabled') {
388
389
  const disconnect = this.#connected || this.#transportConnected || this.#connecting;
389
390
  this.#realtimeSupported = false;
390
391
  this.#generation += 1;
@@ -420,7 +421,16 @@ export class RealtimeSupervisor {
420
421
  void this.#adoptConnectedTransport();
421
422
  return;
422
423
  }
423
- if (snapshot.host.realtime === 'disconnected') {
424
+ if (snapshot.host.realtime === 'connecting') {
425
+ // An attempt is already in flight; the supervisor's own #connect (or
426
+ // the host's direct call) owns the outcome.
427
+ this.#transportConnected = false;
428
+ this.#connected = false;
429
+ return;
430
+ }
431
+ if (snapshot.host.realtime === 'disconnected' ||
432
+ snapshot.host.realtime === 'lost' ||
433
+ snapshot.host.realtime === 'refused') {
424
434
  this.#transportConnected = false;
425
435
  this.#connected = false;
426
436
  if (this.#connecting)
@@ -8,7 +8,11 @@ export interface SyncWorkerOverrides {
8
8
  readonly waitForStorageRetry?: (delayMs: number) => Promise<void>;
9
9
  readonly createTransport?: (config: WorkerInitConfig) => SyncTransport;
10
10
  readonly createSegments?: (config: WorkerInitConfig) => SegmentDownloader | undefined;
11
- readonly createRealtime?: (config: WorkerInitConfig, clientId: string) => RealtimeConnector | undefined;
11
+ /**
12
+ * Custom realtime connector, e.g. one that mints a fresh ticket per
13
+ * connection attempt. `headers` reads the current host auth set.
14
+ */
15
+ readonly createRealtime?: (config: WorkerInitConfig, clientId: string, headers: () => Readonly<Record<string, string>>) => RealtimeConnector | undefined;
12
16
  }
13
17
  /**
14
18
  * Register the RPC server on the current worker scope. Call this once