@electric-sql/client 1.4.0 → 1.4.2

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.2",
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
  /**
@@ -534,7 +538,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
534
538
  readonly #messageParser: MessageParser<T>
535
539
 
536
540
  readonly #subscribers = new Map<
537
- number,
541
+ object,
538
542
  [
539
543
  (messages: Message<T>[]) => MaybePromise<void>,
540
544
  ((error: Error) => void) | undefined,
@@ -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. ` +
@@ -1095,8 +1161,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
1095
1161
  const currentCursor = this.#liveCacheBuster
1096
1162
 
1097
1163
  if (currentCursor === this.#lastSeenCursor) {
1098
- // Same cursor = still replaying cached responses
1099
- // Suppress this up-to-date notification
1164
+ // Same cursor as previous session - suppress this up-to-date notification.
1165
+ // Exit replay mode after first suppression to ensure we don't get stuck
1166
+ // if CDN keeps returning the same cursor indefinitely.
1167
+ this.#lastSeenCursor = undefined
1100
1168
  return
1101
1169
  }
1102
1170
  }
@@ -1327,7 +1395,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1327
1395
  callback: (messages: Message<T>[]) => MaybePromise<void>,
1328
1396
  onError: (error: Error) => void = () => {}
1329
1397
  ) {
1330
- const subscriptionId = Math.random()
1398
+ const subscriptionId = {}
1331
1399
 
1332
1400
  this.#subscribers.set(subscriptionId, [callback, onError])
1333
1401
  if (!this.#started) this.#start()
@@ -1490,6 +1558,9 @@ export class ShapeStream<T extends Row<unknown> = Row>
1490
1558
  // Reset SSE fallback state to try SSE again after reset
1491
1559
  this.#consecutiveShortSseConnections = 0
1492
1560
  this.#sseFallbackToLongPolling = false
1561
+ // Reset stale cache retry state
1562
+ this.#staleCacheBuster = undefined
1563
+ this.#staleCacheRetryCount = 0
1493
1564
  }
1494
1565
 
1495
1566
  /**
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
+ }
package/src/shape.ts CHANGED
@@ -51,7 +51,7 @@ export class Shape<T extends Row<unknown> = Row> {
51
51
  readonly stream: ShapeStreamInterface<T>
52
52
 
53
53
  readonly #data: ShapeData<T> = new Map()
54
- readonly #subscribers = new Map<number, ShapeChangedCallback<T>>()
54
+ readonly #subscribers = new Map<object, ShapeChangedCallback<T>>()
55
55
  readonly #insertedKeys = new Set<string>()
56
56
  readonly #requestedSubSnapshots = new Set<string>()
57
57
  #reexecuteSnapshotsPending = false
@@ -149,7 +149,7 @@ export class Shape<T extends Row<unknown> = Row> {
149
149
  }
150
150
 
151
151
  subscribe(callback: ShapeChangedCallback<T>): () => void {
152
- const subscriptionId = Math.random()
152
+ const subscriptionId = {}
153
153
 
154
154
  this.#subscribers.set(subscriptionId, callback)
155
155