@electric-sql/client 1.5.5 → 1.5.7

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.5",
4
+ "version": "1.5.7",
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,
@@ -987,7 +992,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
987
992
  // Serialize params as JSON to keep the parameter name constant for proxy configs
988
993
  fetchUrl.searchParams.set(
989
994
  SUBSET_PARAM_WHERE_PARAMS,
990
- JSON.stringify(subsetParams.params)
995
+ bigintSafeStringify(subsetParams.params)
991
996
  )
992
997
  if (subsetParams.limit)
993
998
  setQueryParam(fetchUrl, SUBSET_PARAM_LIMIT, subsetParams.limit)
@@ -1261,7 +1266,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1261
1266
  const batch = this.#messageParser.parse<Array<Message<T>>>(messages, schema)
1262
1267
 
1263
1268
  if (!Array.isArray(batch)) {
1264
- const preview = JSON.stringify(batch)?.slice(0, 200)
1269
+ const preview = bigintSafeStringify(batch)?.slice(0, 200)
1265
1270
  throw new FetchError(
1266
1271
  response.status,
1267
1272
  `Received non-array response body from shape endpoint. ` +
@@ -1342,7 +1347,18 @@ export class ShapeStream<T extends Row<unknown> = Row>
1342
1347
  // #start handles it correctly.
1343
1348
  throw new FetchBackoffAbortError()
1344
1349
  }
1345
- throw error
1350
+ // Re-throw known Electric errors so the caller can handle them
1351
+ // (e.g., 409 shape rotation, stale cache retry, missing headers).
1352
+ // Other errors (body parsing, SSE protocol failures, null body)
1353
+ // are SSE connection failures handled by the fallback mechanism
1354
+ // in the finally block below.
1355
+ if (
1356
+ error instanceof FetchError ||
1357
+ error instanceof StaleCacheError ||
1358
+ error instanceof MissingHeadersError
1359
+ ) {
1360
+ throw error
1361
+ }
1346
1362
  } finally {
1347
1363
  // Check if the SSE connection closed too quickly
1348
1364
  // This can happen when responses are cached or when the proxy/server
@@ -1639,7 +1655,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
1639
1655
  }, 30_000)
1640
1656
 
1641
1657
  try {
1642
- const { metadata, data } = await this.fetchSnapshot(opts)
1658
+ const { metadata, data, responseOffset, responseHandle } =
1659
+ await this.fetchSnapshot(opts)
1643
1660
 
1644
1661
  const dataWithEndBoundary = (data as Array<Message<T>>).concat([
1645
1662
  { headers: { control: `snapshot-end`, ...metadata } },
@@ -1652,6 +1669,31 @@ export class ShapeStream<T extends Row<unknown> = Row>
1652
1669
  )
1653
1670
  this.#onMessages(dataWithEndBoundary, false)
1654
1671
 
1672
+ // On cold start the stream's offset is still at "now". Advance it
1673
+ // to the snapshot's position so no updates are missed in between.
1674
+ if (responseOffset !== null || responseHandle !== null) {
1675
+ const transition = this.#syncState.handleResponseMetadata({
1676
+ status: 200,
1677
+ responseHandle,
1678
+ responseOffset,
1679
+ responseCursor: null,
1680
+ expiredHandle: null,
1681
+ now: Date.now(),
1682
+ maxStaleCacheRetries: this.#maxStaleCacheRetries,
1683
+ createCacheBuster: () =>
1684
+ `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
1685
+ })
1686
+ if (transition.action === `accepted`) {
1687
+ this.#syncState = transition.state
1688
+ } else {
1689
+ console.warn(
1690
+ `[Electric] Snapshot response metadata was not accepted ` +
1691
+ `by state "${this.#syncState.kind}" (action: ${transition.action}). ` +
1692
+ `Stream offset was not advanced from snapshot.`
1693
+ )
1694
+ }
1695
+ }
1696
+
1655
1697
  return {
1656
1698
  metadata,
1657
1699
  data,
@@ -1671,11 +1713,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
1671
1713
  * `subsetMethod: 'POST'` on the stream to send parameters in the request body instead.
1672
1714
  *
1673
1715
  * @param opts - The options for the snapshot request.
1674
- * @returns The metadata and the data for the snapshot.
1716
+ * @returns The metadata, data, and the response's offset/handle for state advancement.
1675
1717
  */
1676
1718
  async fetchSnapshot(opts: SubsetParams): Promise<{
1677
1719
  metadata: SnapshotMetadata
1678
1720
  data: Array<ChangeMessage<T>>
1721
+ responseOffset: Offset | null
1722
+ responseHandle: string | null
1679
1723
  }> {
1680
1724
  const method = opts.method ?? this.options.subsetMethod ?? `GET`
1681
1725
  const usePost = method === `POST`
@@ -1692,7 +1736,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1692
1736
  ...result.requestHeaders,
1693
1737
  'Content-Type': `application/json`,
1694
1738
  },
1695
- body: JSON.stringify(this.#buildSubsetBody(opts)),
1739
+ body: bigintSafeStringify(this.#buildSubsetBody(opts)),
1696
1740
  }
1697
1741
  } else {
1698
1742
  const result = await this.#constructUrl(this.options.url, true, opts)
@@ -1746,7 +1790,11 @@ export class ShapeStream<T extends Row<unknown> = Row>
1746
1790
  schema
1747
1791
  )
1748
1792
 
1749
- return { metadata, data }
1793
+ const responseOffset =
1794
+ (response.headers.get(CHUNK_LAST_OFFSET_HEADER) as Offset) || null
1795
+ const responseHandle = response.headers.get(SHAPE_HANDLE_HEADER)
1796
+
1797
+ return { metadata, data, responseOffset, responseHandle }
1750
1798
  }
1751
1799
 
1752
1800
  #buildSubsetBody(opts: SubsetParams): Record<string, unknown> {
package/src/fetch.ts CHANGED
@@ -226,6 +226,17 @@ export function createFetchWithChunkBuffer(
226
226
 
227
227
  const prefetchClient = async (...args: Parameters<typeof fetchClient>) => {
228
228
  const url = args[0].toString()
229
+ const method = getRequestMethod(args[0], args[1])
230
+
231
+ // Prefetch is only valid for GET requests. The prefetch queue matches
232
+ // requests by URL alone and ignores HTTP method/body, so a POST request
233
+ // with the same URL would incorrectly consume the prefetched stream
234
+ // response instead of making its own request.
235
+ if (method !== `GET`) {
236
+ prefetchQueue?.abort()
237
+ prefetchQueue = undefined
238
+ return fetchClient(...args)
239
+ }
229
240
 
230
241
  // try to consume from the prefetch queue first, and if request is
231
242
  // not present abort the prefetch queue as it must no longer be valid
@@ -494,3 +505,18 @@ function chainAborter(
494
505
  }
495
506
 
496
507
  function noop() {}
508
+
509
+ function getRequestMethod(
510
+ input: Parameters<typeof fetch>[0],
511
+ init?: Parameters<typeof fetch>[1]
512
+ ): string {
513
+ if (init?.method) {
514
+ return init.method.toUpperCase()
515
+ }
516
+
517
+ if (typeof Request !== `undefined` && input instanceof Request) {
518
+ return input.method.toUpperCase()
519
+ }
520
+
521
+ return `GET`
522
+ }
package/src/helpers.ts CHANGED
@@ -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
  *
@@ -380,6 +380,19 @@ abstract class FetchingState extends ActiveState {
380
380
  if (staleResult) return staleResult
381
381
 
382
382
  const shared = this.parseResponseFields(input)
383
+
384
+ // NOTE: 204s are deprecated, the Electric server should not send these
385
+ // in latest versions but this is here for backwards compatibility.
386
+ // A 204 means "no content, you're caught up" — transition to live.
387
+ // Skip SSE detection: a 204 gives no indication SSE will work, and
388
+ // the 3-attempt fallback cycle adds unnecessary latency.
389
+ if (input.status === 204) {
390
+ return {
391
+ action: `accepted`,
392
+ state: new LiveState(shared, { sseFallbackToLongPolling: true }),
393
+ }
394
+ }
395
+
383
396
  return { action: `accepted`, state: new SyncingState(shared) }
384
397
  }
385
398
 
@@ -691,6 +704,16 @@ export class PausedState extends ShapeStreamState {
691
704
  return this.previousState.replayCursor
692
705
  }
693
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
+
694
717
  withHandle(handle: string): PausedState {
695
718
  return new PausedState(this.previousState.withHandle(handle))
696
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 */