@electric-sql/client 1.5.6 → 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`;
@@ -439,6 +439,12 @@ function getOffset(message) {
439
439
  const lsn = message.headers.global_last_seen_lsn;
440
440
  return lsn ? `${lsn}_0` : void 0;
441
441
  }
442
+ function bigintReplacer(_key, value) {
443
+ return typeof value === `bigint` ? value.toString() : value;
444
+ }
445
+ function bigintSafeStringify(value) {
446
+ return JSON.stringify(value, bigintReplacer);
447
+ }
442
448
  function isVisibleInSnapshot(txid, snapshot) {
443
449
  const xid = BigInt(txid);
444
450
  const xmin = BigInt(snapshot.xmin);
@@ -498,10 +504,9 @@ var ELECTRIC_PROTOCOL_QUERY_PARAMS = [
498
504
  // src/fetch.ts
499
505
  var HTTP_RETRY_STATUS_CODES = [429];
500
506
  var BackoffDefaults = {
501
- initialDelay: 100,
502
- maxDelay: 6e4,
503
- // Cap at 60s - reasonable for long-lived connections
504
- multiplier: 1.3,
507
+ initialDelay: 1e3,
508
+ maxDelay: 32e3,
509
+ multiplier: 2,
505
510
  maxRetries: Infinity
506
511
  // Retry forever - clients may go offline and come back
507
512
  };
@@ -1116,6 +1121,10 @@ var ExpiredShapesCache = class {
1116
1121
  this.data = {};
1117
1122
  this.save();
1118
1123
  }
1124
+ delete(shapeUrl) {
1125
+ delete this.data[shapeUrl];
1126
+ this.save();
1127
+ }
1119
1128
  };
1120
1129
  var expiredShapesCache = new ExpiredShapesCache();
1121
1130
 
@@ -1237,6 +1246,10 @@ var UpToDateTracker = class {
1237
1246
  }
1238
1247
  this.save();
1239
1248
  }
1249
+ delete(shapeKey) {
1250
+ delete this.data[shapeKey];
1251
+ this.save();
1252
+ }
1240
1253
  };
1241
1254
  var upToDateTracker = new UpToDateTracker();
1242
1255
 
@@ -1704,6 +1717,13 @@ var PausedState = class _PausedState extends ShapeStreamState {
1704
1717
  get replayCursor() {
1705
1718
  return this.previousState.replayCursor;
1706
1719
  }
1720
+ handleResponseMetadata(input) {
1721
+ const transition = this.previousState.handleResponseMetadata(input);
1722
+ if (transition.action === `accepted`) {
1723
+ return { action: `accepted`, state: new _PausedState(transition.state) };
1724
+ }
1725
+ return transition;
1726
+ }
1707
1727
  withHandle(handle) {
1708
1728
  return new _PausedState(this.previousState.withHandle(handle));
1709
1729
  }
@@ -1854,6 +1874,7 @@ var RESERVED_PARAMS = /* @__PURE__ */ new Set([
1854
1874
  OFFSET_QUERY_PARAM,
1855
1875
  CACHE_BUSTER_QUERY_PARAM
1856
1876
  ]);
1877
+ var TROUBLESHOOTING_URL = `https://electric-sql.com/docs/guides/troubleshooting`;
1857
1878
  async function resolveValue(value) {
1858
1879
  if (typeof value === `function`) {
1859
1880
  return value();
@@ -1894,7 +1915,7 @@ function canonicalShapeKey(url) {
1894
1915
  cleanUrl.searchParams.sort();
1895
1916
  return cleanUrl.toString();
1896
1917
  }
1897
- 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;
1898
1919
  var ShapeStream = class {
1899
1920
  constructor(options) {
1900
1921
  __privateAdd(this, _ShapeStream_instances);
@@ -1932,6 +1953,15 @@ var ShapeStream = class {
1932
1953
  __privateAdd(this, _unsubscribeFromVisibilityChanges);
1933
1954
  __privateAdd(this, _unsubscribeFromWakeDetection);
1934
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);
1935
1965
  var _a, _b, _c, _d;
1936
1966
  this.options = __spreadValues({ subscribe: true }, options);
1937
1967
  validateOptions(this.options);
@@ -2094,7 +2124,7 @@ var ShapeStream = class {
2094
2124
  );
2095
2125
  }, 3e4);
2096
2126
  try {
2097
- const { metadata, data } = await this.fetchSnapshot(opts);
2127
+ const { metadata, data, responseOffset, responseHandle } = await this.fetchSnapshot(opts);
2098
2128
  const dataWithEndBoundary = data.concat([
2099
2129
  { headers: __spreadValues({ control: `snapshot-end` }, metadata) },
2100
2130
  { headers: __spreadValues({ control: `subset-end` }, opts) }
@@ -2104,6 +2134,25 @@ var ShapeStream = class {
2104
2134
  new Set(data.map((message) => message.key))
2105
2135
  );
2106
2136
  __privateMethod(this, _ShapeStream_instances, onMessages_fn).call(this, dataWithEndBoundary, false);
2137
+ if (responseOffset !== null || responseHandle !== null) {
2138
+ const transition = __privateGet(this, _syncState).handleResponseMetadata({
2139
+ status: 200,
2140
+ responseHandle,
2141
+ responseOffset,
2142
+ responseCursor: null,
2143
+ expiredHandle: null,
2144
+ now: Date.now(),
2145
+ maxStaleCacheRetries: __privateGet(this, _maxStaleCacheRetries),
2146
+ createCacheBuster: () => `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
2147
+ });
2148
+ if (transition.action === `accepted`) {
2149
+ __privateSet(this, _syncState, transition.state);
2150
+ } else {
2151
+ console.warn(
2152
+ `[Electric] Snapshot response metadata was not accepted by state "${__privateGet(this, _syncState).kind}" (action: ${transition.action}). Stream offset was not advanced from snapshot.`
2153
+ );
2154
+ }
2155
+ }
2107
2156
  return {
2108
2157
  metadata,
2109
2158
  data
@@ -2122,7 +2171,7 @@ var ShapeStream = class {
2122
2171
  * `subsetMethod: 'POST'` on the stream to send parameters in the request body instead.
2123
2172
  *
2124
2173
  * @param opts - The options for the snapshot request.
2125
- * @returns The metadata and the data for the snapshot.
2174
+ * @returns The metadata, data, and the response's offset/handle for state advancement.
2126
2175
  */
2127
2176
  async fetchSnapshot(opts) {
2128
2177
  var _a, _b, _c;
@@ -2138,7 +2187,7 @@ var ShapeStream = class {
2138
2187
  headers: __spreadProps(__spreadValues({}, result.requestHeaders), {
2139
2188
  "Content-Type": `application/json`
2140
2189
  }),
2141
- body: JSON.stringify(__privateMethod(this, _ShapeStream_instances, buildSubsetBody_fn).call(this, opts))
2190
+ body: bigintSafeStringify(__privateMethod(this, _ShapeStream_instances, buildSubsetBody_fn).call(this, opts))
2142
2191
  };
2143
2192
  } else {
2144
2193
  const result = await __privateMethod(this, _ShapeStream_instances, constructUrl_fn).call(this, this.options.url, true, opts);
@@ -2173,7 +2222,9 @@ var ShapeStream = class {
2173
2222
  rawData,
2174
2223
  schema
2175
2224
  );
2176
- return { metadata, data };
2225
+ const responseOffset = response.headers.get(CHUNK_LAST_OFFSET_HEADER) || null;
2226
+ const responseHandle = response.headers.get(SHAPE_HANDLE_HEADER);
2227
+ return { metadata, data, responseOffset, responseHandle };
2177
2228
  }
2178
2229
  };
2179
2230
  _error = new WeakMap();
@@ -2208,6 +2259,13 @@ _sseBackoffMaxDelay = new WeakMap();
2208
2259
  _unsubscribeFromVisibilityChanges = new WeakMap();
2209
2260
  _unsubscribeFromWakeDetection = new WeakMap();
2210
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();
2211
2269
  start_fn = async function() {
2212
2270
  var _a, _b;
2213
2271
  __privateSet(this, _started, true);
@@ -2232,6 +2290,8 @@ start_fn = async function() {
2232
2290
  if (__privateGet(this, _syncState) instanceof ErrorState) {
2233
2291
  __privateSet(this, _syncState, __privateGet(this, _syncState).retry());
2234
2292
  }
2293
+ __privateSet(this, _fastLoopConsecutiveCount, 0);
2294
+ __privateSet(this, _recentRequestEntries, []);
2235
2295
  __privateSet(this, _started, false);
2236
2296
  await __privateMethod(this, _ShapeStream_instances, start_fn).call(this);
2237
2297
  return;
@@ -2262,6 +2322,12 @@ requestShape_fn = async function() {
2262
2322
  if (!this.options.subscribe && (((_a = this.options.signal) == null ? void 0 : _a.aborted) || __privateGet(this, _syncState).isUpToDate)) {
2263
2323
  return;
2264
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
+ }
2265
2331
  let resumingFromPause = false;
2266
2332
  if (__privateGet(this, _syncState) instanceof PausedState) {
2267
2333
  resumingFromPause = true;
@@ -2320,6 +2386,56 @@ requestShape_fn = async function() {
2320
2386
  (_b = __privateGet(this, _tickPromiseResolver)) == null ? void 0 : _b.call(this);
2321
2387
  return __privateMethod(this, _ShapeStream_instances, requestShape_fn).call(this);
2322
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
+ };
2323
2439
  constructUrl_fn = async function(url, resumingFromPause, subsetParams) {
2324
2440
  var _a, _b, _c, _d, _e, _f;
2325
2441
  const [requestHeaders, params] = await Promise.all([
@@ -2386,7 +2502,7 @@ constructUrl_fn = async function(url, resumingFromPause, subsetParams) {
2386
2502
  if (subsetParams.params)
2387
2503
  fetchUrl.searchParams.set(
2388
2504
  SUBSET_PARAM_WHERE_PARAMS,
2389
- JSON.stringify(subsetParams.params)
2505
+ bigintSafeStringify(subsetParams.params)
2390
2506
  );
2391
2507
  if (subsetParams.limit)
2392
2508
  setQueryParam(fetchUrl, SUBSET_PARAM_LIMIT, subsetParams.limit);
@@ -2470,11 +2586,11 @@ onInitialResponse_fn = async function(response) {
2470
2586
  void 0,
2471
2587
  {},
2472
2588
  (_c = (_b = __privateGet(this, _currentFetchUrl)) == null ? void 0 : _b.toString()) != null ? _c : ``,
2473
- `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}`
2474
2590
  );
2475
2591
  }
2476
2592
  console.warn(
2477
- `[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)}).`
2478
2594
  );
2479
2595
  throw new StaleCacheError(
2480
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.`
@@ -2565,7 +2681,7 @@ requestShapeLongPoll_fn = async function(opts) {
2565
2681
  const messages = res || `[]`;
2566
2682
  const batch = __privateGet(this, _messageParser).parse(messages, schema);
2567
2683
  if (!Array.isArray(batch)) {
2568
- const preview = (_a = JSON.stringify(batch)) == null ? void 0 : _a.slice(0, 200);
2684
+ const preview = (_a = bigintSafeStringify(batch)) == null ? void 0 : _a.slice(0, 200);
2569
2685
  throw new FetchError(
2570
2686
  response.status,
2571
2687
  `Received non-array response body from shape endpoint. This may indicate a proxy or CDN is returning an unexpected response. Expected a JSON array, got ${typeof batch}: ${preview}`,
@@ -2929,7 +3045,7 @@ var Shape = class {
2929
3045
  * Returns void; data will be emitted via the stream and processed by this Shape.
2930
3046
  */
2931
3047
  async requestSnapshot(params) {
2932
- const key = JSON.stringify(params);
3048
+ const key = bigintSafeStringify(params);
2933
3049
  __privateGet(this, _requestedSubSnapshots).add(key);
2934
3050
  await __privateMethod(this, _Shape_instances, awaitUpToDate_fn).call(this);
2935
3051
  await this.stream.requestSnapshot(params);