@electric-sql/client 1.4.2 → 1.5.1

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.4.2",
4
+ "version": "1.5.1",
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
@@ -429,6 +429,27 @@ export interface ShapeStreamOptions<T = never> {
429
429
  * ```
430
430
  */
431
431
  onError?: ShapeStreamErrorHandler
432
+
433
+ /**
434
+ * HTTP method to use for subset snapshot requests (`requestSnapshot`/`fetchSnapshot`).
435
+ *
436
+ * - `'GET'` (default): Sends subset params as URL query parameters. May fail with
437
+ * HTTP 414 errors for large queries with many parameters.
438
+ * - `'POST'`: Sends subset params in request body as JSON. Recommended for queries
439
+ * with large parameter lists (e.g., `WHERE id = ANY($1)` with hundreds of IDs).
440
+ *
441
+ * This can be overridden per-request by passing `method` in the subset params.
442
+ *
443
+ * @example
444
+ * ```typescript
445
+ * const stream = new ShapeStream({
446
+ * url: 'http://localhost:3000/v1/shape',
447
+ * params: { table: 'items' },
448
+ * subsetMethod: 'POST', // Use POST for all subset requests
449
+ * })
450
+ * ```
451
+ */
452
+ subsetMethod?: `GET` | `POST`
432
453
  }
433
454
 
434
455
  export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
@@ -1097,13 +1118,16 @@ export class ShapeStream<T extends Row<unknown> = Row>
1097
1118
  `Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key.`
1098
1119
  )
1099
1120
  } else {
1121
+ // We already have a valid handle, so ignore the stale response entirely
1122
+ // to prevent a mismatch between our current handle and the stale offset.
1100
1123
  console.warn(
1101
1124
  `[Electric] Received stale cached response with expired shape handle. ` +
1102
1125
  `This should not happen and indicates a proxy/CDN caching misconfiguration. ` +
1103
1126
  `The response contained handle "${shapeHandle}" which was previously marked as expired. ` +
1104
1127
  `Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +
1105
- `Ignoring the stale handle and continuing with handle "${this.#shapeHandle}".`
1128
+ `Ignoring the stale response and continuing with handle "${this.#shapeHandle}".`
1106
1129
  )
1130
+ return
1107
1131
  }
1108
1132
  }
1109
1133
 
@@ -1632,6 +1656,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
1632
1656
  * Fetch a snapshot for subset of data.
1633
1657
  * Returns the metadata and the data, but does not inject it into the subscribed data stream.
1634
1658
  *
1659
+ * By default, uses GET to send subset parameters as query parameters. This may hit URL length
1660
+ * limits (HTTP 414) with large WHERE clauses or many parameters. Set `method: 'POST'` or use
1661
+ * `subsetMethod: 'POST'` on the stream to send parameters in the request body instead.
1662
+ *
1635
1663
  * @param opts - The options for the snapshot request.
1636
1664
  * @returns The metadata and the data for the snapshot.
1637
1665
  */
@@ -1639,27 +1667,59 @@ export class ShapeStream<T extends Row<unknown> = Row>
1639
1667
  metadata: SnapshotMetadata
1640
1668
  data: Array<ChangeMessage<T>>
1641
1669
  }> {
1642
- const { fetchUrl, requestHeaders } = await this.#constructUrl(
1643
- this.options.url,
1644
- true,
1645
- opts
1646
- )
1670
+ const method = opts.method ?? this.options.subsetMethod ?? `GET`
1671
+ const usePost = method === `POST`
1672
+
1673
+ let fetchUrl: URL
1674
+ let fetchOptions: RequestInit
1675
+
1676
+ if (usePost) {
1677
+ const result = await this.#constructUrl(this.options.url, true)
1678
+ fetchUrl = result.fetchUrl
1679
+ fetchOptions = {
1680
+ method: `POST`,
1681
+ headers: {
1682
+ ...result.requestHeaders,
1683
+ 'Content-Type': `application/json`,
1684
+ },
1685
+ body: JSON.stringify(this.#buildSubsetBody(opts)),
1686
+ }
1687
+ } else {
1688
+ const result = await this.#constructUrl(this.options.url, true, opts)
1689
+ fetchUrl = result.fetchUrl
1690
+ fetchOptions = { headers: result.requestHeaders }
1691
+ }
1647
1692
 
1648
- const response = await this.#fetchClient(fetchUrl.toString(), {
1649
- headers: requestHeaders,
1650
- })
1693
+ // Capture handle before fetch to avoid race conditions if it changes during the request
1694
+ const usedHandle = this.#shapeHandle
1651
1695
 
1696
+ let response: Response
1697
+ try {
1698
+ response = await this.#fetchClient(fetchUrl.toString(), fetchOptions)
1699
+ } catch (e) {
1700
+ // Handle 409 "must-refetch" - shape handle changed/expired.
1701
+ // The fetch wrapper throws FetchError for non-OK responses, so we catch here.
1702
+ // Unlike #requestShape, we don't call #reset() here as that would
1703
+ // clear #activeSnapshotRequests and break requestSnapshot's pause/resume logic.
1704
+ if (e instanceof FetchError && e.status === 409) {
1705
+ if (usedHandle) {
1706
+ const shapeKey = canonicalShapeKey(fetchUrl)
1707
+ expiredShapesCache.markExpired(shapeKey, usedHandle)
1708
+ }
1709
+
1710
+ this.#shapeHandle =
1711
+ e.headers[SHAPE_HANDLE_HEADER] || `${usedHandle ?? `handle`}-next`
1712
+
1713
+ return this.fetchSnapshot(opts)
1714
+ }
1715
+ throw e
1716
+ }
1717
+
1718
+ // Handle non-OK responses from custom fetch clients that bypass the wrapper chain
1652
1719
  if (!response.ok) {
1653
- throw new FetchError(
1654
- response.status,
1655
- undefined,
1656
- undefined,
1657
- Object.fromEntries([...response.headers.entries()]),
1658
- fetchUrl.toString()
1659
- )
1720
+ throw await FetchError.fromResponse(response, fetchUrl.toString())
1660
1721
  }
1661
1722
 
1662
- // Use schema from stream if available, otherwise extract from response header
1663
1723
  const schema: Schema =
1664
1724
  this.#schema ??
1665
1725
  getSchemaFromHeaders(response.headers, {
@@ -1673,10 +1733,51 @@ export class ShapeStream<T extends Row<unknown> = Row>
1673
1733
  schema
1674
1734
  )
1675
1735
 
1676
- return {
1677
- metadata,
1678
- data,
1736
+ return { metadata, data }
1737
+ }
1738
+
1739
+ #buildSubsetBody(opts: SubsetParams): Record<string, unknown> {
1740
+ const body: Record<string, unknown> = {}
1741
+
1742
+ if (opts.whereExpr) {
1743
+ body.where = compileExpression(
1744
+ opts.whereExpr,
1745
+ this.options.columnMapper?.encode
1746
+ )
1747
+ body.where_expr = opts.whereExpr
1748
+ } else if (opts.where && typeof opts.where === `string`) {
1749
+ body.where = encodeWhereClause(
1750
+ opts.where,
1751
+ this.options.columnMapper?.encode
1752
+ )
1753
+ }
1754
+
1755
+ if (opts.params) {
1756
+ body.params = opts.params
1757
+ }
1758
+
1759
+ if (opts.limit !== undefined) {
1760
+ body.limit = opts.limit
1761
+ }
1762
+
1763
+ if (opts.offset !== undefined) {
1764
+ body.offset = opts.offset
1765
+ }
1766
+
1767
+ if (opts.orderByExpr) {
1768
+ body.order_by = compileOrderBy(
1769
+ opts.orderByExpr,
1770
+ this.options.columnMapper?.encode
1771
+ )
1772
+ body.order_by_expr = opts.orderByExpr
1773
+ } else if (opts.orderBy && typeof opts.orderBy === `string`) {
1774
+ body.order_by = encodeWhereClause(
1775
+ opts.orderBy,
1776
+ this.options.columnMapper?.encode
1777
+ )
1679
1778
  }
1779
+
1780
+ return body
1680
1781
  }
1681
1782
  }
1682
1783
 
package/src/types.ts CHANGED
@@ -102,6 +102,16 @@ export type SubsetParams = {
102
102
  whereExpr?: SerializedExpression
103
103
  /** Structured ORDER BY clauses (preferred when available) */
104
104
  orderByExpr?: SerializedOrderByClause[]
105
+ /**
106
+ * HTTP method to use for the request. Overrides `subsetMethod` from ShapeStreamOptions.
107
+ * - `GET` (default): Sends subset params as query parameters. May fail with 414 errors
108
+ * for large queries.
109
+ * - `POST`: Sends subset params in request body as JSON. Recommended to avoid URL
110
+ * length limits with large WHERE clauses or many parameters.
111
+ *
112
+ * In Electric 2.0, GET will be deprecated and only POST will be supported.
113
+ */
114
+ method?: `GET` | `POST`
105
115
  }
106
116
 
107
117
  export type ControlMessage = {