@electric-sql/client 1.5.6 → 1.5.8
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 +131 -15
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +4 -2
- package/dist/index.browser.mjs +8 -4
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.legacy-esm.js +131 -15
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +131 -15
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +149 -9
- package/src/expired-shapes-cache.ts +5 -0
- package/src/fetch.ts +3 -3
- package/src/helpers.ts +16 -1
- package/src/shape-stream-state.ts +10 -0
- package/src/shape.ts +6 -2
- package/src/types.ts +1 -1
- package/src/up-to-date-tracker.ts +5 -0
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.8",
|
|
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
|
@@ -15,7 +15,12 @@ import {
|
|
|
15
15
|
encodeWhereClause,
|
|
16
16
|
quoteIdentifier,
|
|
17
17
|
} from './column-mapper'
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
getOffset,
|
|
20
|
+
isUpToDateMessage,
|
|
21
|
+
isChangeMessage,
|
|
22
|
+
bigintSafeStringify,
|
|
23
|
+
} from './helpers'
|
|
19
24
|
import {
|
|
20
25
|
FetchError,
|
|
21
26
|
FetchBackoffAbortError,
|
|
@@ -89,6 +94,8 @@ const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
|
|
|
89
94
|
CACHE_BUSTER_QUERY_PARAM,
|
|
90
95
|
])
|
|
91
96
|
|
|
97
|
+
const TROUBLESHOOTING_URL = `https://electric-sql.com/docs/guides/troubleshooting`
|
|
98
|
+
|
|
92
99
|
type Replica = `full` | `default`
|
|
93
100
|
export type LogMode = `changes_only` | `full`
|
|
94
101
|
|
|
@@ -601,6 +608,15 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
601
608
|
#unsubscribeFromVisibilityChanges?: () => void
|
|
602
609
|
#unsubscribeFromWakeDetection?: () => void
|
|
603
610
|
#maxStaleCacheRetries = 3
|
|
611
|
+
// Fast-loop detection: track recent non-live requests to detect tight retry
|
|
612
|
+
// loops caused by proxy/CDN misconfiguration or stale client-side caches
|
|
613
|
+
#recentRequestEntries: Array<{ timestamp: number; offset: string }> = []
|
|
614
|
+
#fastLoopWindowMs = 500
|
|
615
|
+
#fastLoopThreshold = 5
|
|
616
|
+
#fastLoopBackoffBaseMs = 100
|
|
617
|
+
#fastLoopBackoffMaxMs = 5_000
|
|
618
|
+
#fastLoopConsecutiveCount = 0
|
|
619
|
+
#fastLoopMaxCount = 5
|
|
604
620
|
|
|
605
621
|
constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
|
|
606
622
|
this.options = { subscribe: true, ...options }
|
|
@@ -745,6 +761,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
745
761
|
if (this.#syncState instanceof ErrorState) {
|
|
746
762
|
this.#syncState = this.#syncState.retry()
|
|
747
763
|
}
|
|
764
|
+
this.#fastLoopConsecutiveCount = 0
|
|
765
|
+
this.#recentRequestEntries = []
|
|
748
766
|
|
|
749
767
|
// Restart from current offset
|
|
750
768
|
this.#started = false
|
|
@@ -788,6 +806,14 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
788
806
|
return
|
|
789
807
|
}
|
|
790
808
|
|
|
809
|
+
// Only check for fast loops on non-live requests; live polling is expected to be rapid
|
|
810
|
+
if (!this.#syncState.isUpToDate) {
|
|
811
|
+
await this.#checkFastLoop()
|
|
812
|
+
} else {
|
|
813
|
+
this.#fastLoopConsecutiveCount = 0
|
|
814
|
+
this.#recentRequestEntries = []
|
|
815
|
+
}
|
|
816
|
+
|
|
791
817
|
let resumingFromPause = false
|
|
792
818
|
if (this.#syncState instanceof PausedState) {
|
|
793
819
|
resumingFromPause = true
|
|
@@ -893,6 +919,88 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
893
919
|
return this.#requestShape()
|
|
894
920
|
}
|
|
895
921
|
|
|
922
|
+
/**
|
|
923
|
+
* Detects tight retry loops (e.g., from stale client-side caches or
|
|
924
|
+
* proxy/CDN misconfiguration) and attempts recovery. On first detection,
|
|
925
|
+
* clears client-side caches (in-memory and localStorage) and resets the
|
|
926
|
+
* stream to fetch from scratch.
|
|
927
|
+
* If the loop persists, applies exponential backoff and eventually throws.
|
|
928
|
+
*/
|
|
929
|
+
async #checkFastLoop(): Promise<void> {
|
|
930
|
+
const now = Date.now()
|
|
931
|
+
const currentOffset = this.#syncState.offset
|
|
932
|
+
|
|
933
|
+
this.#recentRequestEntries = this.#recentRequestEntries.filter(
|
|
934
|
+
(e) => now - e.timestamp < this.#fastLoopWindowMs
|
|
935
|
+
)
|
|
936
|
+
this.#recentRequestEntries.push({ timestamp: now, offset: currentOffset })
|
|
937
|
+
|
|
938
|
+
// Only flag as a fast loop if requests are stuck at the same offset.
|
|
939
|
+
// Normal rapid syncing advances the offset with each response.
|
|
940
|
+
const sameOffsetCount = this.#recentRequestEntries.filter(
|
|
941
|
+
(e) => e.offset === currentOffset
|
|
942
|
+
).length
|
|
943
|
+
|
|
944
|
+
if (sameOffsetCount < this.#fastLoopThreshold) return
|
|
945
|
+
|
|
946
|
+
this.#fastLoopConsecutiveCount++
|
|
947
|
+
|
|
948
|
+
if (this.#fastLoopConsecutiveCount >= this.#fastLoopMaxCount) {
|
|
949
|
+
throw new FetchError(
|
|
950
|
+
502,
|
|
951
|
+
undefined,
|
|
952
|
+
undefined,
|
|
953
|
+
{},
|
|
954
|
+
this.options.url,
|
|
955
|
+
`Client is stuck in a fast retry loop ` +
|
|
956
|
+
`(${this.#fastLoopThreshold} requests in ${this.#fastLoopWindowMs}ms at the same offset, ` +
|
|
957
|
+
`repeated ${this.#fastLoopMaxCount} times). ` +
|
|
958
|
+
`Client-side caches were cleared automatically on first detection, but the loop persists. ` +
|
|
959
|
+
`This usually indicates a proxy or CDN misconfiguration. ` +
|
|
960
|
+
`Common causes:\n` +
|
|
961
|
+
` - Proxy is not including query parameters (handle, offset) in its cache key\n` +
|
|
962
|
+
` - CDN is serving stale 409 responses\n` +
|
|
963
|
+
` - Proxy is stripping required Electric headers from responses\n` +
|
|
964
|
+
`For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL}`
|
|
965
|
+
)
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
if (this.#fastLoopConsecutiveCount === 1) {
|
|
969
|
+
console.warn(
|
|
970
|
+
`[Electric] Detected fast retry loop ` +
|
|
971
|
+
`(${this.#fastLoopThreshold} requests in ${this.#fastLoopWindowMs}ms at the same offset). ` +
|
|
972
|
+
`Clearing client-side caches and resetting stream to recover. ` +
|
|
973
|
+
`If this persists, check that your proxy includes all query parameters ` +
|
|
974
|
+
`(especially 'handle' and 'offset') in its cache key, ` +
|
|
975
|
+
`and that required Electric headers are forwarded to the client. ` +
|
|
976
|
+
`For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL}`
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
if (this.#currentFetchUrl) {
|
|
980
|
+
const shapeKey = canonicalShapeKey(this.#currentFetchUrl)
|
|
981
|
+
expiredShapesCache.delete(shapeKey)
|
|
982
|
+
upToDateTracker.delete(shapeKey)
|
|
983
|
+
} else {
|
|
984
|
+
expiredShapesCache.clear()
|
|
985
|
+
upToDateTracker.clear()
|
|
986
|
+
}
|
|
987
|
+
this.#reset()
|
|
988
|
+
this.#recentRequestEntries = []
|
|
989
|
+
return
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Exponential backoff with full jitter
|
|
993
|
+
const maxDelay = Math.min(
|
|
994
|
+
this.#fastLoopBackoffMaxMs,
|
|
995
|
+
this.#fastLoopBackoffBaseMs * Math.pow(2, this.#fastLoopConsecutiveCount)
|
|
996
|
+
)
|
|
997
|
+
const delayMs = Math.floor(Math.random() * maxDelay)
|
|
998
|
+
|
|
999
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
|
1000
|
+
|
|
1001
|
+
this.#recentRequestEntries = []
|
|
1002
|
+
}
|
|
1003
|
+
|
|
896
1004
|
async #constructUrl(
|
|
897
1005
|
url: string,
|
|
898
1006
|
resumingFromPause: boolean,
|
|
@@ -987,7 +1095,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
987
1095
|
// Serialize params as JSON to keep the parameter name constant for proxy configs
|
|
988
1096
|
fetchUrl.searchParams.set(
|
|
989
1097
|
SUBSET_PARAM_WHERE_PARAMS,
|
|
990
|
-
|
|
1098
|
+
bigintSafeStringify(subsetParams.params)
|
|
991
1099
|
)
|
|
992
1100
|
if (subsetParams.limit)
|
|
993
1101
|
setQueryParam(fetchUrl, SUBSET_PARAM_LIMIT, subsetParams.limit)
|
|
@@ -1110,7 +1218,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1110
1218
|
`CDN continues serving stale cached responses after ${this.#maxStaleCacheRetries} retry attempts. ` +
|
|
1111
1219
|
`This indicates a severe proxy/CDN misconfiguration. ` +
|
|
1112
1220
|
`Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +
|
|
1113
|
-
`For more information visit the troubleshooting guide:
|
|
1221
|
+
`For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL}`
|
|
1114
1222
|
)
|
|
1115
1223
|
}
|
|
1116
1224
|
console.warn(
|
|
@@ -1118,7 +1226,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1118
1226
|
`This should not happen and indicates a proxy/CDN caching misconfiguration. ` +
|
|
1119
1227
|
`The response contained handle "${shapeHandle}" which was previously marked as expired. ` +
|
|
1120
1228
|
`Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +
|
|
1121
|
-
`For more information visit the troubleshooting guide:
|
|
1229
|
+
`For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL} ` +
|
|
1122
1230
|
`Retrying with a random cache buster to bypass the stale cache (attempt ${this.#syncState.staleCacheRetryCount}/${this.#maxStaleCacheRetries}).`
|
|
1123
1231
|
)
|
|
1124
1232
|
throw new StaleCacheError(
|
|
@@ -1261,7 +1369,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1261
1369
|
const batch = this.#messageParser.parse<Array<Message<T>>>(messages, schema)
|
|
1262
1370
|
|
|
1263
1371
|
if (!Array.isArray(batch)) {
|
|
1264
|
-
const preview =
|
|
1372
|
+
const preview = bigintSafeStringify(batch)?.slice(0, 200)
|
|
1265
1373
|
throw new FetchError(
|
|
1266
1374
|
response.status,
|
|
1267
1375
|
`Received non-array response body from shape endpoint. ` +
|
|
@@ -1650,7 +1758,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1650
1758
|
}, 30_000)
|
|
1651
1759
|
|
|
1652
1760
|
try {
|
|
1653
|
-
const { metadata, data } =
|
|
1761
|
+
const { metadata, data, responseOffset, responseHandle } =
|
|
1762
|
+
await this.fetchSnapshot(opts)
|
|
1654
1763
|
|
|
1655
1764
|
const dataWithEndBoundary = (data as Array<Message<T>>).concat([
|
|
1656
1765
|
{ headers: { control: `snapshot-end`, ...metadata } },
|
|
@@ -1663,6 +1772,31 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1663
1772
|
)
|
|
1664
1773
|
this.#onMessages(dataWithEndBoundary, false)
|
|
1665
1774
|
|
|
1775
|
+
// On cold start the stream's offset is still at "now". Advance it
|
|
1776
|
+
// to the snapshot's position so no updates are missed in between.
|
|
1777
|
+
if (responseOffset !== null || responseHandle !== null) {
|
|
1778
|
+
const transition = this.#syncState.handleResponseMetadata({
|
|
1779
|
+
status: 200,
|
|
1780
|
+
responseHandle,
|
|
1781
|
+
responseOffset,
|
|
1782
|
+
responseCursor: null,
|
|
1783
|
+
expiredHandle: null,
|
|
1784
|
+
now: Date.now(),
|
|
1785
|
+
maxStaleCacheRetries: this.#maxStaleCacheRetries,
|
|
1786
|
+
createCacheBuster: () =>
|
|
1787
|
+
`${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
|
1788
|
+
})
|
|
1789
|
+
if (transition.action === `accepted`) {
|
|
1790
|
+
this.#syncState = transition.state
|
|
1791
|
+
} else {
|
|
1792
|
+
console.warn(
|
|
1793
|
+
`[Electric] Snapshot response metadata was not accepted ` +
|
|
1794
|
+
`by state "${this.#syncState.kind}" (action: ${transition.action}). ` +
|
|
1795
|
+
`Stream offset was not advanced from snapshot.`
|
|
1796
|
+
)
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1666
1800
|
return {
|
|
1667
1801
|
metadata,
|
|
1668
1802
|
data,
|
|
@@ -1682,11 +1816,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1682
1816
|
* `subsetMethod: 'POST'` on the stream to send parameters in the request body instead.
|
|
1683
1817
|
*
|
|
1684
1818
|
* @param opts - The options for the snapshot request.
|
|
1685
|
-
* @returns The metadata and the
|
|
1819
|
+
* @returns The metadata, data, and the response's offset/handle for state advancement.
|
|
1686
1820
|
*/
|
|
1687
1821
|
async fetchSnapshot(opts: SubsetParams): Promise<{
|
|
1688
1822
|
metadata: SnapshotMetadata
|
|
1689
1823
|
data: Array<ChangeMessage<T>>
|
|
1824
|
+
responseOffset: Offset | null
|
|
1825
|
+
responseHandle: string | null
|
|
1690
1826
|
}> {
|
|
1691
1827
|
const method = opts.method ?? this.options.subsetMethod ?? `GET`
|
|
1692
1828
|
const usePost = method === `POST`
|
|
@@ -1703,7 +1839,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1703
1839
|
...result.requestHeaders,
|
|
1704
1840
|
'Content-Type': `application/json`,
|
|
1705
1841
|
},
|
|
1706
|
-
body:
|
|
1842
|
+
body: bigintSafeStringify(this.#buildSubsetBody(opts)),
|
|
1707
1843
|
}
|
|
1708
1844
|
} else {
|
|
1709
1845
|
const result = await this.#constructUrl(this.options.url, true, opts)
|
|
@@ -1757,7 +1893,11 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1757
1893
|
schema
|
|
1758
1894
|
)
|
|
1759
1895
|
|
|
1760
|
-
|
|
1896
|
+
const responseOffset =
|
|
1897
|
+
(response.headers.get(CHUNK_LAST_OFFSET_HEADER) as Offset) || null
|
|
1898
|
+
const responseHandle = response.headers.get(SHAPE_HANDLE_HEADER)
|
|
1899
|
+
|
|
1900
|
+
return { metadata, data, responseOffset, responseHandle }
|
|
1761
1901
|
}
|
|
1762
1902
|
|
|
1763
1903
|
#buildSubsetBody(opts: SubsetParams): Record<string, unknown> {
|
package/src/fetch.ts
CHANGED
|
@@ -44,9 +44,9 @@ export interface BackoffOptions {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export const BackoffDefaults = {
|
|
47
|
-
initialDelay:
|
|
48
|
-
maxDelay:
|
|
49
|
-
multiplier:
|
|
47
|
+
initialDelay: 1_000,
|
|
48
|
+
maxDelay: 32_000,
|
|
49
|
+
multiplier: 2,
|
|
50
50
|
maxRetries: Infinity, // Retry forever - clients may go offline and come back
|
|
51
51
|
}
|
|
52
52
|
|
package/src/helpers.ts
CHANGED
|
@@ -51,7 +51,7 @@ export function isChangeMessage<T extends Row<unknown> = Row>(
|
|
|
51
51
|
export function isControlMessage<T extends Row<unknown> = Row>(
|
|
52
52
|
message: Message<T>
|
|
53
53
|
): message is ControlMessage {
|
|
54
|
-
return message != null &&
|
|
54
|
+
return message != null && `headers` in message && `control` in message.headers
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export function isUpToDateMessage<T extends Row<unknown> = Row>(
|
|
@@ -71,6 +71,21 @@ export function getOffset(message: ControlMessage): Offset | undefined {
|
|
|
71
71
|
return lsn ? (`${lsn}_0` as Offset) : undefined
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
function bigintReplacer(_key: string, value: unknown): unknown {
|
|
75
|
+
return typeof value === `bigint` ? value.toString() : value
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* BigInt-safe version of JSON.stringify.
|
|
80
|
+
* Converts BigInt values to their string representation (as JSON strings,
|
|
81
|
+
* e.g. `{ id: 42n }` becomes `{"id":"42"}`) instead of throwing.
|
|
82
|
+
* Assumes input is a JSON-serializable value — passing `undefined` at the
|
|
83
|
+
* top level will return `undefined` (matching `JSON.stringify` behavior).
|
|
84
|
+
*/
|
|
85
|
+
export function bigintSafeStringify(value: unknown): string {
|
|
86
|
+
return JSON.stringify(value, bigintReplacer)
|
|
87
|
+
}
|
|
88
|
+
|
|
74
89
|
/**
|
|
75
90
|
* Checks if a transaction is visible in a snapshot.
|
|
76
91
|
*
|
|
@@ -704,6 +704,16 @@ export class PausedState extends ShapeStreamState {
|
|
|
704
704
|
return this.previousState.replayCursor
|
|
705
705
|
}
|
|
706
706
|
|
|
707
|
+
handleResponseMetadata(
|
|
708
|
+
input: ResponseMetadataInput
|
|
709
|
+
): ResponseMetadataTransition {
|
|
710
|
+
const transition = this.previousState.handleResponseMetadata(input)
|
|
711
|
+
if (transition.action === `accepted`) {
|
|
712
|
+
return { action: `accepted`, state: new PausedState(transition.state) }
|
|
713
|
+
}
|
|
714
|
+
return transition
|
|
715
|
+
}
|
|
716
|
+
|
|
707
717
|
withHandle(handle: string): PausedState {
|
|
708
718
|
return new PausedState(this.previousState.withHandle(handle))
|
|
709
719
|
}
|
package/src/shape.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { Message, Offset, Row } from './types'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
isChangeMessage,
|
|
4
|
+
isControlMessage,
|
|
5
|
+
bigintSafeStringify,
|
|
6
|
+
} from './helpers'
|
|
3
7
|
import { FetchError } from './error'
|
|
4
8
|
import { LogMode, ShapeStreamInterface } from './client'
|
|
5
9
|
|
|
@@ -141,7 +145,7 @@ export class Shape<T extends Row<unknown> = Row> {
|
|
|
141
145
|
params: Parameters<ShapeStreamInterface<T>[`requestSnapshot`]>[0]
|
|
142
146
|
): Promise<void> {
|
|
143
147
|
// Track this snapshot request for future re-execution on shape rotation
|
|
144
|
-
const key =
|
|
148
|
+
const key = bigintSafeStringify(params)
|
|
145
149
|
this.#requestedSubSnapshots.add(key)
|
|
146
150
|
// Ensure the stream is up-to-date so schema is available for parsing
|
|
147
151
|
await this.#awaitUpToDate()
|
package/src/types.ts
CHANGED
|
@@ -91,7 +91,7 @@ export type SubsetParams = {
|
|
|
91
91
|
/** Legacy string format WHERE clause */
|
|
92
92
|
where?: string
|
|
93
93
|
/** Positional parameter values for WHERE clause */
|
|
94
|
-
params?: Record<string, string>
|
|
94
|
+
params?: Record<string, string | bigint | number>
|
|
95
95
|
/** Maximum number of rows to return */
|
|
96
96
|
limit?: number
|
|
97
97
|
/** Number of rows to skip */
|