@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/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.6",
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 { getOffset, isUpToDateMessage, isChangeMessage } from './helpers'
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
- JSON.stringify(subsetParams.params)
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: https://electric-sql.com/docs/guides/troubleshooting`
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: https://electric-sql.com/docs/guides/troubleshooting ` +
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 = JSON.stringify(batch)?.slice(0, 200)
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 } = await this.fetchSnapshot(opts)
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 data for the snapshot.
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: JSON.stringify(this.#buildSubsetBody(opts)),
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
- return { metadata, data }
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> {
@@ -66,6 +66,11 @@ export class ExpiredShapesCache {
66
66
  this.data = {}
67
67
  this.save()
68
68
  }
69
+
70
+ delete(shapeUrl: string): void {
71
+ delete this.data[shapeUrl]
72
+ this.save()
73
+ }
69
74
  }
70
75
 
71
76
  // Module-level singleton instance
package/src/fetch.ts CHANGED
@@ -44,9 +44,9 @@ export interface BackoffOptions {
44
44
  }
45
45
 
46
46
  export const BackoffDefaults = {
47
- initialDelay: 100,
48
- maxDelay: 60_000, // Cap at 60s - reasonable for long-lived connections
49
- multiplier: 1.3,
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 && !isChangeMessage(message)
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 { isChangeMessage, isControlMessage } from './helpers'
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 = JSON.stringify(params)
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 */
@@ -151,6 +151,11 @@ export class UpToDateTracker {
151
151
  }
152
152
  this.save()
153
153
  }
154
+
155
+ delete(shapeKey: string): void {
156
+ delete this.data[shapeKey]
157
+ this.save()
158
+ }
154
159
  }
155
160
 
156
161
  // Module-level singleton instance