@electric-sql/client 1.5.11 → 1.5.13

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/src/client.ts CHANGED
@@ -621,7 +621,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
621
621
  #fastLoopBackoffMaxMs = 5_000
622
622
  #fastLoopConsecutiveCount = 0
623
623
  #fastLoopMaxCount = 5
624
- #refetchCacheBuster?: string
624
+ #pendingRequestShapeCacheBuster?: string
625
+ #maxSnapshotRetries = 5
625
626
 
626
627
  constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
627
628
  this.options = { subscribe: true, ...options }
@@ -801,8 +802,16 @@ export class ShapeStream<T extends Row<unknown> = Row>
801
802
  this.#unsubscribeFromWakeDetection?.()
802
803
  }
803
804
 
804
- async #requestShape(): Promise<void> {
805
- if (this.#pauseLock.isPaused) return
805
+ async #requestShape(requestShapeCacheBuster?: string): Promise<void> {
806
+ const activeCacheBuster =
807
+ requestShapeCacheBuster ?? this.#pendingRequestShapeCacheBuster
808
+
809
+ if (this.#pauseLock.isPaused) {
810
+ if (activeCacheBuster) {
811
+ this.#pendingRequestShapeCacheBuster = activeCacheBuster
812
+ }
813
+ return
814
+ }
806
815
 
807
816
  if (
808
817
  !this.options.subscribe &&
@@ -830,6 +839,11 @@ export class ShapeStream<T extends Row<unknown> = Row>
830
839
  url,
831
840
  resumingFromPause
832
841
  )
842
+
843
+ if (activeCacheBuster) {
844
+ fetchUrl.searchParams.set(CACHE_BUSTER_QUERY_PARAM, activeCacheBuster)
845
+ fetchUrl.searchParams.sort()
846
+ }
833
847
  const abortListener = await this.#createAbortListener(signal)
834
848
  const requestAbortController = this.#requestAbortController! // we know that it is not undefined because it is set by `this.#createAbortListener`
835
849
 
@@ -840,10 +854,15 @@ export class ShapeStream<T extends Row<unknown> = Row>
840
854
  if (abortListener && signal) {
841
855
  signal.removeEventListener(`abort`, abortListener)
842
856
  }
857
+ if (activeCacheBuster) {
858
+ this.#pendingRequestShapeCacheBuster = activeCacheBuster
859
+ }
843
860
  this.#requestAbortController = undefined
844
861
  return
845
862
  }
846
863
 
864
+ this.#pendingRequestShapeCacheBuster = undefined
865
+
847
866
  try {
848
867
  await this.#fetchShape({
849
868
  fetchUrl,
@@ -892,12 +911,14 @@ export class ShapeStream<T extends Row<unknown> = Row>
892
911
  }
893
912
 
894
913
  const newShapeHandle = e.headers[SHAPE_HANDLE_HEADER]
914
+ let nextRequestShapeCacheBuster: string | undefined
895
915
  if (!newShapeHandle) {
896
916
  console.warn(
897
917
  `[Electric] Received 409 response without a shape handle header. ` +
898
- `This likely indicates a proxy or CDN stripping required headers.`
918
+ `This likely indicates a proxy or CDN stripping required headers.`,
919
+ new Error(`stack trace`)
899
920
  )
900
- this.#refetchCacheBuster = createCacheBuster()
921
+ nextRequestShapeCacheBuster = createCacheBuster()
901
922
  }
902
923
  this.#reset(newShapeHandle)
903
924
 
@@ -911,7 +932,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
911
932
  ? [e.json]
912
933
  : []
913
934
  await this.#publish(messages409 as Message<T>[])
914
- return this.#requestShape()
935
+ return this.#requestShape(nextRequestShapeCacheBuster)
915
936
  } else {
916
937
  // errors that have reached this point are not actionable without
917
938
  // additional user input, such as 400s or failures to read the
@@ -984,7 +1005,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
984
1005
  `If this persists, check that your proxy includes all query parameters ` +
985
1006
  `(especially 'handle' and 'offset') in its cache key, ` +
986
1007
  `and that required Electric headers are forwarded to the client. ` +
987
- `For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL}`
1008
+ `For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL}`,
1009
+ new Error(`stack trace`)
988
1010
  )
989
1011
 
990
1012
  if (this.#currentFetchUrl) {
@@ -1155,16 +1177,6 @@ export class ShapeStream<T extends Row<unknown> = Row>
1155
1177
  fetchUrl.searchParams.set(EXPIRED_HANDLE_QUERY_PARAM, expiredHandle)
1156
1178
  }
1157
1179
 
1158
- // Add one-shot cache buster when a 409 response lacked a handle header
1159
- // (e.g. proxy stripped it). Ensures each retry has a unique URL.
1160
- if (this.#refetchCacheBuster) {
1161
- fetchUrl.searchParams.set(
1162
- CACHE_BUSTER_QUERY_PARAM,
1163
- this.#refetchCacheBuster
1164
- )
1165
- this.#refetchCacheBuster = undefined
1166
- }
1167
-
1168
1180
  // sort query params in-place for stable URLs and improved cache hits
1169
1181
  fetchUrl.searchParams.sort()
1170
1182
 
@@ -1247,7 +1259,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
1247
1259
  `The response contained handle "${shapeHandle}" which was previously marked as expired. ` +
1248
1260
  `Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +
1249
1261
  `For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL} ` +
1250
- `Retrying with a random cache buster to bypass the stale cache (attempt ${this.#syncState.staleCacheRetryCount}/${this.#maxStaleCacheRetries}).`
1262
+ `Retrying with a random cache buster to bypass the stale cache (attempt ${this.#syncState.staleCacheRetryCount}/${this.#maxStaleCacheRetries}).`,
1263
+ new Error(`stack trace`)
1251
1264
  )
1252
1265
  throw new StaleCacheError(
1253
1266
  `Received stale cached response with expired handle "${shapeHandle}". ` +
@@ -1257,15 +1270,11 @@ export class ShapeStream<T extends Row<unknown> = Row>
1257
1270
  }
1258
1271
 
1259
1272
  if (transition.action === `ignored`) {
1260
- // We already have a valid handle, so ignore the entire stale response
1261
- // (both metadata and body) to prevent a mismatch between our current
1262
- // handle and the stale data.
1263
1273
  console.warn(
1264
- `[Electric] Received stale cached response with expired shape handle. ` +
1265
- `This should not happen and indicates a proxy/CDN caching misconfiguration. ` +
1266
- `The response contained handle "${shapeHandle}" which was previously marked as expired. ` +
1267
- `Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +
1268
- `Ignoring the stale response and continuing with handle "${this.#syncState.handle}".`
1274
+ `[Electric] Response was ignored by state "${this.#syncState.kind}". ` +
1275
+ `The response body will be skipped. ` +
1276
+ `This may indicate a proxy/CDN caching issue or a client state machine bug.`,
1277
+ new Error(`stack trace`)
1269
1278
  )
1270
1279
  return false
1271
1280
  }
@@ -1277,7 +1286,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
1277
1286
  if (!Array.isArray(batch)) {
1278
1287
  console.warn(
1279
1288
  `[Electric] #onMessages called with non-array argument (${typeof batch}). ` +
1280
- `This is a client bug — please report it.`
1289
+ `This is a client bug — please report it.`,
1290
+ new Error(`stack trace`)
1281
1291
  )
1282
1292
  return
1283
1293
  }
@@ -1503,7 +1513,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
1503
1513
  `Falling back to long polling. ` +
1504
1514
  `Your proxy must support streaming SSE responses (not buffer the complete response). ` +
1505
1515
  `Configuration: Nginx add 'X-Accel-Buffering: no', Caddy add 'flush_interval -1' to reverse_proxy. ` +
1506
- `Note: Do NOT disable caching entirely - Electric uses cache headers to enable request collapsing for efficiency.`
1516
+ `Note: Do NOT disable caching entirely - Electric uses cache headers to enable request collapsing for efficiency.`,
1517
+ new Error(`stack trace`)
1507
1518
  )
1508
1519
  } else if (transition.wasShortConnection) {
1509
1520
  // Exponential backoff with full jitter: random(0, min(cap, base * 2^attempt))
@@ -1776,7 +1787,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
1776
1787
  console.warn(
1777
1788
  `[Electric] Snapshot "${snapshotReason}" has held the pause lock for 30s — ` +
1778
1789
  `possible hung request or leaked lock. ` +
1779
- `Current holders: ${[...new Set([snapshotReason])].join(`, `)}`
1790
+ `Current holders: ${[...new Set([snapshotReason])].join(`, `)}`,
1791
+ new Error(`stack trace`)
1780
1792
  )
1781
1793
  }, 30_000)
1782
1794
 
@@ -1814,7 +1826,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
1814
1826
  console.warn(
1815
1827
  `[Electric] Snapshot response metadata was not accepted ` +
1816
1828
  `by state "${this.#syncState.kind}" (action: ${transition.action}). ` +
1817
- `Stream offset was not advanced from snapshot.`
1829
+ `Stream offset was not advanced from snapshot.`,
1830
+ new Error(`stack trace`)
1818
1831
  )
1819
1832
  }
1820
1833
  }
@@ -1845,6 +1858,19 @@ export class ShapeStream<T extends Row<unknown> = Row>
1845
1858
  data: Array<ChangeMessage<T>>
1846
1859
  responseOffset: Offset | null
1847
1860
  responseHandle: string | null
1861
+ }> {
1862
+ return this.#fetchSnapshotWithRetry(opts, 0)
1863
+ }
1864
+
1865
+ async #fetchSnapshotWithRetry(
1866
+ opts: SubsetParams,
1867
+ retryCount: number,
1868
+ cacheBuster?: string
1869
+ ): Promise<{
1870
+ metadata: SnapshotMetadata
1871
+ data: Array<ChangeMessage<T>>
1872
+ responseOffset: Offset | null
1873
+ responseHandle: string | null
1848
1874
  }> {
1849
1875
  const method = opts.method ?? this.options.subsetMethod ?? `GET`
1850
1876
  const usePost = method === `POST`
@@ -1869,6 +1895,12 @@ export class ShapeStream<T extends Row<unknown> = Row>
1869
1895
  fetchOptions = { headers: result.requestHeaders }
1870
1896
  }
1871
1897
 
1898
+ // Apply cache buster from same-handle 409 retry
1899
+ if (cacheBuster) {
1900
+ fetchUrl.searchParams.set(CACHE_BUSTER_QUERY_PARAM, cacheBuster)
1901
+ fetchUrl.searchParams.sort()
1902
+ }
1903
+
1872
1904
  // Capture handle before fetch to avoid race conditions if it changes during the request
1873
1905
  const usedHandle = this.#syncState.handle
1874
1906
 
@@ -1881,6 +1913,20 @@ export class ShapeStream<T extends Row<unknown> = Row>
1881
1913
  // Unlike #requestShape, we don't call #reset() here as that would
1882
1914
  // clear the pause lock and break requestSnapshot's pause/resume logic.
1883
1915
  if (e instanceof FetchError && e.status === 409) {
1916
+ const nextRetryCount = retryCount + 1
1917
+ if (nextRetryCount > this.#maxSnapshotRetries) {
1918
+ throw new FetchError(
1919
+ 502,
1920
+ undefined,
1921
+ undefined,
1922
+ {},
1923
+ fetchUrl.toString(),
1924
+ `Snapshot request stuck in 409 retry loop after ${this.#maxSnapshotRetries} attempts. ` +
1925
+ `This indicates a proxy/CDN misconfiguration. ` +
1926
+ `For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL}`
1927
+ )
1928
+ }
1929
+
1884
1930
  if (usedHandle) {
1885
1931
  const shapeKey = canonicalShapeKey(fetchUrl)
1886
1932
  expiredShapesCache.markExpired(shapeKey, usedHandle)
@@ -1889,17 +1935,28 @@ export class ShapeStream<T extends Row<unknown> = Row>
1889
1935
  // For snapshot 409s, only update the handle — don't reset offset/schema/etc.
1890
1936
  // The main stream is paused and should not be disturbed.
1891
1937
  const nextHandle = e.headers[SHAPE_HANDLE_HEADER]
1938
+ let nextCacheBuster: string | undefined
1892
1939
  if (nextHandle) {
1893
1940
  this.#syncState = this.#syncState.withHandle(nextHandle)
1941
+ // If 409 returned the same handle, the URL won't change —
1942
+ // pass a cache buster to the next retry to force a unique URL.
1943
+ if (nextHandle === usedHandle) {
1944
+ nextCacheBuster = createCacheBuster()
1945
+ }
1894
1946
  } else {
1895
1947
  console.warn(
1896
1948
  `[Electric] Received 409 response without a shape handle header. ` +
1897
- `This likely indicates a proxy or CDN stripping required headers.`
1949
+ `This likely indicates a proxy or CDN stripping required headers.`,
1950
+ new Error(`stack trace`)
1898
1951
  )
1899
- this.#refetchCacheBuster = createCacheBuster()
1952
+ nextCacheBuster = createCacheBuster()
1900
1953
  }
1901
1954
 
1902
- return this.fetchSnapshot(opts)
1955
+ return this.#fetchSnapshotWithRetry(
1956
+ opts,
1957
+ nextRetryCount,
1958
+ nextCacheBuster
1959
+ )
1903
1960
  }
1904
1961
  throw e
1905
1962
  }
package/src/fetch.ts CHANGED
@@ -2,8 +2,10 @@ import {
2
2
  CHUNK_LAST_OFFSET_HEADER,
3
3
  CHUNK_UP_TO_DATE_HEADER,
4
4
  EXPIRED_HANDLE_QUERY_PARAM,
5
+ LIVE_CACHE_BUSTER_HEADER,
5
6
  LIVE_QUERY_PARAM,
6
7
  OFFSET_QUERY_PARAM,
8
+ SHAPE_SCHEMA_HEADER,
7
9
  SHAPE_HANDLE_HEADER,
8
10
  SHAPE_HANDLE_QUERY_PARAM,
9
11
  SUBSET_PARAM_LIMIT,
@@ -269,13 +271,13 @@ export function createFetchWithChunkBuffer(
269
271
  }
270
272
 
271
273
  export const requiredElectricResponseHeaders = [
272
- `electric-offset`,
273
- `electric-handle`,
274
+ CHUNK_LAST_OFFSET_HEADER,
275
+ SHAPE_HANDLE_HEADER,
274
276
  ]
275
277
 
276
- export const requiredLiveResponseHeaders = [`electric-cursor`]
278
+ export const requiredLiveResponseHeaders = [LIVE_CACHE_BUSTER_HEADER]
277
279
 
278
- export const requiredNonLiveResponseHeaders = [`electric-schema`]
280
+ export const requiredNonLiveResponseHeaders = [SHAPE_SCHEMA_HEADER]
279
281
 
280
282
  export function createFetchWithResponseHeadersCheck(
281
283
  fetchClient: typeof fetch
@@ -303,26 +303,20 @@ abstract class ActiveState extends ShapeStreamState {
303
303
  return null // not stale
304
304
  }
305
305
 
306
- // Stale response detected
307
- if (
308
- this.#shared.handle === undefined ||
309
- this.#shared.handle === expiredHandle
310
- ) {
311
- // No local handle, or local handle is itself the expired one — enter stale retry
312
- const retryCount = this.staleCacheRetryCount + 1
313
- return {
314
- action: `stale-retry`,
315
- state: new StaleRetryState({
316
- ...this.currentFields,
317
- staleCacheBuster: input.createCacheBuster(),
318
- staleCacheRetryCount: retryCount,
319
- }),
320
- exceededMaxRetries: retryCount > input.maxStaleCacheRetries,
321
- }
306
+ // Stale response detected — always enter stale-retry to get a cache buster.
307
+ // Without a cache buster, the CDN will keep serving the same stale response
308
+ // and the client loops infinitely (the URL never changes).
309
+ // currentFields preserves the valid local handle when we already have one.
310
+ const retryCount = this.staleCacheRetryCount + 1
311
+ return {
312
+ action: `stale-retry`,
313
+ state: new StaleRetryState({
314
+ ...this.currentFields,
315
+ staleCacheBuster: input.createCacheBuster(),
316
+ staleCacheRetryCount: retryCount,
317
+ }),
318
+ exceededMaxRetries: retryCount > input.maxStaleCacheRetries,
322
319
  }
323
-
324
- // We have a different valid local handle — ignore this stale response
325
- return { action: `ignored`, state: this }
326
320
  }
327
321
 
328
322
  // --- handleMessageBatch: template method with onUpToDate override point ---