@electric-sql/client 1.0.9 → 1.0.10

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/src/client.ts CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  MaybePromise,
7
7
  GetExtensions,
8
8
  } from './types'
9
- import { MessageParser, Parser } from './parser'
9
+ import { MessageParser, Parser, TransformFunction } from './parser'
10
10
  import { getOffset, isUpToDateMessage } from './helpers'
11
11
  import {
12
12
  FetchError,
@@ -28,6 +28,7 @@ import {
28
28
  CHUNK_LAST_OFFSET_HEADER,
29
29
  LIVE_CACHE_BUSTER_HEADER,
30
30
  LIVE_CACHE_BUSTER_QUERY_PARAM,
31
+ EXPIRED_HANDLE_QUERY_PARAM,
31
32
  COLUMNS_QUERY_PARAM,
32
33
  LIVE_QUERY_PARAM,
33
34
  OFFSET_QUERY_PARAM,
@@ -41,11 +42,13 @@ import {
41
42
  FORCE_DISCONNECT_AND_REFRESH,
42
43
  PAUSE_STREAM,
43
44
  EXPERIMENTAL_LIVE_SSE_QUERY_PARAM,
45
+ ELECTRIC_PROTOCOL_QUERY_PARAMS,
44
46
  } from './constants'
45
47
  import {
46
48
  EventSourceMessage,
47
49
  fetchEventSource,
48
50
  } from '@microsoft/fetch-event-source'
51
+ import { expiredShapesCache } from './expired-shapes-cache'
49
52
 
50
53
  const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
51
54
  LIVE_CACHE_BUSTER_QUERY_PARAM,
@@ -259,6 +262,7 @@ export interface ShapeStreamOptions<T = never> {
259
262
  fetchClient?: typeof fetch
260
263
  backoffOptions?: BackoffOptions
261
264
  parser?: Parser<T>
265
+ transformer?: TransformFunction<T>
262
266
 
263
267
  /**
264
268
  * A function for handling shapestream errors.
@@ -293,6 +297,23 @@ export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
293
297
  forceDisconnectAndRefresh(): Promise<void>
294
298
  }
295
299
 
300
+ /**
301
+ * Creates a canonical shape key from a URL excluding only Electric protocol parameters
302
+ */
303
+ function canonicalShapeKey(url: URL): string {
304
+ const cleanUrl = new URL(url.origin + url.pathname)
305
+
306
+ // Copy all params except Electric protocol ones that vary between requests
307
+ for (const [key, value] of url.searchParams) {
308
+ if (!ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) {
309
+ cleanUrl.searchParams.set(key, value)
310
+ }
311
+ }
312
+
313
+ cleanUrl.searchParams.sort()
314
+ return cleanUrl.toString()
315
+ }
316
+
296
317
  /**
297
318
  * Reads updates to a shape from Electric using HTTP requests and long polling or
298
319
  * Server-Sent Events (SSE).
@@ -379,7 +400,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
379
400
  this.#lastOffset = this.options.offset ?? `-1`
380
401
  this.#liveCacheBuster = ``
381
402
  this.#shapeHandle = this.options.handle
382
- this.#messageParser = new MessageParser<T>(options.parser)
403
+ this.#messageParser = new MessageParser<T>(
404
+ options.parser,
405
+ options.transformer
406
+ )
383
407
  this.#onError = this.options.onError
384
408
 
385
409
  const baseFetchClient =
@@ -517,6 +541,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
517
541
  // with the newly provided shape handle, or a fallback
518
542
  // pseudo-handle based on the current one to act as a
519
543
  // consistent cache buster
544
+
545
+ // Store the current shape URL as expired to avoid future 409s
546
+ if (this.#shapeHandle) {
547
+ const shapeKey = canonicalShapeKey(fetchUrl)
548
+ expiredShapesCache.markExpired(shapeKey, this.#shapeHandle)
549
+ }
550
+
520
551
  const newShapeHandle =
521
552
  e.headers[SHAPE_HANDLE_HEADER] || `${this.#shapeHandle!}-next`
522
553
  this.#reset(newShapeHandle)
@@ -602,6 +633,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
602
633
  fetchUrl.searchParams.set(SHAPE_HANDLE_QUERY_PARAM, this.#shapeHandle!)
603
634
  }
604
635
 
636
+ // Add cache buster for shapes known to be expired to prevent 409s
637
+ const shapeKey = canonicalShapeKey(fetchUrl)
638
+ const expiredHandle = expiredShapesCache.getExpiredHandle(shapeKey)
639
+ if (expiredHandle) {
640
+ fetchUrl.searchParams.set(EXPIRED_HANDLE_QUERY_PARAM, expiredHandle)
641
+ }
642
+
605
643
  // sort query params in-place for stable URLs and improved cache hits
606
644
  fetchUrl.searchParams.sort()
607
645
 
package/src/constants.ts CHANGED
@@ -5,6 +5,7 @@ export const SHAPE_SCHEMA_HEADER = `electric-schema`
5
5
  export const CHUNK_UP_TO_DATE_HEADER = `electric-up-to-date`
6
6
  export const COLUMNS_QUERY_PARAM = `columns`
7
7
  export const LIVE_CACHE_BUSTER_QUERY_PARAM = `cursor`
8
+ export const EXPIRED_HANDLE_QUERY_PARAM = `expired_handle`
8
9
  export const SHAPE_HANDLE_QUERY_PARAM = `handle`
9
10
  export const LIVE_QUERY_PARAM = `live`
10
11
  export const OFFSET_QUERY_PARAM = `offset`
@@ -22,4 +23,5 @@ export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
22
23
  SHAPE_HANDLE_QUERY_PARAM,
23
24
  OFFSET_QUERY_PARAM,
24
25
  LIVE_CACHE_BUSTER_QUERY_PARAM,
26
+ EXPIRED_HANDLE_QUERY_PARAM,
25
27
  ]
@@ -0,0 +1,72 @@
1
+ interface ExpiredShapeCacheEntry {
2
+ expiredHandle: string
3
+ lastUsed: number
4
+ }
5
+
6
+ /**
7
+ * LRU cache for tracking expired shapes with automatic cleanup
8
+ */
9
+ export class ExpiredShapesCache {
10
+ private data: Record<string, ExpiredShapeCacheEntry> = {}
11
+ private max: number = 250
12
+ private readonly storageKey = `electric_expired_shapes`
13
+
14
+ getExpiredHandle(shapeUrl: string): string | null {
15
+ const entry = this.data[shapeUrl]
16
+ if (entry) {
17
+ // Update last used time when accessed
18
+ entry.lastUsed = Date.now()
19
+ this.save()
20
+ return entry.expiredHandle
21
+ }
22
+ return null
23
+ }
24
+
25
+ markExpired(shapeUrl: string, handle: string): void {
26
+ this.data[shapeUrl] = { expiredHandle: handle, lastUsed: Date.now() }
27
+
28
+ const keys = Object.keys(this.data)
29
+ if (keys.length > this.max) {
30
+ const oldest = keys.reduce((min, k) =>
31
+ this.data[k].lastUsed < this.data[min].lastUsed ? k : min
32
+ )
33
+ delete this.data[oldest]
34
+ }
35
+
36
+ this.save()
37
+ }
38
+
39
+ private save(): void {
40
+ if (typeof localStorage === `undefined`) return
41
+ try {
42
+ localStorage.setItem(this.storageKey, JSON.stringify(this.data))
43
+ } catch {
44
+ // Ignore localStorage errors
45
+ }
46
+ }
47
+
48
+ private load(): void {
49
+ if (typeof localStorage === `undefined`) return
50
+ try {
51
+ const stored = localStorage.getItem(this.storageKey)
52
+ if (stored) {
53
+ this.data = JSON.parse(stored)
54
+ }
55
+ } catch {
56
+ // Ignore localStorage errors, start fresh
57
+ this.data = {}
58
+ }
59
+ }
60
+
61
+ constructor() {
62
+ this.load()
63
+ }
64
+
65
+ clear(): void {
66
+ this.data = {}
67
+ this.save()
68
+ }
69
+ }
70
+
71
+ // Module-level singleton instance
72
+ export const expiredShapesCache = new ExpiredShapesCache()
package/src/parser.ts CHANGED
@@ -19,6 +19,10 @@ export type Parser<Extensions = never> = {
19
19
  [key: string]: ParseFunction<Extensions>
20
20
  }
21
21
 
22
+ export type TransformFunction<Extensions = never> = (
23
+ message: Row<Extensions>
24
+ ) => Row<Extensions>
25
+
22
26
  const parseNumber = (value: string) => Number(value)
23
27
  const parseBool = (value: string) => value === `true` || value === `t`
24
28
  const parseBigInt = (value: string) => BigInt(value)
@@ -94,11 +98,16 @@ export function pgArrayParser<Extensions>(
94
98
 
95
99
  export class MessageParser<T extends Row<unknown>> {
96
100
  private parser: Parser<GetExtensions<T>>
97
- constructor(parser?: Parser<GetExtensions<T>>) {
101
+ private transformer?: TransformFunction<GetExtensions<T>>
102
+ constructor(
103
+ parser?: Parser<GetExtensions<T>>,
104
+ transformer?: TransformFunction<GetExtensions<T>>
105
+ ) {
98
106
  // Merge the provided parser with the default parser
99
107
  // to use the provided parser whenever defined
100
108
  // and otherwise fall back to the default parser
101
109
  this.parser = { ...defaultParser, ...parser }
110
+ this.transformer = transformer
102
111
  }
103
112
 
104
113
  parse<Result>(messages: string, schema: Schema): Result {
@@ -118,6 +127,8 @@ export class MessageParser<T extends Row<unknown>> {
118
127
  Object.keys(row).forEach((key) => {
119
128
  row[key] = this.parseRow(key, row[key] as NullableToken, schema)
120
129
  })
130
+
131
+ if (this.transformer) value = this.transformer(value)
121
132
  }
122
133
  return value
123
134
  }) as Result
package/src/types.ts CHANGED
@@ -14,8 +14,14 @@ export type Value<Extensions = never> =
14
14
 
15
15
  export type Row<Extensions = never> = Record<string, Value<Extensions>>
16
16
 
17
- export type GetExtensions<T extends Row<unknown>> =
18
- T extends Row<infer Extensions> ? Extensions : never
17
+ // Check if `T` extends the base Row type without extensions
18
+ // if yes, it has no extensions so we return `never`
19
+ // otherwise, we infer the extensions from the Row type
20
+ export type GetExtensions<T> = [T] extends [Row<never>]
21
+ ? never
22
+ : [T] extends [Row<infer E>]
23
+ ? E
24
+ : never
19
25
 
20
26
  export type Offset = `-1` | `${number}_${number}` | `${bigint}_${number}`
21
27