@electric-sql/client 1.1.2 → 1.1.4

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
@@ -99,26 +99,84 @@ shape.subscribe(({ rows }) => {
99
99
 
100
100
  ### Error Handling
101
101
 
102
- The ShapeStream provides two ways to handle errors:
102
+ The ShapeStream provides robust error handling with automatic retry support:
103
103
 
104
- 1. Using the `onError` handler:
104
+ #### 1. Stream-level error handler with retry control
105
+
106
+ The `onError` handler gives you full control over error recovery:
105
107
 
106
108
  ```typescript
107
109
  const stream = new ShapeStream({
108
110
  url: `${BASE_URL}/v1/shape`,
109
- params: {
110
- table: `foo`,
111
- },
111
+ params: { table: `foo` },
112
112
  onError: (error) => {
113
- // Handle all stream errors here
114
113
  console.error('Stream error:', error)
114
+
115
+ // IMPORTANT: Return an object to keep syncing!
116
+ // Return void/undefined to stop syncing permanently.
117
+
118
+ // Note: 5xx errors and network errors are automatically retried,
119
+ // so onError is mainly for handling client errors (4xx)
120
+
121
+ if (error instanceof FetchError) {
122
+ if (error.status === 401) {
123
+ // Unauthorized - refresh token and retry
124
+ const newToken = getRefreshedToken()
125
+ return {
126
+ headers: {
127
+ Authorization: `Bearer ${newToken}`,
128
+ },
129
+ }
130
+ }
131
+
132
+ if (error.status === 403) {
133
+ // Forbidden - maybe change user context
134
+ return {
135
+ params: {
136
+ table: `foo`,
137
+ where: `user_id = $1`,
138
+ params: [fallbackUserId],
139
+ },
140
+ }
141
+ }
142
+ }
143
+
144
+ // Stop syncing for other errors (return void)
115
145
  },
116
146
  })
117
147
  ```
118
148
 
119
- If no `onError` handler is provided, the ShapeStream will throw errors that occur during streaming.
149
+ **Critical**: The `onError` callback's return value controls whether syncing continues:
120
150
 
121
- 2. Individual subscribers can optionally handle errors specific to their subscription:
151
+ - **Return an object** (even empty `{}`) to retry syncing:
152
+ - `{}` - Retry with same params and headers
153
+ - `{ params }` - Retry with modified params
154
+ - `{ headers }` - Retry with modified headers
155
+ - `{ params, headers }` - Retry with both modified
156
+ - **Return void/undefined** to stop the stream permanently
157
+
158
+ The handler supports async operations:
159
+
160
+ ```typescript
161
+ onError: async (error) => {
162
+ if (error instanceof FetchError && error.status === 401) {
163
+ // Perform async token refresh
164
+ const newToken = await refreshAuthToken()
165
+ return {
166
+ headers: { Authorization: `Bearer ${newToken}` },
167
+ }
168
+ }
169
+ return {} // Retry other errors
170
+ }
171
+ ```
172
+
173
+ **Automatic retries**: The client automatically retries 5xx server errors, network errors, and 429 rate limits with exponential backoff. The `onError` callback is only invoked after these retries are exhausted, or for non-retryable errors like 4xx client errors.
174
+
175
+ **Without `onError`**: If no `onError` handler is provided, non-retryable errors (like 4xx client errors) will be thrown and the stream will stop.
176
+
177
+ #### 2. Subscription-level error callbacks
178
+
179
+ Individual subscribers can handle errors specific to their subscription:
122
180
 
123
181
  ```typescript
124
182
  stream.subscribe(
@@ -132,7 +190,11 @@ stream.subscribe(
132
190
  )
133
191
  ```
134
192
 
135
- Common error types include:
193
+ Note: Subscription error callbacks cannot control retry behavior - use the stream-level `onError` for that.
194
+
195
+ #### Common Error Types
196
+
197
+ Setup errors:
136
198
 
137
199
  - `MissingShapeUrlError`: Missing required URL parameter
138
200
  - `InvalidSignalError`: Invalid AbortSignal instance
@@ -140,12 +202,12 @@ Common error types include:
140
202
 
141
203
  Runtime errors:
142
204
 
143
- - `FetchError`: HTTP errors during shape fetching
205
+ - `FetchError`: HTTP errors during shape fetching (includes `status`, `url`, `headers`)
144
206
  - `FetchBackoffAbortError`: Fetch aborted using AbortSignal
145
207
  - `MissingShapeHandleError`: Missing required shape handle
146
- - `ParserNullValueError`: Parser encountered NULL value in a column that doesn't allow NULL values
208
+ - `ParserNullValueError`: NULL value in a non-nullable column
147
209
 
148
- See the [typescript client docs on the website](https://electric-sql.com/docs/api/clients/typescript#error-handling) for more details on error handling.
210
+ See the [TypeScript client docs](https://electric-sql.com/docs/api/clients/typescript#error-handling) for more details.
149
211
 
150
212
  And in general, see the [docs website](https://electric-sql.com) and [examples](https://electric-sql.com/demos) for more information.
151
213
 
@@ -353,6 +353,7 @@ var SUBSET_PARAM_ORDER_BY = `subset__order_by`;
353
353
  var SUBSET_PARAM_WHERE_PARAMS = `subset__params`;
354
354
  var ELECTRIC_PROTOCOL_QUERY_PARAMS = [
355
355
  LIVE_QUERY_PARAM,
356
+ LIVE_SSE_QUERY_PARAM,
356
357
  SHAPE_HANDLE_QUERY_PARAM,
357
358
  OFFSET_QUERY_PARAM,
358
359
  LIVE_CACHE_BUSTER_QUERY_PARAM,
@@ -369,16 +370,33 @@ var ELECTRIC_PROTOCOL_QUERY_PARAMS = [
369
370
  var HTTP_RETRY_STATUS_CODES = [429];
370
371
  var BackoffDefaults = {
371
372
  initialDelay: 100,
372
- maxDelay: 1e4,
373
- multiplier: 1.3
373
+ maxDelay: 6e4,
374
+ // Cap at 60s - reasonable for long-lived connections
375
+ multiplier: 1.3,
376
+ maxRetries: Infinity
377
+ // Retry forever - clients may go offline and come back
374
378
  };
379
+ function parseRetryAfterHeader(retryAfter) {
380
+ if (!retryAfter) return 0;
381
+ const retryAfterSec = Number(retryAfter);
382
+ if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
383
+ return retryAfterSec * 1e3;
384
+ }
385
+ const retryDate = Date.parse(retryAfter);
386
+ if (!isNaN(retryDate)) {
387
+ const deltaMs = retryDate - Date.now();
388
+ return Math.max(0, Math.min(deltaMs, 36e5));
389
+ }
390
+ return 0;
391
+ }
375
392
  function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
376
393
  const {
377
394
  initialDelay,
378
395
  maxDelay,
379
396
  multiplier,
380
397
  debug = false,
381
- onFailedAttempt
398
+ onFailedAttempt,
399
+ maxRetries = Infinity
382
400
  } = backoffOptions;
383
401
  return (...args) => __async(this, null, function* () {
384
402
  var _a;
@@ -389,7 +407,9 @@ function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
389
407
  while (true) {
390
408
  try {
391
409
  const result = yield fetchClient(...args);
392
- if (result.ok) return result;
410
+ if (result.ok) {
411
+ return result;
412
+ }
393
413
  const err = yield FetchError.fromResponse(result, url.toString());
394
414
  throw err;
395
415
  } catch (e) {
@@ -399,12 +419,27 @@ function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
399
419
  } else if (e instanceof FetchError && !HTTP_RETRY_STATUS_CODES.includes(e.status) && e.status >= 400 && e.status < 500) {
400
420
  throw e;
401
421
  } else {
402
- yield new Promise((resolve) => setTimeout(resolve, delay));
403
- delay = Math.min(delay * multiplier, maxDelay);
422
+ attempt++;
423
+ if (attempt > maxRetries) {
424
+ if (debug) {
425
+ console.log(
426
+ `Max retries reached (${attempt}/${maxRetries}), giving up`
427
+ );
428
+ }
429
+ throw e;
430
+ }
431
+ const serverMinimumMs = e instanceof FetchError && e.headers ? parseRetryAfterHeader(e.headers[`retry-after`]) : 0;
432
+ const jitter = Math.random() * delay;
433
+ const clientBackoffMs = Math.min(jitter, maxDelay);
434
+ const waitMs = Math.max(serverMinimumMs, clientBackoffMs);
404
435
  if (debug) {
405
- attempt++;
406
- console.log(`Retry attempt #${attempt} after ${delay}ms`);
436
+ const source = serverMinimumMs > 0 ? `server+client` : `client`;
437
+ console.log(
438
+ `Retry attempt #${attempt} after ${waitMs}ms (${source}, serverMin=${serverMinimumMs}ms, clientBackoff=${clientBackoffMs}ms)`
439
+ );
407
440
  }
441
+ yield new Promise((resolve) => setTimeout(resolve, waitMs));
442
+ delay = Math.min(delay * multiplier, maxDelay);
408
443
  }
409
444
  }
410
445
  }
@@ -775,9 +810,8 @@ function canonicalShapeKey(url) {
775
810
  cleanUrl.searchParams.sort();
776
811
  return cleanUrl.toString();
777
812
  }
778
- var _error, _fetchClient2, _sseFetchClient, _messageParser, _subscribers, _started, _state, _lastOffset, _liveCacheBuster, _lastSyncedAt, _isUpToDate, _isMidStream, _connected, _shapeHandle, _mode, _schema, _onError, _requestAbortController, _isRefreshing, _tickPromise, _tickPromiseResolver, _tickPromiseRejecter, _messageChain, _snapshotTracker, _activeSnapshotRequests, _midStreamPromise, _midStreamPromiseResolver, _lastSseConnectionStartTime, _minSseConnectionDuration, _consecutiveShortSseConnections, _maxShortSseConnections, _sseFallbackToLongPolling, _sseBackoffBaseDelay, _sseBackoffMaxDelay, _ShapeStream_instances, start_fn, requestShape_fn, constructUrl_fn, createAbortListener_fn, onInitialResponse_fn, onMessages_fn, fetchShape_fn, requestShapeLongPoll_fn, requestShapeSSE_fn, pause_fn, resume_fn, nextTick_fn, waitForStreamEnd_fn, publish_fn, sendErrorToSubscribers_fn, subscribeToVisibilityChanges_fn, reset_fn, fetchSnapshot_fn;
813
+ var _error, _fetchClient2, _sseFetchClient, _messageParser, _subscribers, _started, _state, _lastOffset, _liveCacheBuster, _lastSyncedAt, _isUpToDate, _isMidStream, _connected, _shapeHandle, _mode, _schema, _onError, _requestAbortController, _isRefreshing, _tickPromise, _tickPromiseResolver, _tickPromiseRejecter, _messageChain, _snapshotTracker, _activeSnapshotRequests, _midStreamPromise, _midStreamPromiseResolver, _lastSseConnectionStartTime, _minSseConnectionDuration, _consecutiveShortSseConnections, _maxShortSseConnections, _sseFallbackToLongPolling, _sseBackoffBaseDelay, _sseBackoffMaxDelay, _unsubscribeFromVisibilityChanges, _ShapeStream_instances, start_fn, requestShape_fn, constructUrl_fn, createAbortListener_fn, onInitialResponse_fn, onMessages_fn, fetchShape_fn, requestShapeLongPoll_fn, requestShapeSSE_fn, pause_fn, resume_fn, nextTick_fn, waitForStreamEnd_fn, publish_fn, sendErrorToSubscribers_fn, subscribeToVisibilityChanges_fn, reset_fn, fetchSnapshot_fn;
779
814
  var ShapeStream = class {
780
- // Maximum delay cap (ms)
781
815
  constructor(options) {
782
816
  __privateAdd(this, _ShapeStream_instances);
783
817
  __privateAdd(this, _error, null);
@@ -821,6 +855,8 @@ var ShapeStream = class {
821
855
  __privateAdd(this, _sseBackoffBaseDelay, 100);
822
856
  // Base delay for exponential backoff (ms)
823
857
  __privateAdd(this, _sseBackoffMaxDelay, 5e3);
858
+ // Maximum delay cap (ms)
859
+ __privateAdd(this, _unsubscribeFromVisibilityChanges);
824
860
  var _a, _b, _c, _d;
825
861
  this.options = __spreadValues({ subscribe: true }, options);
826
862
  validateOptions(this.options);
@@ -876,7 +912,9 @@ var ShapeStream = class {
876
912
  };
877
913
  }
878
914
  unsubscribeAll() {
915
+ var _a;
879
916
  __privateGet(this, _subscribers).clear();
917
+ (_a = __privateGet(this, _unsubscribeFromVisibilityChanges)) == null ? void 0 : _a.call(this);
880
918
  }
881
919
  /** Unix time at which we last synced. Undefined when `isLoading` is true. */
882
920
  lastSyncedAt() {
@@ -1003,6 +1041,7 @@ _maxShortSseConnections = new WeakMap();
1003
1041
  _sseFallbackToLongPolling = new WeakMap();
1004
1042
  _sseBackoffBaseDelay = new WeakMap();
1005
1043
  _sseBackoffMaxDelay = new WeakMap();
1044
+ _unsubscribeFromVisibilityChanges = new WeakMap();
1006
1045
  _ShapeStream_instances = new WeakSet();
1007
1046
  start_fn = function() {
1008
1047
  return __async(this, null, function* () {
@@ -1072,7 +1111,8 @@ requestShape_fn = function() {
1072
1111
  return __privateMethod(this, _ShapeStream_instances, requestShape_fn).call(this);
1073
1112
  }
1074
1113
  if (e instanceof FetchBackoffAbortError) {
1075
- if (requestAbortController.signal.aborted && requestAbortController.signal.reason === PAUSE_STREAM) {
1114
+ const currentState = __privateGet(this, _state);
1115
+ if (requestAbortController.signal.aborted && requestAbortController.signal.reason === PAUSE_STREAM && currentState === `pause-requested`) {
1076
1116
  __privateSet(this, _state, `paused`);
1077
1117
  }
1078
1118
  return;
@@ -1333,7 +1373,10 @@ pause_fn = function() {
1333
1373
  }
1334
1374
  };
1335
1375
  resume_fn = function() {
1336
- if (__privateGet(this, _started) && __privateGet(this, _state) === `paused`) {
1376
+ if (__privateGet(this, _started) && (__privateGet(this, _state) === `paused` || __privateGet(this, _state) === `pause-requested`)) {
1377
+ if (__privateGet(this, _state) === `pause-requested`) {
1378
+ __privateSet(this, _state, `active`);
1379
+ }
1337
1380
  __privateMethod(this, _ShapeStream_instances, start_fn).call(this);
1338
1381
  }
1339
1382
  };
@@ -1405,6 +1448,9 @@ subscribeToVisibilityChanges_fn = function() {
1405
1448
  }
1406
1449
  };
1407
1450
  document.addEventListener(`visibilitychange`, visibilityHandler);
1451
+ __privateSet(this, _unsubscribeFromVisibilityChanges, () => {
1452
+ document.removeEventListener(`visibilitychange`, visibilityHandler);
1453
+ });
1408
1454
  }
1409
1455
  };
1410
1456
  /**