@electric-sql/client 1.5.12 → 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/bin/analyze-pr-risks.mjs +81 -0
- package/bin/analyze-shape-stream-risks.mjs +20 -0
- package/bin/lib/shape-stream-static-analysis.mjs +1067 -0
- package/dist/cjs/index.cjs +134 -96
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/index.browser.mjs +4 -4
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.legacy-esm.js +134 -96
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +134 -96
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -1
- package/src/client.ts +90 -33
- package/src/fetch.ts +6 -4
- package/src/shape-stream-state.ts +13 -19
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.5.
|
|
4
|
+
"version": "1.5.13",
|
|
5
5
|
"author": "ElectricSQL team and contributors.",
|
|
6
6
|
"bugs": {
|
|
7
7
|
"url": "https://github.com/electric-sql/electric/issues"
|
|
@@ -77,6 +77,8 @@
|
|
|
77
77
|
}
|
|
78
78
|
},
|
|
79
79
|
"scripts": {
|
|
80
|
+
"analyze:pr": "node ./bin/analyze-pr-risks.mjs",
|
|
81
|
+
"analyze:shape-stream": "node ./bin/analyze-shape-stream-risks.mjs",
|
|
80
82
|
"build": "shx rm -rf dist && tsup && tsc -p tsconfig.build.json",
|
|
81
83
|
"format": "eslint . --fix",
|
|
82
84
|
"stylecheck": "eslint . --quiet",
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
1265
|
-
`
|
|
1266
|
-
`
|
|
1267
|
-
|
|
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
|
-
|
|
1952
|
+
nextCacheBuster = createCacheBuster()
|
|
1900
1953
|
}
|
|
1901
1954
|
|
|
1902
|
-
return this
|
|
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
|
-
|
|
273
|
-
|
|
274
|
+
CHUNK_LAST_OFFSET_HEADER,
|
|
275
|
+
SHAPE_HANDLE_HEADER,
|
|
274
276
|
]
|
|
275
277
|
|
|
276
|
-
export const requiredLiveResponseHeaders = [
|
|
278
|
+
export const requiredLiveResponseHeaders = [LIVE_CACHE_BUSTER_HEADER]
|
|
277
279
|
|
|
278
|
-
export const requiredNonLiveResponseHeaders = [
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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 ---
|