@electric-sql/client 1.5.6 → 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.6",
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. ` +
@@ -1650,7 +1655,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
1650
1655
  }, 30_000)
1651
1656
 
1652
1657
  try {
1653
- const { metadata, data } = await this.fetchSnapshot(opts)
1658
+ const { metadata, data, responseOffset, responseHandle } =
1659
+ await this.fetchSnapshot(opts)
1654
1660
 
1655
1661
  const dataWithEndBoundary = (data as Array<Message<T>>).concat([
1656
1662
  { headers: { control: `snapshot-end`, ...metadata } },
@@ -1663,6 +1669,31 @@ export class ShapeStream<T extends Row<unknown> = Row>
1663
1669
  )
1664
1670
  this.#onMessages(dataWithEndBoundary, false)
1665
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
+
1666
1697
  return {
1667
1698
  metadata,
1668
1699
  data,
@@ -1682,11 +1713,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
1682
1713
  * `subsetMethod: 'POST'` on the stream to send parameters in the request body instead.
1683
1714
  *
1684
1715
  * @param opts - The options for the snapshot request.
1685
- * @returns The metadata and the data for the snapshot.
1716
+ * @returns The metadata, data, and the response's offset/handle for state advancement.
1686
1717
  */
1687
1718
  async fetchSnapshot(opts: SubsetParams): Promise<{
1688
1719
  metadata: SnapshotMetadata
1689
1720
  data: Array<ChangeMessage<T>>
1721
+ responseOffset: Offset | null
1722
+ responseHandle: string | null
1690
1723
  }> {
1691
1724
  const method = opts.method ?? this.options.subsetMethod ?? `GET`
1692
1725
  const usePost = method === `POST`
@@ -1703,7 +1736,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1703
1736
  ...result.requestHeaders,
1704
1737
  'Content-Type': `application/json`,
1705
1738
  },
1706
- body: JSON.stringify(this.#buildSubsetBody(opts)),
1739
+ body: bigintSafeStringify(this.#buildSubsetBody(opts)),
1707
1740
  }
1708
1741
  } else {
1709
1742
  const result = await this.#constructUrl(this.options.url, true, opts)
@@ -1757,7 +1790,11 @@ export class ShapeStream<T extends Row<unknown> = Row>
1757
1790
  schema
1758
1791
  )
1759
1792
 
1760
- 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 }
1761
1798
  }
1762
1799
 
1763
1800
  #buildSubsetBody(opts: SubsetParams): Record<string, unknown> {
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
  *
@@ -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 */