@electric-sql/client 1.2.1 → 1.3.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.2.1",
4
+ "version": "1.3.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
@@ -7,9 +7,14 @@ import {
7
7
  GetExtensions,
8
8
  ChangeMessage,
9
9
  SnapshotMetadata,
10
+ SubsetParams,
10
11
  } from './types'
11
12
  import { MessageParser, Parser, TransformFunction } from './parser'
12
- import { ColumnMapper, encodeWhereClause } from './column-mapper'
13
+ import {
14
+ ColumnMapper,
15
+ encodeWhereClause,
16
+ quoteIdentifier,
17
+ } from './column-mapper'
13
18
  import { getOffset, isUpToDateMessage, isChangeMessage } from './helpers'
14
19
  import {
15
20
  FetchError,
@@ -128,14 +133,6 @@ export type ExternalParamsRecord<T extends Row<unknown> = Row> = {
128
133
  [K in string]: ParamValue | undefined
129
134
  } & Partial<PostgresParams<T>> & { [K in ReservedParamKeys]?: never }
130
135
 
131
- export type SubsetParams = {
132
- where?: string
133
- params?: Record<string, string>
134
- limit?: number
135
- offset?: number
136
- orderBy?: string
137
- }
138
-
139
136
  type ReservedParamKeys =
140
137
  | typeof LIVE_CACHE_BUSTER_QUERY_PARAM
141
138
  | typeof SHAPE_HANDLE_QUERY_PARAM
@@ -727,7 +724,6 @@ export class ShapeStream<T extends Row<unknown> = Row>
727
724
  async #requestShape(): Promise<void> {
728
725
  if (this.#state === `pause-requested`) {
729
726
  this.#state = `paused`
730
-
731
727
  return
732
728
  }
733
729
 
@@ -855,8 +851,27 @@ export class ShapeStream<T extends Row<unknown> = Row>
855
851
  )
856
852
  setQueryParam(fetchUrl, WHERE_QUERY_PARAM, encodedWhere)
857
853
  }
858
- if (params.columns)
859
- setQueryParam(fetchUrl, COLUMNS_QUERY_PARAM, params.columns)
854
+ if (params.columns) {
855
+ // Get original columns array from options (before toInternalParams converted to string)
856
+ const originalColumns = await resolveValue(this.options.params?.columns)
857
+ if (Array.isArray(originalColumns)) {
858
+ // Apply columnMapper encoding if present
859
+ let encodedColumns = originalColumns.map(String)
860
+ if (this.options.columnMapper) {
861
+ encodedColumns = encodedColumns.map(
862
+ this.options.columnMapper.encode
863
+ )
864
+ }
865
+ // Quote each column name to handle special characters (commas, etc.)
866
+ const serializedColumns = encodedColumns
867
+ .map(quoteIdentifier)
868
+ .join(`,`)
869
+ setQueryParam(fetchUrl, COLUMNS_QUERY_PARAM, serializedColumns)
870
+ } else {
871
+ // Fallback: columns was already a string
872
+ setQueryParam(fetchUrl, COLUMNS_QUERY_PARAM, params.columns)
873
+ }
874
+ }
860
875
  if (params.replica) setQueryParam(fetchUrl, REPLICA_PARAM, params.replica)
861
876
  if (params.params)
862
877
  setQueryParam(fetchUrl, WHERE_PARAMS_PARAM, params.params)
@@ -1236,6 +1251,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
1236
1251
  this.#started &&
1237
1252
  (this.#state === `paused` || this.#state === `pause-requested`)
1238
1253
  ) {
1254
+ // Don't resume if the user's signal is already aborted
1255
+ // This can happen if the signal was aborted while we were paused
1256
+ // (e.g., TanStack DB collection was GC'd)
1257
+ if (this.options.signal?.aborted) {
1258
+ return
1259
+ }
1260
+
1239
1261
  // If we're resuming from pause-requested state, we need to set state back to active
1240
1262
  // to prevent the pause from completing
1241
1263
  if (this.#state === `pause-requested`) {
@@ -1457,6 +1479,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1457
1479
 
1458
1480
  const dataWithEndBoundary = (data as Array<Message<T>>).concat([
1459
1481
  { headers: { control: `snapshot-end`, ...metadata } },
1482
+ { headers: { control: `subset-end`, ...opts } },
1460
1483
  ])
1461
1484
 
1462
1485
  this.#snapshotTracker.addSnapshot(
@@ -3,6 +3,31 @@ import { Schema } from './types'
3
3
  type DbColumnName = string
4
4
  type AppColumnName = string
5
5
 
6
+ /**
7
+ * Quote a PostgreSQL identifier for safe use in query parameters.
8
+ *
9
+ * Wraps the identifier in double quotes and escapes any internal
10
+ * double quotes by doubling them. This ensures identifiers with
11
+ * special characters (commas, spaces, etc.) are handled correctly.
12
+ *
13
+ * @param identifier - The identifier to quote
14
+ * @returns The quoted identifier
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * quoteIdentifier('user_id') // '"user_id"'
19
+ * quoteIdentifier('foo,bar') // '"foo,bar"'
20
+ * quoteIdentifier('has"quote') // '"has""quote"'
21
+ * ```
22
+ *
23
+ * @internal
24
+ */
25
+ export function quoteIdentifier(identifier: string): string {
26
+ // Escape internal double quotes by doubling them
27
+ const escaped = identifier.replace(/"/g, `""`)
28
+ return `"${escaped}"`
29
+ }
30
+
6
31
  /**
7
32
  * A bidirectional column mapper that handles transforming column **names**
8
33
  * between database format (e.g., snake_case) and application format (e.g., camelCase).
package/src/fetch.ts CHANGED
@@ -221,7 +221,7 @@ export function createFetchWithChunkBuffer(
221
221
  ): typeof fetch {
222
222
  const { maxChunksToPrefetch } = prefetchOptions
223
223
 
224
- let prefetchQueue: PrefetchQueue
224
+ let prefetchQueue: PrefetchQueue | undefined
225
225
 
226
226
  const prefetchClient = async (...args: Parameters<typeof fetchClient>) => {
227
227
  const url = args[0].toString()
@@ -233,7 +233,10 @@ export function createFetchWithChunkBuffer(
233
233
  return prefetchedRequest
234
234
  }
235
235
 
236
+ // Clear the prefetch queue after aborting to prevent returning
237
+ // stale/aborted requests on future calls with the same URL
236
238
  prefetchQueue?.abort()
239
+ prefetchQueue = undefined
237
240
 
238
241
  // perform request and fire off prefetch queue if request is eligible
239
242
  const response = await fetchClient(...args)
@@ -340,16 +343,24 @@ class PrefetchQueue {
340
343
 
341
344
  abort(): void {
342
345
  this.#prefetchQueue.forEach(([_, aborter]) => aborter.abort())
346
+ this.#prefetchQueue.clear()
343
347
  }
344
348
 
345
349
  consume(...args: Parameters<typeof fetch>): Promise<Response> | void {
346
350
  const url = args[0].toString()
347
351
 
348
- const request = this.#prefetchQueue.get(url)?.[0]
352
+ const entry = this.#prefetchQueue.get(url)
349
353
  // only consume if request is in queue and is the queue "head"
350
354
  // if request is in the queue but not the head, the queue is being
351
355
  // consumed out of order and should be restarted
352
- if (!request || url !== this.#queueHeadUrl) return
356
+ if (!entry || url !== this.#queueHeadUrl) return
357
+
358
+ const [request, aborter] = entry
359
+ // Don't return aborted requests - they will reject with AbortError
360
+ if (aborter.signal.aborted) {
361
+ this.#prefetchQueue.delete(url)
362
+ return
363
+ }
353
364
  this.#prefetchQueue.delete(url)
354
365
 
355
366
  // fire off new prefetch since request has been consumed
package/src/helpers.ts CHANGED
@@ -66,11 +66,9 @@ export function isUpToDateMessage<T extends Row<unknown> = Row>(
66
66
  * If we are not in SSE mode this function will return undefined.
67
67
  */
68
68
  export function getOffset(message: ControlMessage): Offset | undefined {
69
+ if (message.headers.control != `up-to-date`) return
69
70
  const lsn = message.headers.global_last_seen_lsn
70
- if (!lsn) {
71
- return
72
- }
73
- return `${lsn}_0` as Offset
71
+ return lsn ? (`${lsn}_0` as Offset) : undefined
74
72
  }
75
73
 
76
74
  /**
package/src/types.ts CHANGED
@@ -68,6 +68,14 @@ export type MoveTag = string
68
68
  */
69
69
  export type MoveOutPattern = { pos: number; value: string }
70
70
 
71
+ export type SubsetParams = {
72
+ where?: string
73
+ params?: Record<string, string>
74
+ limit?: number
75
+ offset?: number
76
+ orderBy?: string
77
+ }
78
+
71
79
  export type ControlMessage = {
72
80
  headers:
73
81
  | (Header & {
@@ -75,6 +83,7 @@ export type ControlMessage = {
75
83
  global_last_seen_lsn?: string
76
84
  })
77
85
  | (Header & { control: `snapshot-end` } & PostgresSnapshot)
86
+ | (Header & { control: `subset-end` } & SubsetParams)
78
87
  }
79
88
 
80
89
  export type EventMessage = {