@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/dist/index.d.ts CHANGED
@@ -135,16 +135,24 @@ interface BackoffOptions {
135
135
  initialDelay: number;
136
136
  /**
137
137
  * Maximum retry delay in milliseconds
138
+ * After reaching this, delay stays constant (e.g., retry every 60s)
138
139
  */
139
140
  maxDelay: number;
140
141
  multiplier: number;
141
142
  onFailedAttempt?: () => void;
142
143
  debug?: boolean;
144
+ /**
145
+ * Maximum number of retry attempts before giving up.
146
+ * Set to Infinity (default) for indefinite retries - needed for offline scenarios
147
+ * where clients may go offline and come back later.
148
+ */
149
+ maxRetries?: number;
143
150
  }
144
151
  declare const BackoffDefaults: {
145
152
  initialDelay: number;
146
153
  maxDelay: number;
147
154
  multiplier: number;
155
+ maxRetries: number;
148
156
  };
149
157
 
150
158
  declare const LIVE_CACHE_BUSTER_QUERY_PARAM = "cursor";
@@ -296,10 +304,51 @@ interface ShapeStreamOptions<T = never> {
296
304
  transformer?: TransformFunction<T>;
297
305
  /**
298
306
  * A function for handling shapestream errors.
299
- * This is optional, when it is not provided any shapestream errors will be thrown.
300
- * If the function returns an object containing parameters and/or headers
301
- * the shapestream will apply those changes and try syncing again.
302
- * If the function returns void the shapestream is stopped.
307
+ *
308
+ * **Automatic retries**: The client automatically retries 5xx server errors, network
309
+ * errors, and 429 rate limits with exponential backoff. The `onError` callback is
310
+ * only invoked after these automatic retries are exhausted, or for non-retryable
311
+ * errors like 4xx client errors.
312
+ *
313
+ * When not provided, non-retryable errors will be thrown and syncing will stop.
314
+ *
315
+ * **Return value behavior**:
316
+ * - Return an **object** (RetryOpts or empty `{}`) to retry syncing:
317
+ * - `{}` - Retry with the same params and headers
318
+ * - `{ params }` - Retry with modified params
319
+ * - `{ headers }` - Retry with modified headers (e.g., refreshed auth token)
320
+ * - `{ params, headers }` - Retry with both modified
321
+ * - Return **void** or **undefined** to stop the stream permanently
322
+ *
323
+ * **Important**: If you want syncing to continue after an error (e.g., to retry
324
+ * on network failures), you MUST return at least an empty object `{}`. Simply
325
+ * logging the error and returning nothing will stop syncing.
326
+ *
327
+ * Supports async functions that return `Promise<void | RetryOpts>`.
328
+ *
329
+ * @example
330
+ * ```typescript
331
+ * // Retry on network errors, stop on others
332
+ * onError: (error) => {
333
+ * console.error('Stream error:', error)
334
+ * if (error instanceof FetchError && error.status >= 500) {
335
+ * return {} // Retry with same params
336
+ * }
337
+ * // Return void to stop on other errors
338
+ * }
339
+ * ```
340
+ *
341
+ * @example
342
+ * ```typescript
343
+ * // Refresh auth token on 401
344
+ * onError: async (error) => {
345
+ * if (error instanceof FetchError && error.status === 401) {
346
+ * const newToken = await refreshAuthToken()
347
+ * return { headers: { Authorization: `Bearer ${newToken}` } }
348
+ * }
349
+ * return {} // Retry other errors
350
+ * }
351
+ * ```
303
352
  */
304
353
  onError?: ShapeStreamErrorHandler;
305
354
  }
@@ -300,6 +300,7 @@ var SUBSET_PARAM_ORDER_BY = `subset__order_by`;
300
300
  var SUBSET_PARAM_WHERE_PARAMS = `subset__params`;
301
301
  var ELECTRIC_PROTOCOL_QUERY_PARAMS = [
302
302
  LIVE_QUERY_PARAM,
303
+ LIVE_SSE_QUERY_PARAM,
303
304
  SHAPE_HANDLE_QUERY_PARAM,
304
305
  OFFSET_QUERY_PARAM,
305
306
  LIVE_CACHE_BUSTER_QUERY_PARAM,
@@ -316,16 +317,33 @@ var ELECTRIC_PROTOCOL_QUERY_PARAMS = [
316
317
  var HTTP_RETRY_STATUS_CODES = [429];
317
318
  var BackoffDefaults = {
318
319
  initialDelay: 100,
319
- maxDelay: 1e4,
320
- multiplier: 1.3
320
+ maxDelay: 6e4,
321
+ // Cap at 60s - reasonable for long-lived connections
322
+ multiplier: 1.3,
323
+ maxRetries: Infinity
324
+ // Retry forever - clients may go offline and come back
321
325
  };
326
+ function parseRetryAfterHeader(retryAfter) {
327
+ if (!retryAfter) return 0;
328
+ const retryAfterSec = Number(retryAfter);
329
+ if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
330
+ return retryAfterSec * 1e3;
331
+ }
332
+ const retryDate = Date.parse(retryAfter);
333
+ if (!isNaN(retryDate)) {
334
+ const deltaMs = retryDate - Date.now();
335
+ return Math.max(0, Math.min(deltaMs, 36e5));
336
+ }
337
+ return 0;
338
+ }
322
339
  function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
323
340
  const {
324
341
  initialDelay,
325
342
  maxDelay,
326
343
  multiplier,
327
344
  debug = false,
328
- onFailedAttempt
345
+ onFailedAttempt,
346
+ maxRetries = Infinity
329
347
  } = backoffOptions;
330
348
  return async (...args) => {
331
349
  var _a;
@@ -336,7 +354,9 @@ function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
336
354
  while (true) {
337
355
  try {
338
356
  const result = await fetchClient(...args);
339
- if (result.ok) return result;
357
+ if (result.ok) {
358
+ return result;
359
+ }
340
360
  const err = await FetchError.fromResponse(result, url.toString());
341
361
  throw err;
342
362
  } catch (e) {
@@ -346,12 +366,27 @@ function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
346
366
  } else if (e instanceof FetchError && !HTTP_RETRY_STATUS_CODES.includes(e.status) && e.status >= 400 && e.status < 500) {
347
367
  throw e;
348
368
  } else {
349
- await new Promise((resolve) => setTimeout(resolve, delay));
350
- delay = Math.min(delay * multiplier, maxDelay);
369
+ attempt++;
370
+ if (attempt > maxRetries) {
371
+ if (debug) {
372
+ console.log(
373
+ `Max retries reached (${attempt}/${maxRetries}), giving up`
374
+ );
375
+ }
376
+ throw e;
377
+ }
378
+ const serverMinimumMs = e instanceof FetchError && e.headers ? parseRetryAfterHeader(e.headers[`retry-after`]) : 0;
379
+ const jitter = Math.random() * delay;
380
+ const clientBackoffMs = Math.min(jitter, maxDelay);
381
+ const waitMs = Math.max(serverMinimumMs, clientBackoffMs);
351
382
  if (debug) {
352
- attempt++;
353
- console.log(`Retry attempt #${attempt} after ${delay}ms`);
383
+ const source = serverMinimumMs > 0 ? `server+client` : `client`;
384
+ console.log(
385
+ `Retry attempt #${attempt} after ${waitMs}ms (${source}, serverMin=${serverMinimumMs}ms, clientBackoff=${clientBackoffMs}ms)`
386
+ );
354
387
  }
388
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
389
+ delay = Math.min(delay * multiplier, maxDelay);
355
390
  }
356
391
  }
357
392
  }
@@ -716,9 +751,8 @@ function canonicalShapeKey(url) {
716
751
  cleanUrl.searchParams.sort();
717
752
  return cleanUrl.toString();
718
753
  }
719
- 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;
754
+ 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;
720
755
  var ShapeStream = class {
721
- // Maximum delay cap (ms)
722
756
  constructor(options) {
723
757
  __privateAdd(this, _ShapeStream_instances);
724
758
  __privateAdd(this, _error, null);
@@ -762,6 +796,8 @@ var ShapeStream = class {
762
796
  __privateAdd(this, _sseBackoffBaseDelay, 100);
763
797
  // Base delay for exponential backoff (ms)
764
798
  __privateAdd(this, _sseBackoffMaxDelay, 5e3);
799
+ // Maximum delay cap (ms)
800
+ __privateAdd(this, _unsubscribeFromVisibilityChanges);
765
801
  var _a, _b, _c, _d;
766
802
  this.options = __spreadValues({ subscribe: true }, options);
767
803
  validateOptions(this.options);
@@ -817,7 +853,9 @@ var ShapeStream = class {
817
853
  };
818
854
  }
819
855
  unsubscribeAll() {
856
+ var _a;
820
857
  __privateGet(this, _subscribers).clear();
858
+ (_a = __privateGet(this, _unsubscribeFromVisibilityChanges)) == null ? void 0 : _a.call(this);
821
859
  }
822
860
  /** Unix time at which we last synced. Undefined when `isLoading` is true. */
823
861
  lastSyncedAt() {
@@ -940,6 +978,7 @@ _maxShortSseConnections = new WeakMap();
940
978
  _sseFallbackToLongPolling = new WeakMap();
941
979
  _sseBackoffBaseDelay = new WeakMap();
942
980
  _sseBackoffMaxDelay = new WeakMap();
981
+ _unsubscribeFromVisibilityChanges = new WeakMap();
943
982
  _ShapeStream_instances = new WeakSet();
944
983
  start_fn = async function() {
945
984
  var _a, _b, _c, _d, _e;
@@ -1006,7 +1045,8 @@ requestShape_fn = async function() {
1006
1045
  return __privateMethod(this, _ShapeStream_instances, requestShape_fn).call(this);
1007
1046
  }
1008
1047
  if (e instanceof FetchBackoffAbortError) {
1009
- if (requestAbortController.signal.aborted && requestAbortController.signal.reason === PAUSE_STREAM) {
1048
+ const currentState = __privateGet(this, _state);
1049
+ if (requestAbortController.signal.aborted && requestAbortController.signal.reason === PAUSE_STREAM && currentState === `pause-requested`) {
1010
1050
  __privateSet(this, _state, `paused`);
1011
1051
  }
1012
1052
  return;
@@ -1252,7 +1292,10 @@ pause_fn = function() {
1252
1292
  }
1253
1293
  };
1254
1294
  resume_fn = function() {
1255
- if (__privateGet(this, _started) && __privateGet(this, _state) === `paused`) {
1295
+ if (__privateGet(this, _started) && (__privateGet(this, _state) === `paused` || __privateGet(this, _state) === `pause-requested`)) {
1296
+ if (__privateGet(this, _state) === `pause-requested`) {
1297
+ __privateSet(this, _state, `active`);
1298
+ }
1256
1299
  __privateMethod(this, _ShapeStream_instances, start_fn).call(this);
1257
1300
  }
1258
1301
  };
@@ -1318,6 +1361,9 @@ subscribeToVisibilityChanges_fn = function() {
1318
1361
  }
1319
1362
  };
1320
1363
  document.addEventListener(`visibilitychange`, visibilityHandler);
1364
+ __privateSet(this, _unsubscribeFromVisibilityChanges, () => {
1365
+ document.removeEventListener(`visibilitychange`, visibilityHandler);
1366
+ });
1321
1367
  }
1322
1368
  };
1323
1369
  /**