@electric-sql/client 1.2.2 → 1.3.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.2.2",
4
+ "version": "1.3.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
@@ -7,6 +7,7 @@ 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
13
  import {
@@ -132,14 +133,6 @@ export type ExternalParamsRecord<T extends Row<unknown> = Row> = {
132
133
  [K in string]: ParamValue | undefined
133
134
  } & Partial<PostgresParams<T>> & { [K in ReservedParamKeys]?: never }
134
135
 
135
- export type SubsetParams = {
136
- where?: string
137
- params?: Record<string, string>
138
- limit?: number
139
- offset?: number
140
- orderBy?: string
141
- }
142
-
143
136
  type ReservedParamKeys =
144
137
  | typeof LIVE_CACHE_BUSTER_QUERY_PARAM
145
138
  | typeof SHAPE_HANDLE_QUERY_PARAM
@@ -731,7 +724,6 @@ export class ShapeStream<T extends Row<unknown> = Row>
731
724
  async #requestShape(): Promise<void> {
732
725
  if (this.#state === `pause-requested`) {
733
726
  this.#state = `paused`
734
-
735
727
  return
736
728
  }
737
729
 
@@ -990,7 +982,26 @@ export class ShapeStream<T extends Row<unknown> = Row>
990
982
  const { headers, status } = response
991
983
  const shapeHandle = headers.get(SHAPE_HANDLE_HEADER)
992
984
  if (shapeHandle) {
993
- this.#shapeHandle = shapeHandle
985
+ // Don't accept a handle we know is expired - this can happen if a
986
+ // proxy serves a stale cached response despite the expired_handle
987
+ // cache buster parameter
988
+ const shapeKey = this.#currentFetchUrl
989
+ ? canonicalShapeKey(this.#currentFetchUrl)
990
+ : null
991
+ const expiredHandle = shapeKey
992
+ ? expiredShapesCache.getExpiredHandle(shapeKey)
993
+ : null
994
+ if (shapeHandle !== expiredHandle) {
995
+ this.#shapeHandle = shapeHandle
996
+ } else {
997
+ console.warn(
998
+ `[Electric] Received stale cached response with expired shape handle. ` +
999
+ `This should not happen and indicates a proxy/CDN caching misconfiguration. ` +
1000
+ `The response contained handle "${shapeHandle}" which was previously marked as expired. ` +
1001
+ `Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +
1002
+ `Ignoring the stale handle and continuing with handle "${this.#shapeHandle}".`
1003
+ )
1004
+ }
994
1005
  }
995
1006
 
996
1007
  const lastOffset = headers.get(CHUNK_LAST_OFFSET_HEADER)
@@ -1259,6 +1270,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
1259
1270
  this.#started &&
1260
1271
  (this.#state === `paused` || this.#state === `pause-requested`)
1261
1272
  ) {
1273
+ // Don't resume if the user's signal is already aborted
1274
+ // This can happen if the signal was aborted while we were paused
1275
+ // (e.g., TanStack DB collection was GC'd)
1276
+ if (this.options.signal?.aborted) {
1277
+ return
1278
+ }
1279
+
1262
1280
  // If we're resuming from pause-requested state, we need to set state back to active
1263
1281
  // to prevent the pause from completing
1264
1282
  if (this.#state === `pause-requested`) {
@@ -1480,6 +1498,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1480
1498
 
1481
1499
  const dataWithEndBoundary = (data as Array<Message<T>>).concat([
1482
1500
  { headers: { control: `snapshot-end`, ...metadata } },
1501
+ { headers: { control: `subset-end`, ...opts } },
1483
1502
  ])
1484
1503
 
1485
1504
  this.#snapshotTracker.addSnapshot(
package/src/fetch.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  CHUNK_LAST_OFFSET_HEADER,
3
3
  CHUNK_UP_TO_DATE_HEADER,
4
+ EXPIRED_HANDLE_QUERY_PARAM,
4
5
  LIVE_QUERY_PARAM,
5
6
  OFFSET_QUERY_PARAM,
6
7
  SHAPE_HANDLE_HEADER,
@@ -221,7 +222,7 @@ export function createFetchWithChunkBuffer(
221
222
  ): typeof fetch {
222
223
  const { maxChunksToPrefetch } = prefetchOptions
223
224
 
224
- let prefetchQueue: PrefetchQueue
225
+ let prefetchQueue: PrefetchQueue | undefined
225
226
 
226
227
  const prefetchClient = async (...args: Parameters<typeof fetchClient>) => {
227
228
  const url = args[0].toString()
@@ -233,7 +234,10 @@ export function createFetchWithChunkBuffer(
233
234
  return prefetchedRequest
234
235
  }
235
236
 
237
+ // Clear the prefetch queue after aborting to prevent returning
238
+ // stale/aborted requests on future calls with the same URL
236
239
  prefetchQueue?.abort()
240
+ prefetchQueue = undefined
237
241
 
238
242
  // perform request and fire off prefetch queue if request is eligible
239
243
  const response = await fetchClient(...args)
@@ -340,16 +344,24 @@ class PrefetchQueue {
340
344
 
341
345
  abort(): void {
342
346
  this.#prefetchQueue.forEach(([_, aborter]) => aborter.abort())
347
+ this.#prefetchQueue.clear()
343
348
  }
344
349
 
345
350
  consume(...args: Parameters<typeof fetch>): Promise<Response> | void {
346
351
  const url = args[0].toString()
347
352
 
348
- const request = this.#prefetchQueue.get(url)?.[0]
353
+ const entry = this.#prefetchQueue.get(url)
349
354
  // only consume if request is in queue and is the queue "head"
350
355
  // if request is in the queue but not the head, the queue is being
351
356
  // consumed out of order and should be restarted
352
- if (!request || url !== this.#queueHeadUrl) return
357
+ if (!entry || url !== this.#queueHeadUrl) return
358
+
359
+ const [request, aborter] = entry
360
+ // Don't return aborted requests - they will reject with AbortError
361
+ if (aborter.signal.aborted) {
362
+ this.#prefetchQueue.delete(url)
363
+ return
364
+ }
353
365
  this.#prefetchQueue.delete(url)
354
366
 
355
367
  // fire off new prefetch since request has been consumed
@@ -425,6 +437,21 @@ function getNextChunkUrl(url: string, res: Response): string | void {
425
437
  // potentially miss more recent data
426
438
  if (nextUrl.searchParams.has(LIVE_QUERY_PARAM)) return
427
439
 
440
+ // don't prefetch if the response handle is the expired handle from the request
441
+ // this can happen when a proxy serves a stale cached response despite the
442
+ // expired_handle cache buster parameter
443
+ const expiredHandle = nextUrl.searchParams.get(EXPIRED_HANDLE_QUERY_PARAM)
444
+ if (expiredHandle && shapeHandle === expiredHandle) {
445
+ console.warn(
446
+ `[Electric] Received stale cached response with expired shape handle. ` +
447
+ `This should not happen and indicates a proxy/CDN caching misconfiguration. ` +
448
+ `The response contained handle "${shapeHandle}" which was previously marked as expired. ` +
449
+ `Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +
450
+ `Skipping prefetch to prevent infinite 409 loop.`
451
+ )
452
+ return
453
+ }
454
+
428
455
  nextUrl.searchParams.set(SHAPE_HANDLE_QUERY_PARAM, shapeHandle)
429
456
  nextUrl.searchParams.set(OFFSET_QUERY_PARAM, lastOffset)
430
457
  nextUrl.searchParams.sort()
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 = {