@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.
@@ -466,7 +466,7 @@ function isChangeMessage(message) {
466
466
  return message != null && `key` in message;
467
467
  }
468
468
  function isControlMessage(message) {
469
- return message != null && !isChangeMessage(message);
469
+ return message != null && `headers` in message && `control` in message.headers;
470
470
  }
471
471
  function isUpToDateMessage(message) {
472
472
  return isControlMessage(message) && message.headers.control === `up-to-date`;
@@ -476,6 +476,12 @@ function getOffset(message) {
476
476
  const lsn = message.headers.global_last_seen_lsn;
477
477
  return lsn ? `${lsn}_0` : void 0;
478
478
  }
479
+ function bigintReplacer(_key, value) {
480
+ return typeof value === `bigint` ? value.toString() : value;
481
+ }
482
+ function bigintSafeStringify(value) {
483
+ return JSON.stringify(value, bigintReplacer);
484
+ }
479
485
  function isVisibleInSnapshot(txid, snapshot) {
480
486
  const xid = BigInt(txid);
481
487
  const xmin = BigInt(snapshot.xmin);
@@ -535,10 +541,9 @@ var ELECTRIC_PROTOCOL_QUERY_PARAMS = [
535
541
  // src/fetch.ts
536
542
  var HTTP_RETRY_STATUS_CODES = [429];
537
543
  var BackoffDefaults = {
538
- initialDelay: 100,
539
- maxDelay: 6e4,
540
- // Cap at 60s - reasonable for long-lived connections
541
- multiplier: 1.3,
544
+ initialDelay: 1e3,
545
+ maxDelay: 32e3,
546
+ multiplier: 2,
542
547
  maxRetries: Infinity
543
548
  // Retry forever - clients may go offline and come back
544
549
  };
@@ -1153,6 +1158,10 @@ var ExpiredShapesCache = class {
1153
1158
  this.data = {};
1154
1159
  this.save();
1155
1160
  }
1161
+ delete(shapeUrl) {
1162
+ delete this.data[shapeUrl];
1163
+ this.save();
1164
+ }
1156
1165
  };
1157
1166
  var expiredShapesCache = new ExpiredShapesCache();
1158
1167
 
@@ -1274,6 +1283,10 @@ var UpToDateTracker = class {
1274
1283
  }
1275
1284
  this.save();
1276
1285
  }
1286
+ delete(shapeKey) {
1287
+ delete this.data[shapeKey];
1288
+ this.save();
1289
+ }
1277
1290
  };
1278
1291
  var upToDateTracker = new UpToDateTracker();
1279
1292
 
@@ -1741,6 +1754,13 @@ var PausedState = class _PausedState extends ShapeStreamState {
1741
1754
  get replayCursor() {
1742
1755
  return this.previousState.replayCursor;
1743
1756
  }
1757
+ handleResponseMetadata(input) {
1758
+ const transition = this.previousState.handleResponseMetadata(input);
1759
+ if (transition.action === `accepted`) {
1760
+ return { action: `accepted`, state: new _PausedState(transition.state) };
1761
+ }
1762
+ return transition;
1763
+ }
1744
1764
  withHandle(handle) {
1745
1765
  return new _PausedState(this.previousState.withHandle(handle));
1746
1766
  }
@@ -1891,6 +1911,7 @@ var RESERVED_PARAMS = /* @__PURE__ */ new Set([
1891
1911
  OFFSET_QUERY_PARAM,
1892
1912
  CACHE_BUSTER_QUERY_PARAM
1893
1913
  ]);
1914
+ var TROUBLESHOOTING_URL = `https://electric-sql.com/docs/guides/troubleshooting`;
1894
1915
  async function resolveValue(value) {
1895
1916
  if (typeof value === `function`) {
1896
1917
  return value();
@@ -1931,7 +1952,7 @@ function canonicalShapeKey(url) {
1931
1952
  cleanUrl.searchParams.sort();
1932
1953
  return cleanUrl.toString();
1933
1954
  }
1934
- 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;
1955
+ 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;
1935
1956
  var ShapeStream = class {
1936
1957
  constructor(options) {
1937
1958
  __privateAdd(this, _ShapeStream_instances);
@@ -1969,6 +1990,15 @@ var ShapeStream = class {
1969
1990
  __privateAdd(this, _unsubscribeFromVisibilityChanges);
1970
1991
  __privateAdd(this, _unsubscribeFromWakeDetection);
1971
1992
  __privateAdd(this, _maxStaleCacheRetries, 3);
1993
+ // Fast-loop detection: track recent non-live requests to detect tight retry
1994
+ // loops caused by proxy/CDN misconfiguration or stale client-side caches
1995
+ __privateAdd(this, _recentRequestEntries, []);
1996
+ __privateAdd(this, _fastLoopWindowMs, 500);
1997
+ __privateAdd(this, _fastLoopThreshold, 5);
1998
+ __privateAdd(this, _fastLoopBackoffBaseMs, 100);
1999
+ __privateAdd(this, _fastLoopBackoffMaxMs, 5e3);
2000
+ __privateAdd(this, _fastLoopConsecutiveCount, 0);
2001
+ __privateAdd(this, _fastLoopMaxCount, 5);
1972
2002
  var _a, _b, _c, _d;
1973
2003
  this.options = __spreadValues({ subscribe: true }, options);
1974
2004
  validateOptions(this.options);
@@ -2131,7 +2161,7 @@ var ShapeStream = class {
2131
2161
  );
2132
2162
  }, 3e4);
2133
2163
  try {
2134
- const { metadata, data } = await this.fetchSnapshot(opts);
2164
+ const { metadata, data, responseOffset, responseHandle } = await this.fetchSnapshot(opts);
2135
2165
  const dataWithEndBoundary = data.concat([
2136
2166
  { headers: __spreadValues({ control: `snapshot-end` }, metadata) },
2137
2167
  { headers: __spreadValues({ control: `subset-end` }, opts) }
@@ -2141,6 +2171,25 @@ var ShapeStream = class {
2141
2171
  new Set(data.map((message) => message.key))
2142
2172
  );
2143
2173
  __privateMethod(this, _ShapeStream_instances, onMessages_fn).call(this, dataWithEndBoundary, false);
2174
+ if (responseOffset !== null || responseHandle !== null) {
2175
+ const transition = __privateGet(this, _syncState).handleResponseMetadata({
2176
+ status: 200,
2177
+ responseHandle,
2178
+ responseOffset,
2179
+ responseCursor: null,
2180
+ expiredHandle: null,
2181
+ now: Date.now(),
2182
+ maxStaleCacheRetries: __privateGet(this, _maxStaleCacheRetries),
2183
+ createCacheBuster: () => `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
2184
+ });
2185
+ if (transition.action === `accepted`) {
2186
+ __privateSet(this, _syncState, transition.state);
2187
+ } else {
2188
+ console.warn(
2189
+ `[Electric] Snapshot response metadata was not accepted by state "${__privateGet(this, _syncState).kind}" (action: ${transition.action}). Stream offset was not advanced from snapshot.`
2190
+ );
2191
+ }
2192
+ }
2144
2193
  return {
2145
2194
  metadata,
2146
2195
  data
@@ -2159,7 +2208,7 @@ var ShapeStream = class {
2159
2208
  * `subsetMethod: 'POST'` on the stream to send parameters in the request body instead.
2160
2209
  *
2161
2210
  * @param opts - The options for the snapshot request.
2162
- * @returns The metadata and the data for the snapshot.
2211
+ * @returns The metadata, data, and the response's offset/handle for state advancement.
2163
2212
  */
2164
2213
  async fetchSnapshot(opts) {
2165
2214
  var _a, _b, _c;
@@ -2175,7 +2224,7 @@ var ShapeStream = class {
2175
2224
  headers: __spreadProps(__spreadValues({}, result.requestHeaders), {
2176
2225
  "Content-Type": `application/json`
2177
2226
  }),
2178
- body: JSON.stringify(__privateMethod(this, _ShapeStream_instances, buildSubsetBody_fn).call(this, opts))
2227
+ body: bigintSafeStringify(__privateMethod(this, _ShapeStream_instances, buildSubsetBody_fn).call(this, opts))
2179
2228
  };
2180
2229
  } else {
2181
2230
  const result = await __privateMethod(this, _ShapeStream_instances, constructUrl_fn).call(this, this.options.url, true, opts);
@@ -2210,7 +2259,9 @@ var ShapeStream = class {
2210
2259
  rawData,
2211
2260
  schema
2212
2261
  );
2213
- return { metadata, data };
2262
+ const responseOffset = response.headers.get(CHUNK_LAST_OFFSET_HEADER) || null;
2263
+ const responseHandle = response.headers.get(SHAPE_HANDLE_HEADER);
2264
+ return { metadata, data, responseOffset, responseHandle };
2214
2265
  }
2215
2266
  };
2216
2267
  _error = new WeakMap();
@@ -2245,6 +2296,13 @@ _sseBackoffMaxDelay = new WeakMap();
2245
2296
  _unsubscribeFromVisibilityChanges = new WeakMap();
2246
2297
  _unsubscribeFromWakeDetection = new WeakMap();
2247
2298
  _maxStaleCacheRetries = new WeakMap();
2299
+ _recentRequestEntries = new WeakMap();
2300
+ _fastLoopWindowMs = new WeakMap();
2301
+ _fastLoopThreshold = new WeakMap();
2302
+ _fastLoopBackoffBaseMs = new WeakMap();
2303
+ _fastLoopBackoffMaxMs = new WeakMap();
2304
+ _fastLoopConsecutiveCount = new WeakMap();
2305
+ _fastLoopMaxCount = new WeakMap();
2248
2306
  start_fn = async function() {
2249
2307
  var _a, _b;
2250
2308
  __privateSet(this, _started, true);
@@ -2269,6 +2327,8 @@ start_fn = async function() {
2269
2327
  if (__privateGet(this, _syncState) instanceof ErrorState) {
2270
2328
  __privateSet(this, _syncState, __privateGet(this, _syncState).retry());
2271
2329
  }
2330
+ __privateSet(this, _fastLoopConsecutiveCount, 0);
2331
+ __privateSet(this, _recentRequestEntries, []);
2272
2332
  __privateSet(this, _started, false);
2273
2333
  await __privateMethod(this, _ShapeStream_instances, start_fn).call(this);
2274
2334
  return;
@@ -2299,6 +2359,12 @@ requestShape_fn = async function() {
2299
2359
  if (!this.options.subscribe && (((_a = this.options.signal) == null ? void 0 : _a.aborted) || __privateGet(this, _syncState).isUpToDate)) {
2300
2360
  return;
2301
2361
  }
2362
+ if (!__privateGet(this, _syncState).isUpToDate) {
2363
+ await __privateMethod(this, _ShapeStream_instances, checkFastLoop_fn).call(this);
2364
+ } else {
2365
+ __privateSet(this, _fastLoopConsecutiveCount, 0);
2366
+ __privateSet(this, _recentRequestEntries, []);
2367
+ }
2302
2368
  let resumingFromPause = false;
2303
2369
  if (__privateGet(this, _syncState) instanceof PausedState) {
2304
2370
  resumingFromPause = true;
@@ -2357,6 +2423,56 @@ requestShape_fn = async function() {
2357
2423
  (_b = __privateGet(this, _tickPromiseResolver)) == null ? void 0 : _b.call(this);
2358
2424
  return __privateMethod(this, _ShapeStream_instances, requestShape_fn).call(this);
2359
2425
  };
2426
+ checkFastLoop_fn = async function() {
2427
+ const now = Date.now();
2428
+ const currentOffset = __privateGet(this, _syncState).offset;
2429
+ __privateSet(this, _recentRequestEntries, __privateGet(this, _recentRequestEntries).filter(
2430
+ (e) => now - e.timestamp < __privateGet(this, _fastLoopWindowMs)
2431
+ ));
2432
+ __privateGet(this, _recentRequestEntries).push({ timestamp: now, offset: currentOffset });
2433
+ const sameOffsetCount = __privateGet(this, _recentRequestEntries).filter(
2434
+ (e) => e.offset === currentOffset
2435
+ ).length;
2436
+ if (sameOffsetCount < __privateGet(this, _fastLoopThreshold)) return;
2437
+ __privateWrapper(this, _fastLoopConsecutiveCount)._++;
2438
+ if (__privateGet(this, _fastLoopConsecutiveCount) >= __privateGet(this, _fastLoopMaxCount)) {
2439
+ throw new FetchError(
2440
+ 502,
2441
+ void 0,
2442
+ void 0,
2443
+ {},
2444
+ this.options.url,
2445
+ `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:
2446
+ - Proxy is not including query parameters (handle, offset) in its cache key
2447
+ - CDN is serving stale 409 responses
2448
+ - Proxy is stripping required Electric headers from responses
2449
+ For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL}`
2450
+ );
2451
+ }
2452
+ if (__privateGet(this, _fastLoopConsecutiveCount) === 1) {
2453
+ console.warn(
2454
+ `[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}`
2455
+ );
2456
+ if (__privateGet(this, _currentFetchUrl)) {
2457
+ const shapeKey = canonicalShapeKey(__privateGet(this, _currentFetchUrl));
2458
+ expiredShapesCache.delete(shapeKey);
2459
+ upToDateTracker.delete(shapeKey);
2460
+ } else {
2461
+ expiredShapesCache.clear();
2462
+ upToDateTracker.clear();
2463
+ }
2464
+ __privateMethod(this, _ShapeStream_instances, reset_fn).call(this);
2465
+ __privateSet(this, _recentRequestEntries, []);
2466
+ return;
2467
+ }
2468
+ const maxDelay = Math.min(
2469
+ __privateGet(this, _fastLoopBackoffMaxMs),
2470
+ __privateGet(this, _fastLoopBackoffBaseMs) * Math.pow(2, __privateGet(this, _fastLoopConsecutiveCount))
2471
+ );
2472
+ const delayMs = Math.floor(Math.random() * maxDelay);
2473
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
2474
+ __privateSet(this, _recentRequestEntries, []);
2475
+ };
2360
2476
  constructUrl_fn = async function(url, resumingFromPause, subsetParams) {
2361
2477
  var _a, _b, _c, _d, _e, _f;
2362
2478
  const [requestHeaders, params] = await Promise.all([
@@ -2423,7 +2539,7 @@ constructUrl_fn = async function(url, resumingFromPause, subsetParams) {
2423
2539
  if (subsetParams.params)
2424
2540
  fetchUrl.searchParams.set(
2425
2541
  SUBSET_PARAM_WHERE_PARAMS,
2426
- JSON.stringify(subsetParams.params)
2542
+ bigintSafeStringify(subsetParams.params)
2427
2543
  );
2428
2544
  if (subsetParams.limit)
2429
2545
  setQueryParam(fetchUrl, SUBSET_PARAM_LIMIT, subsetParams.limit);
@@ -2507,11 +2623,11 @@ onInitialResponse_fn = async function(response) {
2507
2623
  void 0,
2508
2624
  {},
2509
2625
  (_c = (_b = __privateGet(this, _currentFetchUrl)) == null ? void 0 : _b.toString()) != null ? _c : ``,
2510
- `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`
2626
+ `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}`
2511
2627
  );
2512
2628
  }
2513
2629
  console.warn(
2514
- `[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)}).`
2630
+ `[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)}).`
2515
2631
  );
2516
2632
  throw new StaleCacheError(
2517
2633
  `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.`
@@ -2602,7 +2718,7 @@ requestShapeLongPoll_fn = async function(opts) {
2602
2718
  const messages = res || `[]`;
2603
2719
  const batch = __privateGet(this, _messageParser).parse(messages, schema);
2604
2720
  if (!Array.isArray(batch)) {
2605
- const preview = (_a = JSON.stringify(batch)) == null ? void 0 : _a.slice(0, 200);
2721
+ const preview = (_a = bigintSafeStringify(batch)) == null ? void 0 : _a.slice(0, 200);
2606
2722
  throw new FetchError(
2607
2723
  response.status,
2608
2724
  `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}`,
@@ -2966,7 +3082,7 @@ var Shape = class {
2966
3082
  * Returns void; data will be emitted via the stream and processed by this Shape.
2967
3083
  */
2968
3084
  async requestSnapshot(params) {
2969
- const key = JSON.stringify(params);
3085
+ const key = bigintSafeStringify(params);
2970
3086
  __privateGet(this, _requestedSubSnapshots).add(key);
2971
3087
  await __privateMethod(this, _Shape_instances, awaitUpToDate_fn).call(this);
2972
3088
  await this.stream.requestSnapshot(params);