@electric-sql/client 1.5.7 → 1.5.8

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.mjs CHANGED
@@ -429,7 +429,7 @@ function isChangeMessage(message) {
429
429
  return message != null && `key` in message;
430
430
  }
431
431
  function isControlMessage(message) {
432
- return message != null && !isChangeMessage(message);
432
+ return message != null && `headers` in message && `control` in message.headers;
433
433
  }
434
434
  function isUpToDateMessage(message) {
435
435
  return isControlMessage(message) && message.headers.control === `up-to-date`;
@@ -504,10 +504,9 @@ var ELECTRIC_PROTOCOL_QUERY_PARAMS = [
504
504
  // src/fetch.ts
505
505
  var HTTP_RETRY_STATUS_CODES = [429];
506
506
  var BackoffDefaults = {
507
- initialDelay: 100,
508
- maxDelay: 6e4,
509
- // Cap at 60s - reasonable for long-lived connections
510
- multiplier: 1.3,
507
+ initialDelay: 1e3,
508
+ maxDelay: 32e3,
509
+ multiplier: 2,
511
510
  maxRetries: Infinity
512
511
  // Retry forever - clients may go offline and come back
513
512
  };
@@ -1122,6 +1121,10 @@ var ExpiredShapesCache = class {
1122
1121
  this.data = {};
1123
1122
  this.save();
1124
1123
  }
1124
+ delete(shapeUrl) {
1125
+ delete this.data[shapeUrl];
1126
+ this.save();
1127
+ }
1125
1128
  };
1126
1129
  var expiredShapesCache = new ExpiredShapesCache();
1127
1130
 
@@ -1243,6 +1246,10 @@ var UpToDateTracker = class {
1243
1246
  }
1244
1247
  this.save();
1245
1248
  }
1249
+ delete(shapeKey) {
1250
+ delete this.data[shapeKey];
1251
+ this.save();
1252
+ }
1246
1253
  };
1247
1254
  var upToDateTracker = new UpToDateTracker();
1248
1255
 
@@ -1867,6 +1874,7 @@ var RESERVED_PARAMS = /* @__PURE__ */ new Set([
1867
1874
  OFFSET_QUERY_PARAM,
1868
1875
  CACHE_BUSTER_QUERY_PARAM
1869
1876
  ]);
1877
+ var TROUBLESHOOTING_URL = `https://electric-sql.com/docs/guides/troubleshooting`;
1870
1878
  async function resolveValue(value) {
1871
1879
  if (typeof value === `function`) {
1872
1880
  return value();
@@ -1907,7 +1915,7 @@ function canonicalShapeKey(url) {
1907
1915
  cleanUrl.searchParams.sort();
1908
1916
  return cleanUrl.toString();
1909
1917
  }
1910
- var _error, _fetchClient2, _sseFetchClient, _messageParser, _subscribers, _started, _syncState, _connected, _mode, _onError, _requestAbortController, _refreshCount, _snapshotCounter, _ShapeStream_instances, isRefreshing_get, _tickPromise, _tickPromiseResolver, _tickPromiseRejecter, _messageChain, _snapshotTracker, _pauseLock, _currentFetchUrl, _lastSseConnectionStartTime, _minSseConnectionDuration, _maxShortSseConnections, _sseBackoffBaseDelay, _sseBackoffMaxDelay, _unsubscribeFromVisibilityChanges, _unsubscribeFromWakeDetection, _maxStaleCacheRetries, start_fn, teardown_fn, requestShape_fn, constructUrl_fn, createAbortListener_fn, onInitialResponse_fn, onMessages_fn, fetchShape_fn, requestShapeLongPoll_fn, requestShapeSSE_fn, nextTick_fn, publish_fn, sendErrorToSubscribers_fn, hasBrowserVisibilityAPI_fn, subscribeToVisibilityChanges_fn, subscribeToWakeDetection_fn, reset_fn, buildSubsetBody_fn;
1918
+ var _error, _fetchClient2, _sseFetchClient, _messageParser, _subscribers, _started, _syncState, _connected, _mode, _onError, _requestAbortController, _refreshCount, _snapshotCounter, _ShapeStream_instances, isRefreshing_get, _tickPromise, _tickPromiseResolver, _tickPromiseRejecter, _messageChain, _snapshotTracker, _pauseLock, _currentFetchUrl, _lastSseConnectionStartTime, _minSseConnectionDuration, _maxShortSseConnections, _sseBackoffBaseDelay, _sseBackoffMaxDelay, _unsubscribeFromVisibilityChanges, _unsubscribeFromWakeDetection, _maxStaleCacheRetries, _recentRequestEntries, _fastLoopWindowMs, _fastLoopThreshold, _fastLoopBackoffBaseMs, _fastLoopBackoffMaxMs, _fastLoopConsecutiveCount, _fastLoopMaxCount, start_fn, teardown_fn, requestShape_fn, checkFastLoop_fn, constructUrl_fn, createAbortListener_fn, onInitialResponse_fn, onMessages_fn, fetchShape_fn, requestShapeLongPoll_fn, requestShapeSSE_fn, nextTick_fn, publish_fn, sendErrorToSubscribers_fn, hasBrowserVisibilityAPI_fn, subscribeToVisibilityChanges_fn, subscribeToWakeDetection_fn, reset_fn, buildSubsetBody_fn;
1911
1919
  var ShapeStream = class {
1912
1920
  constructor(options) {
1913
1921
  __privateAdd(this, _ShapeStream_instances);
@@ -1945,6 +1953,15 @@ var ShapeStream = class {
1945
1953
  __privateAdd(this, _unsubscribeFromVisibilityChanges);
1946
1954
  __privateAdd(this, _unsubscribeFromWakeDetection);
1947
1955
  __privateAdd(this, _maxStaleCacheRetries, 3);
1956
+ // Fast-loop detection: track recent non-live requests to detect tight retry
1957
+ // loops caused by proxy/CDN misconfiguration or stale client-side caches
1958
+ __privateAdd(this, _recentRequestEntries, []);
1959
+ __privateAdd(this, _fastLoopWindowMs, 500);
1960
+ __privateAdd(this, _fastLoopThreshold, 5);
1961
+ __privateAdd(this, _fastLoopBackoffBaseMs, 100);
1962
+ __privateAdd(this, _fastLoopBackoffMaxMs, 5e3);
1963
+ __privateAdd(this, _fastLoopConsecutiveCount, 0);
1964
+ __privateAdd(this, _fastLoopMaxCount, 5);
1948
1965
  var _a, _b, _c, _d;
1949
1966
  this.options = __spreadValues({ subscribe: true }, options);
1950
1967
  validateOptions(this.options);
@@ -2242,6 +2259,13 @@ _sseBackoffMaxDelay = new WeakMap();
2242
2259
  _unsubscribeFromVisibilityChanges = new WeakMap();
2243
2260
  _unsubscribeFromWakeDetection = new WeakMap();
2244
2261
  _maxStaleCacheRetries = new WeakMap();
2262
+ _recentRequestEntries = new WeakMap();
2263
+ _fastLoopWindowMs = new WeakMap();
2264
+ _fastLoopThreshold = new WeakMap();
2265
+ _fastLoopBackoffBaseMs = new WeakMap();
2266
+ _fastLoopBackoffMaxMs = new WeakMap();
2267
+ _fastLoopConsecutiveCount = new WeakMap();
2268
+ _fastLoopMaxCount = new WeakMap();
2245
2269
  start_fn = async function() {
2246
2270
  var _a, _b;
2247
2271
  __privateSet(this, _started, true);
@@ -2266,6 +2290,8 @@ start_fn = async function() {
2266
2290
  if (__privateGet(this, _syncState) instanceof ErrorState) {
2267
2291
  __privateSet(this, _syncState, __privateGet(this, _syncState).retry());
2268
2292
  }
2293
+ __privateSet(this, _fastLoopConsecutiveCount, 0);
2294
+ __privateSet(this, _recentRequestEntries, []);
2269
2295
  __privateSet(this, _started, false);
2270
2296
  await __privateMethod(this, _ShapeStream_instances, start_fn).call(this);
2271
2297
  return;
@@ -2296,6 +2322,12 @@ requestShape_fn = async function() {
2296
2322
  if (!this.options.subscribe && (((_a = this.options.signal) == null ? void 0 : _a.aborted) || __privateGet(this, _syncState).isUpToDate)) {
2297
2323
  return;
2298
2324
  }
2325
+ if (!__privateGet(this, _syncState).isUpToDate) {
2326
+ await __privateMethod(this, _ShapeStream_instances, checkFastLoop_fn).call(this);
2327
+ } else {
2328
+ __privateSet(this, _fastLoopConsecutiveCount, 0);
2329
+ __privateSet(this, _recentRequestEntries, []);
2330
+ }
2299
2331
  let resumingFromPause = false;
2300
2332
  if (__privateGet(this, _syncState) instanceof PausedState) {
2301
2333
  resumingFromPause = true;
@@ -2354,6 +2386,56 @@ requestShape_fn = async function() {
2354
2386
  (_b = __privateGet(this, _tickPromiseResolver)) == null ? void 0 : _b.call(this);
2355
2387
  return __privateMethod(this, _ShapeStream_instances, requestShape_fn).call(this);
2356
2388
  };
2389
+ checkFastLoop_fn = async function() {
2390
+ const now = Date.now();
2391
+ const currentOffset = __privateGet(this, _syncState).offset;
2392
+ __privateSet(this, _recentRequestEntries, __privateGet(this, _recentRequestEntries).filter(
2393
+ (e) => now - e.timestamp < __privateGet(this, _fastLoopWindowMs)
2394
+ ));
2395
+ __privateGet(this, _recentRequestEntries).push({ timestamp: now, offset: currentOffset });
2396
+ const sameOffsetCount = __privateGet(this, _recentRequestEntries).filter(
2397
+ (e) => e.offset === currentOffset
2398
+ ).length;
2399
+ if (sameOffsetCount < __privateGet(this, _fastLoopThreshold)) return;
2400
+ __privateWrapper(this, _fastLoopConsecutiveCount)._++;
2401
+ if (__privateGet(this, _fastLoopConsecutiveCount) >= __privateGet(this, _fastLoopMaxCount)) {
2402
+ throw new FetchError(
2403
+ 502,
2404
+ void 0,
2405
+ void 0,
2406
+ {},
2407
+ this.options.url,
2408
+ `Client is stuck in a fast retry loop (${__privateGet(this, _fastLoopThreshold)} requests in ${__privateGet(this, _fastLoopWindowMs)}ms at the same offset, repeated ${__privateGet(this, _fastLoopMaxCount)} times). Client-side caches were cleared automatically on first detection, but the loop persists. This usually indicates a proxy or CDN misconfiguration. Common causes:
2409
+ - Proxy is not including query parameters (handle, offset) in its cache key
2410
+ - CDN is serving stale 409 responses
2411
+ - Proxy is stripping required Electric headers from responses
2412
+ For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL}`
2413
+ );
2414
+ }
2415
+ if (__privateGet(this, _fastLoopConsecutiveCount) === 1) {
2416
+ console.warn(
2417
+ `[Electric] Detected fast retry loop (${__privateGet(this, _fastLoopThreshold)} requests in ${__privateGet(this, _fastLoopWindowMs)}ms at the same offset). Clearing client-side caches and resetting stream to recover. If this persists, check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key, and that required Electric headers are forwarded to the client. For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL}`
2418
+ );
2419
+ if (__privateGet(this, _currentFetchUrl)) {
2420
+ const shapeKey = canonicalShapeKey(__privateGet(this, _currentFetchUrl));
2421
+ expiredShapesCache.delete(shapeKey);
2422
+ upToDateTracker.delete(shapeKey);
2423
+ } else {
2424
+ expiredShapesCache.clear();
2425
+ upToDateTracker.clear();
2426
+ }
2427
+ __privateMethod(this, _ShapeStream_instances, reset_fn).call(this);
2428
+ __privateSet(this, _recentRequestEntries, []);
2429
+ return;
2430
+ }
2431
+ const maxDelay = Math.min(
2432
+ __privateGet(this, _fastLoopBackoffMaxMs),
2433
+ __privateGet(this, _fastLoopBackoffBaseMs) * Math.pow(2, __privateGet(this, _fastLoopConsecutiveCount))
2434
+ );
2435
+ const delayMs = Math.floor(Math.random() * maxDelay);
2436
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
2437
+ __privateSet(this, _recentRequestEntries, []);
2438
+ };
2357
2439
  constructUrl_fn = async function(url, resumingFromPause, subsetParams) {
2358
2440
  var _a, _b, _c, _d, _e, _f;
2359
2441
  const [requestHeaders, params] = await Promise.all([
@@ -2504,11 +2586,11 @@ onInitialResponse_fn = async function(response) {
2504
2586
  void 0,
2505
2587
  {},
2506
2588
  (_c = (_b = __privateGet(this, _currentFetchUrl)) == null ? void 0 : _b.toString()) != null ? _c : ``,
2507
- `CDN continues serving stale cached responses after ${__privateGet(this, _maxStaleCacheRetries)} retry attempts. This indicates a severe proxy/CDN misconfiguration. Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. For more information visit the troubleshooting guide: https://electric-sql.com/docs/guides/troubleshooting`
2589
+ `CDN continues serving stale cached responses after ${__privateGet(this, _maxStaleCacheRetries)} retry attempts. This indicates a severe proxy/CDN misconfiguration. Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL}`
2508
2590
  );
2509
2591
  }
2510
2592
  console.warn(
2511
- `[Electric] Received stale cached response with expired shape handle. This should not happen and indicates a proxy/CDN caching misconfiguration. The response contained handle "${shapeHandle}" which was previously marked as expired. Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. For more information visit the troubleshooting guide: https://electric-sql.com/docs/guides/troubleshooting Retrying with a random cache buster to bypass the stale cache (attempt ${__privateGet(this, _syncState).staleCacheRetryCount}/${__privateGet(this, _maxStaleCacheRetries)}).`
2593
+ `[Electric] Received stale cached response with expired shape handle. This should not happen and indicates a proxy/CDN caching misconfiguration. The response contained handle "${shapeHandle}" which was previously marked as expired. Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL} Retrying with a random cache buster to bypass the stale cache (attempt ${__privateGet(this, _syncState).staleCacheRetryCount}/${__privateGet(this, _maxStaleCacheRetries)}).`
2512
2594
  );
2513
2595
  throw new StaleCacheError(
2514
2596
  `Received stale cached response with expired handle "${shapeHandle}". This indicates a proxy/CDN caching misconfiguration. Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key.`