@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/dist/cjs/index.cjs +61 -8
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +2 -1
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.legacy-esm.js +61 -8
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +61 -8
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +76 -5
- package/src/constants.ts +2 -0
- package/src/error.ts +7 -0
- package/src/shape.ts +2 -2
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1099
|
-
//
|
|
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 =
|
|
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
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<
|
|
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 =
|
|
152
|
+
const subscriptionId = {}
|
|
153
153
|
|
|
154
154
|
this.#subscribers.set(subscriptionId, callback)
|
|
155
155
|
|