@electric-sql/client 1.4.0 → 1.4.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@electric-sql/client",
3
3
  "description": "Postgres everywhere - your data, in sync, wherever you need it.",
4
- "version": "1.4.0",
4
+ "version": "1.4.1",
5
5
  "author": "ElectricSQL team and contributors.",
6
6
  "bugs": {
7
7
  "url": "https://github.com/electric-sql/electric/issues"
package/src/client.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  MissingShapeHandleError,
25
25
  ReservedParamError,
26
26
  MissingHeadersError,
27
+ StaleCacheError,
27
28
  } from './error'
28
29
  import {
29
30
  BackoffDefaults,
@@ -61,6 +62,7 @@ import {
61
62
  SUBSET_PARAM_ORDER_BY,
62
63
  SUBSET_PARAM_WHERE_EXPR,
63
64
  SUBSET_PARAM_ORDER_BY_EXPR,
65
+ CACHE_BUSTER_QUERY_PARAM,
64
66
  } from './constants'
65
67
  import { compileExpression, compileOrderBy } from './expression-compiler'
66
68
  import {
@@ -76,6 +78,7 @@ const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
76
78
  SHAPE_HANDLE_QUERY_PARAM,
77
79
  LIVE_QUERY_PARAM,
78
80
  OFFSET_QUERY_PARAM,
81
+ CACHE_BUSTER_QUERY_PARAM,
79
82
  ])
80
83
 
81
84
  type Replica = `full` | `default`
@@ -141,6 +144,7 @@ type ReservedParamKeys =
141
144
  | typeof SHAPE_HANDLE_QUERY_PARAM
142
145
  | typeof LIVE_QUERY_PARAM
143
146
  | typeof OFFSET_QUERY_PARAM
147
+ | typeof CACHE_BUSTER_QUERY_PARAM
144
148
  | `subset__${string}`
145
149
 
146
150
  /**
@@ -573,6 +577,9 @@ export class ShapeStream<T extends Row<unknown> = Row>
573
577
  #sseBackoffBaseDelay = 100 // Base delay for exponential backoff (ms)
574
578
  #sseBackoffMaxDelay = 5000 // Maximum delay cap (ms)
575
579
  #unsubscribeFromVisibilityChanges?: () => void
580
+ #staleCacheBuster?: string // Cache buster set when stale CDN response detected, used on retry requests to bypass cache
581
+ #staleCacheRetryCount = 0
582
+ #maxStaleCacheRetries = 3
576
583
 
577
584
  // Derived state: we're in replay mode if we have a last seen cursor
578
585
  get #replayMode(): boolean {
@@ -672,7 +679,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
672
679
  if (this.#onError) {
673
680
  const retryOpts = await this.#onError(err as Error)
674
681
  // Guard against null (typeof null === "object" in JavaScript)
675
- if (retryOpts && typeof retryOpts === `object`) {
682
+ const isRetryable = !(err instanceof MissingHeadersError)
683
+ if (retryOpts && typeof retryOpts === `object` && isRetryable) {
676
684
  // Update params/headers but don't reset offset
677
685
  // We want to continue from where we left off, not refetch everything
678
686
  if (retryOpts.params) {
@@ -783,6 +791,15 @@ export class ShapeStream<T extends Row<unknown> = Row>
783
791
  }
784
792
  return // interrupted
785
793
  }
794
+
795
+ if (e instanceof StaleCacheError) {
796
+ // Received a stale cached response from CDN with an expired handle.
797
+ // The #staleCacheBuster has been set in #onInitialResponse, so retry
798
+ // the request which will include a random cache buster to bypass the
799
+ // misconfigured CDN cache.
800
+ return this.#requestShape()
801
+ }
802
+
786
803
  if (!(e instanceof FetchError)) throw e // should never happen
787
804
 
788
805
  if (e.status == 409) {
@@ -985,6 +1002,15 @@ export class ShapeStream<T extends Row<unknown> = Row>
985
1002
  fetchUrl.searchParams.set(EXPIRED_HANDLE_QUERY_PARAM, expiredHandle)
986
1003
  }
987
1004
 
1005
+ // Add random cache buster if we received a stale response from CDN
1006
+ // This forces a fresh request bypassing the misconfigured CDN cache
1007
+ if (this.#staleCacheBuster) {
1008
+ fetchUrl.searchParams.set(
1009
+ CACHE_BUSTER_QUERY_PARAM,
1010
+ this.#staleCacheBuster
1011
+ )
1012
+ }
1013
+
988
1014
  // sort query params in-place for stable URLs and improved cache hits
989
1015
  fetchUrl.searchParams.sort()
990
1016
 
@@ -1030,6 +1056,46 @@ export class ShapeStream<T extends Row<unknown> = Row>
1030
1056
  : null
1031
1057
  if (shapeHandle !== expiredHandle) {
1032
1058
  this.#shapeHandle = shapeHandle
1059
+ // Clear cache buster after successful response with valid handle
1060
+ if (this.#staleCacheBuster) {
1061
+ this.#staleCacheBuster = undefined
1062
+ this.#staleCacheRetryCount = 0
1063
+ }
1064
+ } else if (this.#shapeHandle === undefined) {
1065
+ // We received a stale response from cache and don't have a handle yet.
1066
+ // Instead of accepting the stale handle, throw an error to trigger a retry
1067
+ // with a random cache buster to bypass the CDN cache.
1068
+ this.#staleCacheRetryCount++
1069
+ // Cancel the response body to release the connection before retrying
1070
+ await response.body?.cancel()
1071
+ if (this.#staleCacheRetryCount > this.#maxStaleCacheRetries) {
1072
+ throw new FetchError(
1073
+ 502,
1074
+ undefined,
1075
+ undefined,
1076
+ {},
1077
+ this.#currentFetchUrl?.toString() ?? ``,
1078
+ `CDN continues serving stale cached responses after ${this.#maxStaleCacheRetries} retry attempts. ` +
1079
+ `This indicates a severe proxy/CDN misconfiguration. ` +
1080
+ `Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +
1081
+ `For more information visit the troubleshooting guide: https://electric-sql.com/docs/guides/troubleshooting`
1082
+ )
1083
+ }
1084
+ console.warn(
1085
+ `[Electric] Received stale cached response with expired shape handle. ` +
1086
+ `This should not happen and indicates a proxy/CDN caching misconfiguration. ` +
1087
+ `The response contained handle "${shapeHandle}" which was previously marked as expired. ` +
1088
+ `Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +
1089
+ `For more information visit the troubleshooting guide: https://electric-sql.com/docs/guides/troubleshooting ` +
1090
+ `Retrying with a random cache buster to bypass the stale cache (attempt ${this.#staleCacheRetryCount}/${this.#maxStaleCacheRetries}).`
1091
+ )
1092
+ // Generate a random cache buster for the retry
1093
+ this.#staleCacheBuster = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
1094
+ throw new StaleCacheError(
1095
+ `Received stale cached response with expired handle "${shapeHandle}". ` +
1096
+ `This indicates a proxy/CDN caching misconfiguration. ` +
1097
+ `Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key.`
1098
+ )
1033
1099
  } else {
1034
1100
  console.warn(
1035
1101
  `[Electric] Received stale cached response with expired shape handle. ` +
@@ -1490,6 +1556,9 @@ export class ShapeStream<T extends Row<unknown> = Row>
1490
1556
  // Reset SSE fallback state to try SSE again after reset
1491
1557
  this.#consecutiveShortSseConnections = 0
1492
1558
  this.#sseFallbackToLongPolling = false
1559
+ // Reset stale cache retry state
1560
+ this.#staleCacheBuster = undefined
1561
+ this.#staleCacheRetryCount = 0
1493
1562
  }
1494
1563
 
1495
1564
  /**
package/src/constants.ts CHANGED
@@ -28,6 +28,7 @@ export const SUBSET_PARAM_ORDER_BY = `subset__order_by`
28
28
  export const SUBSET_PARAM_WHERE_PARAMS = `subset__params`
29
29
  export const SUBSET_PARAM_WHERE_EXPR = `subset__where_expr`
30
30
  export const SUBSET_PARAM_ORDER_BY_EXPR = `subset__order_by_expr`
31
+ export const CACHE_BUSTER_QUERY_PARAM = `cache-buster` // Random cache buster to bypass stale CDN responses
31
32
 
32
33
  // Query parameters that should be passed through when proxying Electric requests
33
34
  export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
@@ -45,4 +46,5 @@ export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
45
46
  SUBSET_PARAM_WHERE_PARAMS,
46
47
  SUBSET_PARAM_WHERE_EXPR,
47
48
  SUBSET_PARAM_ORDER_BY_EXPR,
49
+ CACHE_BUSTER_QUERY_PARAM,
48
50
  ]
package/src/error.ts CHANGED
@@ -116,3 +116,10 @@ export class MissingHeadersError extends Error {
116
116
  super(msg)
117
117
  }
118
118
  }
119
+
120
+ export class StaleCacheError extends Error {
121
+ constructor(message: string) {
122
+ super(message)
123
+ this.name = `StaleCacheError`
124
+ }
125
+ }