@electric-sql/client 1.0.4 → 1.0.5

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
@@ -7,7 +7,7 @@ import {
7
7
  GetExtensions,
8
8
  } from './types'
9
9
  import { MessageParser, Parser } from './parser'
10
- import { isUpToDateMessage } from './helpers'
10
+ import { getOffset, isUpToDateMessage } from './helpers'
11
11
  import {
12
12
  FetchError,
13
13
  FetchBackoffAbortError,
@@ -40,7 +40,12 @@ import {
40
40
  REPLICA_PARAM,
41
41
  FORCE_DISCONNECT_AND_REFRESH,
42
42
  PAUSE_STREAM,
43
+ EXPERIMENTAL_LIVE_SSE_QUERY_PARAM,
43
44
  } from './constants'
45
+ import {
46
+ EventSourceMessage,
47
+ fetchEventSource,
48
+ } from '@microsoft/fetch-event-source'
44
49
 
45
50
  const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
46
51
  LIVE_CACHE_BUSTER_QUERY_PARAM,
@@ -54,15 +59,17 @@ type Replica = `full` | `default`
54
59
  /**
55
60
  * PostgreSQL-specific shape parameters that can be provided externally
56
61
  */
57
- export interface PostgresParams {
62
+ export interface PostgresParams<T extends Row<unknown> = Row> {
58
63
  /** The root table for the shape. Not required if you set the table in your proxy. */
59
64
  table?: string
60
65
 
61
66
  /**
62
67
  * The columns to include in the shape.
63
68
  * Must include primary keys, and can only include valid columns.
69
+ * Defaults to all columns of the type `T`. If provided, must include primary keys, and can only include valid columns.
70
+
64
71
  */
65
- columns?: string[]
72
+ columns?: (keyof T)[]
66
73
 
67
74
  /** The where clauses for the shape */
68
75
  where?: string
@@ -100,11 +107,9 @@ type ParamValue =
100
107
  * External params type - what users provide.
101
108
  * Excludes reserved parameters to prevent dynamic variations that could cause stream shape changes.
102
109
  */
103
- export type ExternalParamsRecord = {
104
- [K in string as K extends ReservedParamKeys ? never : K]:
105
- | ParamValue
106
- | undefined
107
- } & Partial<PostgresParams>
110
+ export type ExternalParamsRecord<T extends Row<unknown> = Row> = {
111
+ [K in string]: ParamValue | undefined
112
+ } & Partial<PostgresParams<T>> & { [K in ReservedParamKeys]?: never }
108
113
 
109
114
  type ReservedParamKeys =
110
115
  | typeof LIVE_CACHE_BUSTER_QUERY_PARAM
@@ -146,7 +151,7 @@ export async function resolveValue<T>(
146
151
  * Helper function to convert external params to internal format
147
152
  */
148
153
  async function toInternalParams(
149
- params: ExternalParamsRecord
154
+ params: ExternalParamsRecord<Row>
150
155
  ): Promise<InternalParamsRecord> {
151
156
  const entries = Object.entries(params)
152
157
  const resolvedEntries = await Promise.all(
@@ -245,6 +250,11 @@ export interface ShapeStreamOptions<T = never> {
245
250
  */
246
251
  subscribe?: boolean
247
252
 
253
+ /**
254
+ * Experimental support for Server-Sent Events (SSE) for live updates.
255
+ */
256
+ experimentalLiveSse?: boolean
257
+
248
258
  signal?: AbortSignal
249
259
  fetchClient?: typeof fetch
250
260
  backoffOptions?: BackoffOptions
@@ -262,7 +272,9 @@ export interface ShapeStreamOptions<T = never> {
262
272
 
263
273
  export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
264
274
  subscribe(
265
- callback: (messages: Message<T>[]) => MaybePromise<void>,
275
+ callback: (
276
+ messages: Message<T>[]
277
+ ) => MaybePromise<void> | { columns?: (keyof T)[] },
266
278
  onError?: (error: FetchError | Error) => void
267
279
  ): () => void
268
280
  unsubscribeAll(): void
@@ -282,8 +294,9 @@ export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
282
294
  }
283
295
 
284
296
  /**
285
- * Reads updates to a shape from Electric using HTTP requests and long polling. Notifies subscribers
286
- * when new messages come in. Doesn't maintain any history of the
297
+ * Reads updates to a shape from Electric using HTTP requests and long polling or
298
+ * Server-Sent Events (SSE).
299
+ * Notifies subscribers when new messages come in. Doesn't maintain any history of the
287
300
  * log but does keep track of the offset position and is the best way
288
301
  * to consume the HTTP `GET /v1/shape` api.
289
302
  *
@@ -298,6 +311,14 @@ export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
298
311
  * })
299
312
  * ```
300
313
  *
314
+ * To use Server-Sent Events (SSE) for real-time updates:
315
+ * ```
316
+ * const stream = new ShapeStream({
317
+ * url: `http://localhost:3000/v1/shape`,
318
+ * experimentalLiveSse: true
319
+ * })
320
+ * ```
321
+ *
301
322
  * To abort the stream, abort the `signal`
302
323
  * passed in via the `ShapeStreamOptions`.
303
324
  * ```
@@ -324,6 +345,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
324
345
  #error: unknown = null
325
346
 
326
347
  readonly #fetchClient: typeof fetch
348
+ readonly #sseFetchClient: typeof fetch
327
349
  readonly #messageParser: MessageParser<T>
328
350
 
329
351
  readonly #subscribers = new Map<
@@ -349,6 +371,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
349
371
  #tickPromise?: Promise<void>
350
372
  #tickPromiseResolver?: () => void
351
373
  #tickPromiseRejecter?: (reason?: unknown) => void
374
+ #messageChain = Promise.resolve<void[]>([]) // promise chain for incoming messages
352
375
 
353
376
  constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
354
377
  this.options = { subscribe: true, ...options }
@@ -363,13 +386,17 @@ export class ShapeStream<T extends Row<unknown> = Row>
363
386
  options.fetchClient ??
364
387
  ((...args: Parameters<typeof fetch>) => fetch(...args))
365
388
 
366
- const fetchWithBackoffClient = createFetchWithBackoff(baseFetchClient, {
389
+ const backOffOpts = {
367
390
  ...(options.backoffOptions ?? BackoffDefaults),
368
391
  onFailedAttempt: () => {
369
392
  this.#connected = false
370
393
  options.backoffOptions?.onFailedAttempt?.()
371
394
  },
372
- })
395
+ }
396
+ const fetchWithBackoffClient = createFetchWithBackoff(
397
+ baseFetchClient,
398
+ backOffOpts
399
+ )
373
400
 
374
401
  this.#fetchClient = createFetchWithConsumedMessages(
375
402
  createFetchWithResponseHeadersCheck(
@@ -377,6 +404,16 @@ export class ShapeStream<T extends Row<unknown> = Row>
377
404
  )
378
405
  )
379
406
 
407
+ const sseFetchWithBackoffClient = createFetchWithBackoff(
408
+ baseFetchClient,
409
+ backOffOpts,
410
+ true
411
+ )
412
+
413
+ this.#sseFetchClient = createFetchWithResponseHeadersCheck(
414
+ createFetchWithChunkBuffer(sseFetchWithBackoffClient)
415
+ )
416
+
380
417
  this.#subscribeToVisibilityChanges()
381
418
  }
382
419
 
@@ -434,6 +471,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
434
471
  async #requestShape(): Promise<void> {
435
472
  if (this.#state === `pause-requested`) {
436
473
  this.#state = `paused`
474
+
437
475
  return
438
476
  }
439
477
 
@@ -448,7 +486,70 @@ export class ShapeStream<T extends Row<unknown> = Row>
448
486
  this.#state = `active`
449
487
 
450
488
  const { url, signal } = this.options
489
+ const { fetchUrl, requestHeaders } = await this.#constructUrl(
490
+ url,
491
+ resumingFromPause
492
+ )
493
+ const abortListener = await this.#createAbortListener(signal)
494
+ const requestAbortController = this.#requestAbortController! // we know that it is not undefined because it is set by `this.#createAbortListener`
495
+
496
+ try {
497
+ await this.#fetchShape({
498
+ fetchUrl,
499
+ requestAbortController,
500
+ headers: requestHeaders,
501
+ resumingFromPause: true,
502
+ })
503
+ } catch (e) {
504
+ // Handle abort error triggered by refresh
505
+ if (
506
+ (e instanceof FetchError || e instanceof FetchBackoffAbortError) &&
507
+ requestAbortController.signal.aborted &&
508
+ requestAbortController.signal.reason === FORCE_DISCONNECT_AND_REFRESH
509
+ ) {
510
+ // Start a new request
511
+ return this.#requestShape()
512
+ }
451
513
 
514
+ if (e instanceof FetchBackoffAbortError) {
515
+ if (
516
+ requestAbortController.signal.aborted &&
517
+ requestAbortController.signal.reason === PAUSE_STREAM
518
+ ) {
519
+ this.#state = `paused`
520
+ }
521
+ return // interrupted
522
+ }
523
+ if (!(e instanceof FetchError)) throw e // should never happen
524
+
525
+ if (e.status == 409) {
526
+ // Upon receiving a 409, we should start from scratch
527
+ // with the newly provided shape handle
528
+ const newShapeHandle = e.headers[SHAPE_HANDLE_HEADER]
529
+ this.#reset(newShapeHandle)
530
+ await this.#publish(e.json as Message<T>[])
531
+ return this.#requestShape()
532
+ } else {
533
+ // Notify subscribers
534
+ this.#sendErrorToSubscribers(e)
535
+
536
+ // errors that have reached this point are not actionable without
537
+ // additional user input, such as 400s or failures to read the
538
+ // body of a response, so we exit the loop
539
+ throw e
540
+ }
541
+ } finally {
542
+ if (abortListener && signal) {
543
+ signal.removeEventListener(`abort`, abortListener)
544
+ }
545
+ this.#requestAbortController = undefined
546
+ }
547
+
548
+ this.#tickPromiseResolver?.()
549
+ return this.#requestShape()
550
+ }
551
+
552
+ async #constructUrl(url: string, resumingFromPause: boolean) {
452
553
  // Resolve headers and params in parallel
453
554
  const [requestHeaders, params] = await Promise.all([
454
555
  resolveHeaders(this.options.headers),
@@ -511,75 +612,34 @@ export class ShapeStream<T extends Row<unknown> = Row>
511
612
  // sort query params in-place for stable URLs and improved cache hits
512
613
  fetchUrl.searchParams.sort()
513
614
 
615
+ return {
616
+ fetchUrl,
617
+ requestHeaders,
618
+ }
619
+ }
620
+
621
+ async #createAbortListener(signal?: AbortSignal) {
514
622
  // Create a new AbortController for this request
515
623
  this.#requestAbortController = new AbortController()
516
624
 
517
625
  // If user provided a signal, listen to it and pass on the reason for the abort
518
- let abortListener: (() => void) | undefined
519
626
  if (signal) {
520
- abortListener = () => {
627
+ const abortListener = () => {
521
628
  this.#requestAbortController?.abort(signal.reason)
522
629
  }
630
+
523
631
  signal.addEventListener(`abort`, abortListener, { once: true })
632
+
524
633
  if (signal.aborted) {
525
634
  // If the signal is already aborted, abort the request immediately
526
635
  this.#requestAbortController?.abort(signal.reason)
527
636
  }
528
- }
529
637
 
530
- let response!: Response
531
- try {
532
- response = await this.#fetchClient(fetchUrl.toString(), {
533
- signal: this.#requestAbortController.signal,
534
- headers: requestHeaders,
535
- })
536
- this.#connected = true
537
- } catch (e) {
538
- // Handle abort error triggered by refresh
539
- if (
540
- (e instanceof FetchError || e instanceof FetchBackoffAbortError) &&
541
- this.#requestAbortController.signal.aborted &&
542
- this.#requestAbortController.signal.reason ===
543
- FORCE_DISCONNECT_AND_REFRESH
544
- ) {
545
- // Loop back to the top of the while loop to start a new request
546
- return this.#requestShape()
547
- }
548
-
549
- if (e instanceof FetchBackoffAbortError) {
550
- if (
551
- this.#requestAbortController.signal.aborted &&
552
- this.#requestAbortController.signal.reason === PAUSE_STREAM
553
- ) {
554
- this.#state = `paused`
555
- }
556
- return // interrupted
557
- }
558
- if (!(e instanceof FetchError)) throw e // should never happen
559
-
560
- if (e.status == 409) {
561
- // Upon receiving a 409, we should start from scratch
562
- // with the newly provided shape handle
563
- const newShapeHandle = e.headers[SHAPE_HANDLE_HEADER]
564
- this.#reset(newShapeHandle)
565
- await this.#publish(e.json as Message<T>[])
566
- return this.#requestShape()
567
- } else {
568
- // Notify subscribers
569
- this.#sendErrorToSubscribers(e)
570
-
571
- // errors that have reached this point are not actionable without
572
- // additional user input, such as 400s or failures to read the
573
- // body of a response, so we exit the loop
574
- throw e
575
- }
576
- } finally {
577
- if (abortListener && signal) {
578
- signal.removeEventListener(`abort`, abortListener)
579
- }
580
- this.#requestAbortController = undefined
638
+ return abortListener
581
639
  }
640
+ }
582
641
 
642
+ async #onInitialResponse(response: Response) {
583
643
  const { headers, status } = response
584
644
  const shapeHandle = headers.get(SHAPE_HANDLE_HEADER)
585
645
  if (shapeHandle) {
@@ -609,23 +669,122 @@ export class ShapeStream<T extends Row<unknown> = Row>
609
669
  // There's no content so we are live and up to date
610
670
  this.#lastSyncedAt = Date.now()
611
671
  }
672
+ }
612
673
 
613
- const messages = (await response.text()) || `[]`
614
- const batch = this.#messageParser.parse(messages, this.#schema)
674
+ async #onMessages(messages: string, schema: Schema, isSseMessage = false) {
675
+ const batch = this.#messageParser.parse(messages, schema)
615
676
 
616
677
  // Update isUpToDate
617
678
  if (batch.length > 0) {
618
679
  const lastMessage = batch[batch.length - 1]
619
680
  if (isUpToDateMessage(lastMessage)) {
681
+ if (isSseMessage) {
682
+ // Only use the offset from the up-to-date message if this was an SSE message.
683
+ // If we would use this offset from a regular fetch, then it will be wrong
684
+ // and we will get an "offset is out of bounds for this shape" error
685
+ const offset = getOffset(lastMessage)
686
+ if (offset) {
687
+ this.#lastOffset = offset
688
+ }
689
+ }
620
690
  this.#lastSyncedAt = Date.now()
621
691
  this.#isUpToDate = true
622
692
  }
623
693
 
624
694
  await this.#publish(batch)
625
695
  }
696
+ }
626
697
 
627
- this.#tickPromiseResolver?.()
628
- return this.#requestShape()
698
+ /**
699
+ * Fetches the shape from the server using either long polling or SSE.
700
+ * Upon receiving a successfull response, the #onInitialResponse method is called.
701
+ * Afterwards, the #onMessages method is called for all the incoming updates.
702
+ * @param opts - The options for the request.
703
+ * @returns A promise that resolves when the request is complete (i.e. the long poll receives a response or the SSE connection is closed).
704
+ */
705
+ async #fetchShape(opts: {
706
+ fetchUrl: URL
707
+ requestAbortController: AbortController
708
+ headers: Record<string, string>
709
+ resumingFromPause?: boolean
710
+ }): Promise<void> {
711
+ if (
712
+ this.#isUpToDate &&
713
+ this.options.experimentalLiveSse &&
714
+ !this.#isRefreshing &&
715
+ !opts.resumingFromPause
716
+ ) {
717
+ opts.fetchUrl.searchParams.set(EXPERIMENTAL_LIVE_SSE_QUERY_PARAM, `true`)
718
+ return this.#requestShapeSSE(opts)
719
+ }
720
+
721
+ return this.#requestShapeLongPoll(opts)
722
+ }
723
+
724
+ async #requestShapeLongPoll(opts: {
725
+ fetchUrl: URL
726
+ requestAbortController: AbortController
727
+ headers: Record<string, string>
728
+ }): Promise<void> {
729
+ const { fetchUrl, requestAbortController, headers } = opts
730
+ const response = await this.#fetchClient(fetchUrl.toString(), {
731
+ signal: requestAbortController.signal,
732
+ headers,
733
+ })
734
+
735
+ this.#connected = true
736
+ await this.#onInitialResponse(response)
737
+
738
+ const schema = this.#schema! // we know that it is not undefined because it is set by `this.#onInitialResponse`
739
+ const res = await response.text()
740
+ const messages = res || `[]`
741
+
742
+ await this.#onMessages(messages, schema)
743
+ }
744
+
745
+ async #requestShapeSSE(opts: {
746
+ fetchUrl: URL
747
+ requestAbortController: AbortController
748
+ headers: Record<string, string>
749
+ }): Promise<void> {
750
+ const { fetchUrl, requestAbortController, headers } = opts
751
+ const fetch = this.#sseFetchClient
752
+ try {
753
+ await fetchEventSource(fetchUrl.toString(), {
754
+ headers,
755
+ fetch,
756
+ onopen: async (response: Response) => {
757
+ this.#connected = true
758
+ await this.#onInitialResponse(response)
759
+ },
760
+ onmessage: (event: EventSourceMessage) => {
761
+ if (event.data) {
762
+ // Process the SSE message
763
+ // The event.data is a single JSON object, so we wrap it in an array
764
+ const messages = `[${event.data}]`
765
+ const schema = this.#schema! // we know that it is not undefined because it is set in onopen when we call this.#onInitialResponse
766
+ this.#onMessages(messages, schema, true)
767
+ }
768
+ },
769
+ onerror: (error: Error) => {
770
+ // rethrow to close the SSE connection
771
+ throw error
772
+ },
773
+ signal: requestAbortController.signal,
774
+ })
775
+ } catch (error) {
776
+ if (requestAbortController.signal.aborted) {
777
+ // During an SSE request, the fetch might have succeeded
778
+ // and we are parsing the incoming stream.
779
+ // If the abort happens while we're parsing the stream,
780
+ // then it won't be caught by our `createFetchWithBackoff` wrapper
781
+ // and instead we will get a raw AbortError here
782
+ // which we need to turn into a `FetchBackoffAbortError`
783
+ // such that #start handles it correctly.`
784
+ throw new FetchBackoffAbortError()
785
+ }
786
+ throw error
787
+ }
629
788
  }
630
789
 
631
790
  #pause() {
@@ -722,18 +881,26 @@ export class ShapeStream<T extends Row<unknown> = Row>
722
881
  this.#isRefreshing = false
723
882
  }
724
883
 
725
- async #publish(messages: Message<T>[]): Promise<void> {
726
- await Promise.all(
727
- Array.from(this.#subscribers.values()).map(async ([callback, __]) => {
728
- try {
729
- await callback(messages)
730
- } catch (err) {
731
- queueMicrotask(() => {
732
- throw err
733
- })
734
- }
735
- })
884
+ async #publish(messages: Message<T>[]): Promise<void[]> {
885
+ // We process messages asynchronously
886
+ // but SSE's `onmessage` handler is synchronous.
887
+ // We use a promise chain to ensure that the handlers
888
+ // execute sequentially in the order the messages were received.
889
+ this.#messageChain = this.#messageChain.then(() =>
890
+ Promise.all(
891
+ Array.from(this.#subscribers.values()).map(async ([callback, __]) => {
892
+ try {
893
+ await callback(messages)
894
+ } catch (err) {
895
+ queueMicrotask(() => {
896
+ throw err
897
+ })
898
+ }
899
+ })
900
+ )
736
901
  )
902
+
903
+ return this.#messageChain
737
904
  }
738
905
 
739
906
  #sendErrorToSubscribers(error: Error) {
@@ -830,8 +997,8 @@ function setQueryParam(
830
997
  }
831
998
 
832
999
  function convertWhereParamsToObj(
833
- allPgParams: ExternalParamsRecord
834
- ): ExternalParamsRecord {
1000
+ allPgParams: ExternalParamsRecord<Row>
1001
+ ): ExternalParamsRecord<Row> {
835
1002
  if (Array.isArray(allPgParams.params)) {
836
1003
  return {
837
1004
  ...allPgParams,
package/src/constants.ts CHANGED
@@ -12,5 +12,6 @@ export const TABLE_QUERY_PARAM = `table`
12
12
  export const WHERE_QUERY_PARAM = `where`
13
13
  export const REPLICA_PARAM = `replica`
14
14
  export const WHERE_PARAMS_PARAM = `params`
15
+ export const EXPERIMENTAL_LIVE_SSE_QUERY_PARAM = `experimental_live_sse`
15
16
  export const FORCE_DISCONNECT_AND_REFRESH = `force-disconnect-and-refresh`
16
17
  export const PAUSE_STREAM = `pause-stream`
package/src/fetch.ts CHANGED
@@ -38,7 +38,8 @@ export const BackoffDefaults = {
38
38
 
39
39
  export function createFetchWithBackoff(
40
40
  fetchClient: typeof fetch,
41
- backoffOptions: BackoffOptions = BackoffDefaults
41
+ backoffOptions: BackoffOptions = BackoffDefaults,
42
+ sseMode: boolean = false
42
43
  ): typeof fetch {
43
44
  const {
44
45
  initialDelay,
@@ -64,7 +65,16 @@ export function createFetchWithBackoff(
64
65
  try {
65
66
  const result = await fetchClient(...args)
66
67
  if (result.ok) return result
67
- else throw await FetchError.fromResponse(result, url.toString())
68
+
69
+ const err = await FetchError.fromResponse(result, url.toString())
70
+ if (err.status === 409 && sseMode) {
71
+ // The json body is [ { headers: { control: 'must-refetch' } } ] in normal mode
72
+ // and is { headers: { control: 'must-refetch' } } in SSE mode
73
+ // So in SSE mode we need to wrap it in an array
74
+ err.json = [err.json]
75
+ }
76
+
77
+ throw err
68
78
  } catch (e) {
69
79
  onFailedAttempt?.()
70
80
  if (options?.signal?.aborted) {
package/src/helpers.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ChangeMessage, ControlMessage, Message, Row } from './types'
1
+ import { ChangeMessage, ControlMessage, Message, Offset, Row } from './types'
2
2
 
3
3
  /**
4
4
  * Type guard for checking {@link Message} is {@link ChangeMessage}.
@@ -51,3 +51,15 @@ export function isUpToDateMessage<T extends Row<unknown> = Row>(
51
51
  ): message is ControlMessage & { up_to_date: true } {
52
52
  return isControlMessage(message) && message.headers.control === `up-to-date`
53
53
  }
54
+
55
+ /**
56
+ * Parses the LSN from the up-to-date message and turns it into an offset.
57
+ * The LSN is only present in the up-to-date control message when in SSE mode.
58
+ * If we are not in SSE mode this function will return undefined.
59
+ */
60
+ export function getOffset(message: ControlMessage): Offset | undefined {
61
+ const lsn = Number(message.headers.global_last_seen_lsn)
62
+ if (lsn && !isNaN(lsn)) {
63
+ return `${lsn}_0`
64
+ }
65
+ }
package/src/types.ts CHANGED
@@ -26,7 +26,10 @@ interface Header {
26
26
  export type Operation = `insert` | `update` | `delete`
27
27
 
28
28
  export type ControlMessage = {
29
- headers: Header & { control: `up-to-date` | `must-refetch` }
29
+ headers: Header & {
30
+ control: `up-to-date` | `must-refetch`
31
+ global_last_seen_lsn?: string
32
+ }
30
33
  }
31
34
 
32
35
  export type ChangeMessage<T extends Row<unknown> = Row> = {