@electric-sql/client 1.0.9 → 1.0.11

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
@@ -5,9 +5,11 @@ import {
5
5
  Row,
6
6
  MaybePromise,
7
7
  GetExtensions,
8
+ ChangeMessage,
9
+ SnapshotMetadata,
8
10
  } from './types'
9
- import { MessageParser, Parser } from './parser'
10
- import { getOffset, isUpToDateMessage } from './helpers'
11
+ import { MessageParser, Parser, TransformFunction } from './parser'
12
+ import { getOffset, isUpToDateMessage, isChangeMessage } from './helpers'
11
13
  import {
12
14
  FetchError,
13
15
  FetchBackoffAbortError,
@@ -28,6 +30,7 @@ import {
28
30
  CHUNK_LAST_OFFSET_HEADER,
29
31
  LIVE_CACHE_BUSTER_HEADER,
30
32
  LIVE_CACHE_BUSTER_QUERY_PARAM,
33
+ EXPIRED_HANDLE_QUERY_PARAM,
31
34
  COLUMNS_QUERY_PARAM,
32
35
  LIVE_QUERY_PARAM,
33
36
  OFFSET_QUERY_PARAM,
@@ -41,11 +44,20 @@ import {
41
44
  FORCE_DISCONNECT_AND_REFRESH,
42
45
  PAUSE_STREAM,
43
46
  EXPERIMENTAL_LIVE_SSE_QUERY_PARAM,
47
+ ELECTRIC_PROTOCOL_QUERY_PARAMS,
48
+ LOG_MODE_QUERY_PARAM,
49
+ SUBSET_PARAM_WHERE,
50
+ SUBSET_PARAM_WHERE_PARAMS,
51
+ SUBSET_PARAM_LIMIT,
52
+ SUBSET_PARAM_OFFSET,
53
+ SUBSET_PARAM_ORDER_BY,
44
54
  } from './constants'
45
55
  import {
46
56
  EventSourceMessage,
47
57
  fetchEventSource,
48
58
  } from '@microsoft/fetch-event-source'
59
+ import { expiredShapesCache } from './expired-shapes-cache'
60
+ import { SnapshotTracker } from './snapshot-tracker'
49
61
 
50
62
  const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
51
63
  LIVE_CACHE_BUSTER_QUERY_PARAM,
@@ -55,6 +67,7 @@ const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
55
67
  ])
56
68
 
57
69
  type Replica = `full` | `default`
70
+ export type LogMode = `changes_only` | `full`
58
71
 
59
72
  /**
60
73
  * PostgreSQL-specific shape parameters that can be provided externally
@@ -111,11 +124,20 @@ export type ExternalParamsRecord<T extends Row<unknown> = Row> = {
111
124
  [K in string]: ParamValue | undefined
112
125
  } & Partial<PostgresParams<T>> & { [K in ReservedParamKeys]?: never }
113
126
 
127
+ export type SubsetParams = {
128
+ where?: string
129
+ params?: Record<string, string>
130
+ limit?: number
131
+ offset?: number
132
+ orderBy?: string
133
+ }
134
+
114
135
  type ReservedParamKeys =
115
136
  | typeof LIVE_CACHE_BUSTER_QUERY_PARAM
116
137
  | typeof SHAPE_HANDLE_QUERY_PARAM
117
138
  | typeof LIVE_QUERY_PARAM
118
139
  | typeof OFFSET_QUERY_PARAM
140
+ | `subset__${string}`
119
141
 
120
142
  /**
121
143
  * External headers type - what users provide.
@@ -255,10 +277,16 @@ export interface ShapeStreamOptions<T = never> {
255
277
  */
256
278
  experimentalLiveSse?: boolean
257
279
 
280
+ /**
281
+ * Initial data loading mode
282
+ */
283
+ mode?: LogMode
284
+
258
285
  signal?: AbortSignal
259
286
  fetchClient?: typeof fetch
260
287
  backoffOptions?: BackoffOptions
261
288
  parser?: Parser<T>
289
+ transformer?: TransformFunction<T>
262
290
 
263
291
  /**
264
292
  * A function for handling shapestream errors.
@@ -289,8 +317,37 @@ export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
289
317
  lastOffset: Offset
290
318
  shapeHandle?: string
291
319
  error?: unknown
320
+ mode: LogMode
292
321
 
293
322
  forceDisconnectAndRefresh(): Promise<void>
323
+
324
+ requestSnapshot(params: {
325
+ where?: string
326
+ params?: Record<string, string>
327
+ limit: number
328
+ offset?: number
329
+ orderBy: string
330
+ }): Promise<{
331
+ metadata: SnapshotMetadata
332
+ data: Array<Message<T>>
333
+ }>
334
+ }
335
+
336
+ /**
337
+ * Creates a canonical shape key from a URL excluding only Electric protocol parameters
338
+ */
339
+ function canonicalShapeKey(url: URL): string {
340
+ const cleanUrl = new URL(url.origin + url.pathname)
341
+
342
+ // Copy all params except Electric protocol ones that vary between requests
343
+ for (const [key, value] of url.searchParams) {
344
+ if (!ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) {
345
+ cleanUrl.searchParams.set(key, value)
346
+ }
347
+ }
348
+
349
+ cleanUrl.searchParams.sort()
350
+ return cleanUrl.toString()
294
351
  }
295
352
 
296
353
  /**
@@ -362,8 +419,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
362
419
  #liveCacheBuster: string // Seconds since our Electric Epoch 😎
363
420
  #lastSyncedAt?: number // unix time
364
421
  #isUpToDate: boolean = false
422
+ #isMidStream: boolean = true
365
423
  #connected: boolean = false
366
424
  #shapeHandle?: string
425
+ #mode: LogMode
367
426
  #schema?: Schema
368
427
  #onError?: ShapeStreamErrorHandler
369
428
  #requestAbortController?: AbortController
@@ -372,6 +431,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
372
431
  #tickPromiseResolver?: () => void
373
432
  #tickPromiseRejecter?: (reason?: unknown) => void
374
433
  #messageChain = Promise.resolve<void[]>([]) // promise chain for incoming messages
434
+ #snapshotTracker = new SnapshotTracker()
435
+ #activeSnapshotRequests = 0 // counter for concurrent snapshot requests
436
+ #midStreamPromise?: Promise<void>
437
+ #midStreamPromiseResolver?: () => void
375
438
 
376
439
  constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
377
440
  this.options = { subscribe: true, ...options }
@@ -379,8 +442,12 @@ export class ShapeStream<T extends Row<unknown> = Row>
379
442
  this.#lastOffset = this.options.offset ?? `-1`
380
443
  this.#liveCacheBuster = ``
381
444
  this.#shapeHandle = this.options.handle
382
- this.#messageParser = new MessageParser<T>(options.parser)
445
+ this.#messageParser = new MessageParser<T>(
446
+ options.parser,
447
+ options.transformer
448
+ )
383
449
  this.#onError = this.options.onError
450
+ this.#mode = this.options.mode ?? `full`
384
451
 
385
452
  const baseFetchClient =
386
453
  options.fetchClient ??
@@ -423,6 +490,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
423
490
  return this.#lastOffset
424
491
  }
425
492
 
493
+ get mode() {
494
+ return this.#mode
495
+ }
496
+
426
497
  async #start(): Promise<void> {
427
498
  this.#started = true
428
499
 
@@ -517,6 +588,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
517
588
  // with the newly provided shape handle, or a fallback
518
589
  // pseudo-handle based on the current one to act as a
519
590
  // consistent cache buster
591
+
592
+ // Store the current shape URL as expired to avoid future 409s
593
+ if (this.#shapeHandle) {
594
+ const shapeKey = canonicalShapeKey(fetchUrl)
595
+ expiredShapesCache.markExpired(shapeKey, this.#shapeHandle)
596
+ }
597
+
520
598
  const newShapeHandle =
521
599
  e.headers[SHAPE_HANDLE_HEADER] || `${this.#shapeHandle!}-next`
522
600
  this.#reset(newShapeHandle)
@@ -542,7 +620,11 @@ export class ShapeStream<T extends Row<unknown> = Row>
542
620
  return this.#requestShape()
543
621
  }
544
622
 
545
- async #constructUrl(url: string, resumingFromPause: boolean) {
623
+ async #constructUrl(
624
+ url: string,
625
+ resumingFromPause: boolean,
626
+ subsetParams?: SubsetParams
627
+ ) {
546
628
  // Resolve headers and params in parallel
547
629
  const [requestHeaders, params] = await Promise.all([
548
630
  resolveHeaders(this.options.headers),
@@ -552,9 +634,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
552
634
  ])
553
635
 
554
636
  // Validate params after resolution
555
- if (params) {
556
- validateParams(params)
557
- }
637
+ if (params) validateParams(params)
558
638
 
559
639
  const fetchUrl = new URL(url)
560
640
 
@@ -581,8 +661,22 @@ export class ShapeStream<T extends Row<unknown> = Row>
581
661
  }
582
662
  }
583
663
 
664
+ if (subsetParams) {
665
+ if (subsetParams.where)
666
+ setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, subsetParams.where)
667
+ if (subsetParams.params)
668
+ setQueryParam(fetchUrl, SUBSET_PARAM_WHERE_PARAMS, subsetParams.params)
669
+ if (subsetParams.limit)
670
+ setQueryParam(fetchUrl, SUBSET_PARAM_LIMIT, subsetParams.limit)
671
+ if (subsetParams.offset)
672
+ setQueryParam(fetchUrl, SUBSET_PARAM_OFFSET, subsetParams.offset)
673
+ if (subsetParams.orderBy)
674
+ setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, subsetParams.orderBy)
675
+ }
676
+
584
677
  // Add Electric's internal parameters
585
678
  fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, this.#lastOffset)
679
+ fetchUrl.searchParams.set(LOG_MODE_QUERY_PARAM, this.#mode)
586
680
 
587
681
  if (this.#isUpToDate) {
588
682
  // If we are resuming from a paused state, we don't want to perform a live request
@@ -602,6 +696,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
602
696
  fetchUrl.searchParams.set(SHAPE_HANDLE_QUERY_PARAM, this.#shapeHandle!)
603
697
  }
604
698
 
699
+ // Add cache buster for shapes known to be expired to prevent 409s
700
+ const shapeKey = canonicalShapeKey(fetchUrl)
701
+ const expiredHandle = expiredShapesCache.getExpiredHandle(shapeKey)
702
+ if (expiredHandle) {
703
+ fetchUrl.searchParams.set(EXPIRED_HANDLE_QUERY_PARAM, expiredHandle)
704
+ }
705
+
605
706
  // sort query params in-place for stable URLs and improved cache hits
606
707
  fetchUrl.searchParams.sort()
607
708
 
@@ -667,6 +768,9 @@ export class ShapeStream<T extends Row<unknown> = Row>
667
768
  async #onMessages(batch: Array<Message<T>>, isSseMessage = false) {
668
769
  // Update isUpToDate
669
770
  if (batch.length > 0) {
771
+ // Set isMidStream to true when we receive any data
772
+ this.#isMidStream = true
773
+
670
774
  const lastMessage = batch[batch.length - 1]
671
775
  if (isUpToDateMessage(lastMessage)) {
672
776
  if (isSseMessage) {
@@ -680,9 +784,21 @@ export class ShapeStream<T extends Row<unknown> = Row>
680
784
  }
681
785
  this.#lastSyncedAt = Date.now()
682
786
  this.#isUpToDate = true
787
+ // Set isMidStream to false when we see an up-to-date message
788
+ this.#isMidStream = false
789
+ // Resolve the promise waiting for mid-stream to end
790
+ this.#midStreamPromiseResolver?.()
683
791
  }
684
792
 
685
- await this.#publish(batch)
793
+ // Filter messages using snapshot tracker
794
+ const messagesToProcess = batch.filter((message) => {
795
+ if (isChangeMessage(message)) {
796
+ return !this.#snapshotTracker.shouldRejectMessage(message)
797
+ }
798
+ return true // Always process control messages
799
+ })
800
+
801
+ await this.#publish(messagesToProcess)
686
802
  }
687
803
  }
688
804
 
@@ -866,6 +982,24 @@ export class ShapeStream<T extends Row<unknown> = Row>
866
982
  return this.#tickPromise
867
983
  }
868
984
 
985
+ /** Await until we're not in the middle of a stream (i.e., until we see an up-to-date message) */
986
+ async #waitForStreamEnd() {
987
+ if (!this.#isMidStream) {
988
+ return
989
+ }
990
+ if (this.#midStreamPromise) {
991
+ return this.#midStreamPromise
992
+ }
993
+ this.#midStreamPromise = new Promise((resolve) => {
994
+ this.#midStreamPromiseResolver = resolve
995
+ })
996
+ this.#midStreamPromise.finally(() => {
997
+ this.#midStreamPromise = undefined
998
+ this.#midStreamPromiseResolver = undefined
999
+ })
1000
+ return this.#midStreamPromise
1001
+ }
1002
+
869
1003
  /**
870
1004
  * Refreshes the shape stream.
871
1005
  * This preemptively aborts any ongoing long poll and reconnects without
@@ -938,8 +1072,108 @@ export class ShapeStream<T extends Row<unknown> = Row>
938
1072
  this.#liveCacheBuster = ``
939
1073
  this.#shapeHandle = handle
940
1074
  this.#isUpToDate = false
1075
+ this.#isMidStream = true
941
1076
  this.#connected = false
942
1077
  this.#schema = undefined
1078
+ this.#activeSnapshotRequests = 0
1079
+ }
1080
+
1081
+ /**
1082
+ * Request a snapshot for subset of data.
1083
+ *
1084
+ * Only available when mode is `changes_only`.
1085
+ * Returns the insertion point & the data, but more importantly injects the data
1086
+ * into the subscribed data stream. Returned value is unlikely to be useful for the caller,
1087
+ * unless the caller has complicated additional logic.
1088
+ *
1089
+ * Data will be injected in a way that's also tracking further incoming changes, and it'll
1090
+ * skip the ones that are already in the snapshot.
1091
+ *
1092
+ * @param opts - The options for the snapshot request.
1093
+ * @returns The metadata and the data for the snapshot.
1094
+ */
1095
+ async requestSnapshot(opts: SubsetParams): Promise<{
1096
+ metadata: SnapshotMetadata
1097
+ data: Array<ChangeMessage<T>>
1098
+ }> {
1099
+ if (this.#mode === `full`) {
1100
+ throw new Error(
1101
+ `Snapshot requests are not supported in ${this.#mode} mode, as the consumer is guaranteed to observe all data`
1102
+ )
1103
+ }
1104
+ // We shouldn't be getting a snapshot on a shape that's not started
1105
+ if (!this.#started) await this.#start()
1106
+
1107
+ // Wait until we're not mid-stream before pausing
1108
+ // This ensures we don't pause in the middle of a transaction
1109
+ await this.#waitForStreamEnd()
1110
+
1111
+ // Pause the stream if this is the first snapshot request
1112
+ this.#activeSnapshotRequests++
1113
+
1114
+ try {
1115
+ if (this.#activeSnapshotRequests === 1) {
1116
+ // Currently this cannot throw, but in case it can later it's in this try block to not have a stuck counter
1117
+ this.#pause()
1118
+ }
1119
+
1120
+ const { fetchUrl, requestHeaders } = await this.#constructUrl(
1121
+ this.options.url,
1122
+ true,
1123
+ opts
1124
+ )
1125
+
1126
+ const { metadata, data } = await this.#fetchSnapshot(
1127
+ fetchUrl,
1128
+ requestHeaders
1129
+ )
1130
+
1131
+ const dataWithEndBoundary = (data as Array<Message<T>>).concat([
1132
+ { headers: { control: `snapshot-end`, ...metadata } },
1133
+ ])
1134
+
1135
+ this.#snapshotTracker.addSnapshot(
1136
+ metadata,
1137
+ new Set(data.map((message) => message.key))
1138
+ )
1139
+ this.#onMessages(dataWithEndBoundary, false)
1140
+
1141
+ return {
1142
+ metadata,
1143
+ data,
1144
+ }
1145
+ } finally {
1146
+ // Resume the stream if this was the last snapshot request
1147
+ this.#activeSnapshotRequests--
1148
+ if (this.#activeSnapshotRequests === 0) {
1149
+ this.#resume()
1150
+ }
1151
+ }
1152
+ }
1153
+
1154
+ async #fetchSnapshot(url: URL, headers: Record<string, string>) {
1155
+ const response = await this.#fetchClient(url.toString(), { headers })
1156
+
1157
+ if (!response.ok) {
1158
+ throw new FetchError(
1159
+ response.status,
1160
+ undefined,
1161
+ undefined,
1162
+ Object.fromEntries([...response.headers.entries()]),
1163
+ url.toString()
1164
+ )
1165
+ }
1166
+
1167
+ const { metadata, data } = await response.json()
1168
+ const batch = this.#messageParser.parse<Array<ChangeMessage<T>>>(
1169
+ JSON.stringify(data),
1170
+ this.#schema!
1171
+ )
1172
+
1173
+ return {
1174
+ metadata,
1175
+ data: batch,
1176
+ }
943
1177
  }
944
1178
  }
945
1179
 
@@ -969,6 +1203,7 @@ function validateOptions<T>(options: Partial<ShapeStreamOptions<T>>): void {
969
1203
  if (
970
1204
  options.offset !== undefined &&
971
1205
  options.offset !== `-1` &&
1206
+ options.offset !== `now` &&
972
1207
  !options.handle
973
1208
  ) {
974
1209
  throw new MissingShapeHandleError()
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`
@@ -15,6 +16,12 @@ export const WHERE_PARAMS_PARAM = `params`
15
16
  export const EXPERIMENTAL_LIVE_SSE_QUERY_PARAM = `experimental_live_sse`
16
17
  export const FORCE_DISCONNECT_AND_REFRESH = `force-disconnect-and-refresh`
17
18
  export const PAUSE_STREAM = `pause-stream`
19
+ export const LOG_MODE_QUERY_PARAM = `log`
20
+ export const SUBSET_PARAM_WHERE = `subset__where`
21
+ export const SUBSET_PARAM_LIMIT = `subset__limit`
22
+ export const SUBSET_PARAM_OFFSET = `subset__offset`
23
+ export const SUBSET_PARAM_ORDER_BY = `subset__order_by`
24
+ export const SUBSET_PARAM_WHERE_PARAMS = `subset__params`
18
25
 
19
26
  // Query parameters that should be passed through when proxying Electric requests
20
27
  export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
@@ -22,4 +29,11 @@ export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
22
29
  SHAPE_HANDLE_QUERY_PARAM,
23
30
  OFFSET_QUERY_PARAM,
24
31
  LIVE_CACHE_BUSTER_QUERY_PARAM,
32
+ EXPIRED_HANDLE_QUERY_PARAM,
33
+ LOG_MODE_QUERY_PARAM,
34
+ SUBSET_PARAM_WHERE,
35
+ SUBSET_PARAM_LIMIT,
36
+ SUBSET_PARAM_OFFSET,
37
+ SUBSET_PARAM_ORDER_BY,
38
+ SUBSET_PARAM_WHERE_PARAMS,
25
39
  ]
@@ -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/fetch.ts CHANGED
@@ -5,6 +5,11 @@ import {
5
5
  OFFSET_QUERY_PARAM,
6
6
  SHAPE_HANDLE_HEADER,
7
7
  SHAPE_HANDLE_QUERY_PARAM,
8
+ SUBSET_PARAM_LIMIT,
9
+ SUBSET_PARAM_OFFSET,
10
+ SUBSET_PARAM_ORDER_BY,
11
+ SUBSET_PARAM_WHERE,
12
+ SUBSET_PARAM_WHERE_PARAMS,
8
13
  } from './constants'
9
14
  import {
10
15
  FetchError,
@@ -206,11 +211,24 @@ export function createFetchWithResponseHeadersCheck(
206
211
 
207
212
  const addMissingHeaders = (requiredHeaders: Array<string>) =>
208
213
  missingHeaders.push(...requiredHeaders.filter((h) => !headers.has(h)))
209
- addMissingHeaders(requiredElectricResponseHeaders)
210
214
 
211
215
  const input = args[0]
212
216
  const urlString = input.toString()
213
217
  const url = new URL(urlString)
218
+
219
+ // Snapshot responses (subset params) return a JSON object and do not include Electric chunk headers
220
+ const isSnapshotRequest = [
221
+ SUBSET_PARAM_WHERE,
222
+ SUBSET_PARAM_WHERE_PARAMS,
223
+ SUBSET_PARAM_LIMIT,
224
+ SUBSET_PARAM_OFFSET,
225
+ SUBSET_PARAM_ORDER_BY,
226
+ ].some((p) => url.searchParams.has(p))
227
+ if (isSnapshotRequest) {
228
+ return response
229
+ }
230
+
231
+ addMissingHeaders(requiredElectricResponseHeaders)
214
232
  if (url.searchParams.get(LIVE_QUERY_PARAM) === `true`) {
215
233
  addMissingHeaders(requiredLiveResponseHeaders)
216
234
  }
package/src/helpers.ts CHANGED
@@ -1,4 +1,12 @@
1
- import { ChangeMessage, ControlMessage, Message, Offset, Row } from './types'
1
+ import {
2
+ ChangeMessage,
3
+ ControlMessage,
4
+ Message,
5
+ NormalizedPgSnapshot,
6
+ Offset,
7
+ PostgresSnapshot,
8
+ Row,
9
+ } from './types'
2
10
 
3
11
  /**
4
12
  * Type guard for checking {@link Message} is {@link ChangeMessage}.
@@ -64,3 +72,28 @@ export function getOffset(message: ControlMessage): Offset | undefined {
64
72
  }
65
73
  return `${lsn}_0` as Offset
66
74
  }
75
+
76
+ /**
77
+ * Checks if a transaction is visible in a snapshot.
78
+ *
79
+ * @param txid - the transaction id to check
80
+ * @param snapshot - the information about the snapshot
81
+ * @returns true if the transaction is visible in the snapshot
82
+ */
83
+ export function isVisibleInSnapshot(
84
+ txid: number | bigint | `${bigint}`,
85
+ snapshot: PostgresSnapshot | NormalizedPgSnapshot
86
+ ): boolean {
87
+ const xid = BigInt(txid)
88
+ const xmin = BigInt(snapshot.xmin)
89
+ const xmax = BigInt(snapshot.xmax)
90
+ const xip = snapshot.xip_list.map(BigInt)
91
+
92
+ // If the transaction id is less than the minimum transaction id, it is visible in the snapshot.
93
+ // If the transaction id is less than the maximum transaction id and not in the list of active
94
+ // transactions at the time of the snapshot, it has been committed before the snapshot was taken
95
+ // and is therefore visible in the snapshot.
96
+ // Otherwise, it is not visible in the snapshot.
97
+
98
+ return xid < xmin || (xid < xmax && !xip.includes(xid))
99
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  export * from './client'
2
2
  export * from './shape'
3
3
  export * from './types'
4
- export { isChangeMessage, isControlMessage } from './helpers'
4
+ export {
5
+ isChangeMessage,
6
+ isControlMessage,
7
+ isVisibleInSnapshot,
8
+ } from './helpers'
5
9
  export { FetchError } from './error'
6
10
  export { type BackoffOptions, BackoffDefaults } from './fetch'
7
11
  export { ELECTRIC_PROTOCOL_QUERY_PARAMS } from './constants'
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