@electric-sql/client 1.4.1 → 1.5.0

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.1",
4
+ "version": "1.5.0",
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> {
@@ -538,7 +559,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
538
559
  readonly #messageParser: MessageParser<T>
539
560
 
540
561
  readonly #subscribers = new Map<
541
- number,
562
+ object,
542
563
  [
543
564
  (messages: Message<T>[]) => MaybePromise<void>,
544
565
  ((error: Error) => void) | undefined,
@@ -1161,8 +1182,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
1161
1182
  const currentCursor = this.#liveCacheBuster
1162
1183
 
1163
1184
  if (currentCursor === this.#lastSeenCursor) {
1164
- // Same cursor = still replaying cached responses
1165
- // Suppress this up-to-date notification
1185
+ // Same cursor as previous session - suppress this up-to-date notification.
1186
+ // Exit replay mode after first suppression to ensure we don't get stuck
1187
+ // if CDN keeps returning the same cursor indefinitely.
1188
+ this.#lastSeenCursor = undefined
1166
1189
  return
1167
1190
  }
1168
1191
  }
@@ -1393,7 +1416,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1393
1416
  callback: (messages: Message<T>[]) => MaybePromise<void>,
1394
1417
  onError: (error: Error) => void = () => {}
1395
1418
  ) {
1396
- const subscriptionId = Math.random()
1419
+ const subscriptionId = {}
1397
1420
 
1398
1421
  this.#subscribers.set(subscriptionId, [callback, onError])
1399
1422
  if (!this.#started) this.#start()
@@ -1630,6 +1653,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
1630
1653
  * Fetch a snapshot for subset of data.
1631
1654
  * Returns the metadata and the data, but does not inject it into the subscribed data stream.
1632
1655
  *
1656
+ * By default, uses GET to send subset parameters as query parameters. This may hit URL length
1657
+ * limits (HTTP 414) with large WHERE clauses or many parameters. Set `method: 'POST'` or use
1658
+ * `subsetMethod: 'POST'` on the stream to send parameters in the request body instead.
1659
+ *
1633
1660
  * @param opts - The options for the snapshot request.
1634
1661
  * @returns The metadata and the data for the snapshot.
1635
1662
  */
@@ -1637,27 +1664,35 @@ export class ShapeStream<T extends Row<unknown> = Row>
1637
1664
  metadata: SnapshotMetadata
1638
1665
  data: Array<ChangeMessage<T>>
1639
1666
  }> {
1640
- const { fetchUrl, requestHeaders } = await this.#constructUrl(
1641
- this.options.url,
1642
- true,
1643
- opts
1644
- )
1667
+ const method = opts.method ?? this.options.subsetMethod ?? `GET`
1668
+ const usePost = method === `POST`
1669
+
1670
+ let fetchUrl: URL
1671
+ let fetchOptions: RequestInit
1672
+
1673
+ if (usePost) {
1674
+ const result = await this.#constructUrl(this.options.url, true)
1675
+ fetchUrl = result.fetchUrl
1676
+ fetchOptions = {
1677
+ method: `POST`,
1678
+ headers: {
1679
+ ...result.requestHeaders,
1680
+ 'Content-Type': `application/json`,
1681
+ },
1682
+ body: JSON.stringify(this.#buildSubsetBody(opts)),
1683
+ }
1684
+ } else {
1685
+ const result = await this.#constructUrl(this.options.url, true, opts)
1686
+ fetchUrl = result.fetchUrl
1687
+ fetchOptions = { headers: result.requestHeaders }
1688
+ }
1645
1689
 
1646
- const response = await this.#fetchClient(fetchUrl.toString(), {
1647
- headers: requestHeaders,
1648
- })
1690
+ const response = await this.#fetchClient(fetchUrl.toString(), fetchOptions)
1649
1691
 
1650
1692
  if (!response.ok) {
1651
- throw new FetchError(
1652
- response.status,
1653
- undefined,
1654
- undefined,
1655
- Object.fromEntries([...response.headers.entries()]),
1656
- fetchUrl.toString()
1657
- )
1693
+ throw await FetchError.fromResponse(response, fetchUrl.toString())
1658
1694
  }
1659
1695
 
1660
- // Use schema from stream if available, otherwise extract from response header
1661
1696
  const schema: Schema =
1662
1697
  this.#schema ??
1663
1698
  getSchemaFromHeaders(response.headers, {
@@ -1671,10 +1706,51 @@ export class ShapeStream<T extends Row<unknown> = Row>
1671
1706
  schema
1672
1707
  )
1673
1708
 
1674
- return {
1675
- metadata,
1676
- data,
1709
+ return { metadata, data }
1710
+ }
1711
+
1712
+ #buildSubsetBody(opts: SubsetParams): Record<string, unknown> {
1713
+ const body: Record<string, unknown> = {}
1714
+
1715
+ if (opts.whereExpr) {
1716
+ body.where = compileExpression(
1717
+ opts.whereExpr,
1718
+ this.options.columnMapper?.encode
1719
+ )
1720
+ body.where_expr = opts.whereExpr
1721
+ } else if (opts.where && typeof opts.where === `string`) {
1722
+ body.where = encodeWhereClause(
1723
+ opts.where,
1724
+ this.options.columnMapper?.encode
1725
+ )
1726
+ }
1727
+
1728
+ if (opts.params) {
1729
+ body.params = opts.params
1677
1730
  }
1731
+
1732
+ if (opts.limit !== undefined) {
1733
+ body.limit = opts.limit
1734
+ }
1735
+
1736
+ if (opts.offset !== undefined) {
1737
+ body.offset = opts.offset
1738
+ }
1739
+
1740
+ if (opts.orderByExpr) {
1741
+ body.order_by = compileOrderBy(
1742
+ opts.orderByExpr,
1743
+ this.options.columnMapper?.encode
1744
+ )
1745
+ body.order_by_expr = opts.orderByExpr
1746
+ } else if (opts.orderBy && typeof opts.orderBy === `string`) {
1747
+ body.order_by = encodeWhereClause(
1748
+ opts.orderBy,
1749
+ this.options.columnMapper?.encode
1750
+ )
1751
+ }
1752
+
1753
+ return body
1678
1754
  }
1679
1755
  }
1680
1756
 
package/src/shape.ts CHANGED
@@ -51,7 +51,7 @@ export class Shape<T extends Row<unknown> = Row> {
51
51
  readonly stream: ShapeStreamInterface<T>
52
52
 
53
53
  readonly #data: ShapeData<T> = new Map()
54
- readonly #subscribers = new Map<number, ShapeChangedCallback<T>>()
54
+ readonly #subscribers = new Map<object, ShapeChangedCallback<T>>()
55
55
  readonly #insertedKeys = new Set<string>()
56
56
  readonly #requestedSubSnapshots = new Set<string>()
57
57
  #reexecuteSnapshotsPending = false
@@ -149,7 +149,7 @@ export class Shape<T extends Row<unknown> = Row> {
149
149
  }
150
150
 
151
151
  subscribe(callback: ShapeChangedCallback<T>): () => void {
152
- const subscriptionId = Math.random()
152
+ const subscriptionId = {}
153
153
 
154
154
  this.#subscribers.set(subscriptionId, callback)
155
155
 
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 = {