@electric-sql/client 1.0.4 → 1.0.6

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,20 +386,24 @@ 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
- this.#fetchClient = createFetchWithConsumedMessages(
375
- createFetchWithResponseHeadersCheck(
376
- createFetchWithChunkBuffer(fetchWithBackoffClient)
377
- )
401
+ this.#sseFetchClient = createFetchWithResponseHeadersCheck(
402
+ createFetchWithChunkBuffer(fetchWithBackoffClient)
378
403
  )
379
404
 
405
+ this.#fetchClient = createFetchWithConsumedMessages(this.#sseFetchClient)
406
+
380
407
  this.#subscribeToVisibilityChanges()
381
408
  }
382
409
 
@@ -434,6 +461,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
434
461
  async #requestShape(): Promise<void> {
435
462
  if (this.#state === `pause-requested`) {
436
463
  this.#state = `paused`
464
+
437
465
  return
438
466
  }
439
467
 
@@ -448,7 +476,70 @@ export class ShapeStream<T extends Row<unknown> = Row>
448
476
  this.#state = `active`
449
477
 
450
478
  const { url, signal } = this.options
479
+ const { fetchUrl, requestHeaders } = await this.#constructUrl(
480
+ url,
481
+ resumingFromPause
482
+ )
483
+ const abortListener = await this.#createAbortListener(signal)
484
+ const requestAbortController = this.#requestAbortController! // we know that it is not undefined because it is set by `this.#createAbortListener`
451
485
 
486
+ try {
487
+ await this.#fetchShape({
488
+ fetchUrl,
489
+ requestAbortController,
490
+ headers: requestHeaders,
491
+ resumingFromPause,
492
+ })
493
+ } catch (e) {
494
+ // Handle abort error triggered by refresh
495
+ if (
496
+ (e instanceof FetchError || e instanceof FetchBackoffAbortError) &&
497
+ requestAbortController.signal.aborted &&
498
+ requestAbortController.signal.reason === FORCE_DISCONNECT_AND_REFRESH
499
+ ) {
500
+ // Start a new request
501
+ return this.#requestShape()
502
+ }
503
+
504
+ if (e instanceof FetchBackoffAbortError) {
505
+ if (
506
+ requestAbortController.signal.aborted &&
507
+ requestAbortController.signal.reason === PAUSE_STREAM
508
+ ) {
509
+ this.#state = `paused`
510
+ }
511
+ return // interrupted
512
+ }
513
+ if (!(e instanceof FetchError)) throw e // should never happen
514
+
515
+ if (e.status == 409) {
516
+ // Upon receiving a 409, we should start from scratch
517
+ // with the newly provided shape handle
518
+ const newShapeHandle = e.headers[SHAPE_HANDLE_HEADER]
519
+ this.#reset(newShapeHandle)
520
+ await this.#publish(e.json as Message<T>[])
521
+ return this.#requestShape()
522
+ } else {
523
+ // Notify subscribers
524
+ this.#sendErrorToSubscribers(e)
525
+
526
+ // errors that have reached this point are not actionable without
527
+ // additional user input, such as 400s or failures to read the
528
+ // body of a response, so we exit the loop
529
+ throw e
530
+ }
531
+ } finally {
532
+ if (abortListener && signal) {
533
+ signal.removeEventListener(`abort`, abortListener)
534
+ }
535
+ this.#requestAbortController = undefined
536
+ }
537
+
538
+ this.#tickPromiseResolver?.()
539
+ return this.#requestShape()
540
+ }
541
+
542
+ async #constructUrl(url: string, resumingFromPause: boolean) {
452
543
  // Resolve headers and params in parallel
453
544
  const [requestHeaders, params] = await Promise.all([
454
545
  resolveHeaders(this.options.headers),
@@ -511,75 +602,34 @@ export class ShapeStream<T extends Row<unknown> = Row>
511
602
  // sort query params in-place for stable URLs and improved cache hits
512
603
  fetchUrl.searchParams.sort()
513
604
 
605
+ return {
606
+ fetchUrl,
607
+ requestHeaders,
608
+ }
609
+ }
610
+
611
+ async #createAbortListener(signal?: AbortSignal) {
514
612
  // Create a new AbortController for this request
515
613
  this.#requestAbortController = new AbortController()
516
614
 
517
615
  // If user provided a signal, listen to it and pass on the reason for the abort
518
- let abortListener: (() => void) | undefined
519
616
  if (signal) {
520
- abortListener = () => {
617
+ const abortListener = () => {
521
618
  this.#requestAbortController?.abort(signal.reason)
522
619
  }
620
+
523
621
  signal.addEventListener(`abort`, abortListener, { once: true })
622
+
524
623
  if (signal.aborted) {
525
624
  // If the signal is already aborted, abort the request immediately
526
625
  this.#requestAbortController?.abort(signal.reason)
527
626
  }
528
- }
529
627
 
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
628
+ return abortListener
581
629
  }
630
+ }
582
631
 
632
+ async #onInitialResponse(response: Response) {
583
633
  const { headers, status } = response
584
634
  const shapeHandle = headers.get(SHAPE_HANDLE_HEADER)
585
635
  if (shapeHandle) {
@@ -609,23 +659,131 @@ export class ShapeStream<T extends Row<unknown> = Row>
609
659
  // There's no content so we are live and up to date
610
660
  this.#lastSyncedAt = Date.now()
611
661
  }
662
+ }
612
663
 
613
- const messages = (await response.text()) || `[]`
614
- const batch = this.#messageParser.parse(messages, this.#schema)
615
-
664
+ async #onMessages(batch: Array<Message<T>>, isSseMessage = false) {
616
665
  // Update isUpToDate
617
666
  if (batch.length > 0) {
618
667
  const lastMessage = batch[batch.length - 1]
619
668
  if (isUpToDateMessage(lastMessage)) {
669
+ if (isSseMessage) {
670
+ // Only use the offset from the up-to-date message if this was an SSE message.
671
+ // If we would use this offset from a regular fetch, then it will be wrong
672
+ // and we will get an "offset is out of bounds for this shape" error
673
+ const offset = getOffset(lastMessage)
674
+ if (offset) {
675
+ this.#lastOffset = offset
676
+ }
677
+ }
620
678
  this.#lastSyncedAt = Date.now()
621
679
  this.#isUpToDate = true
622
680
  }
623
681
 
624
682
  await this.#publish(batch)
625
683
  }
684
+ }
626
685
 
627
- this.#tickPromiseResolver?.()
628
- return this.#requestShape()
686
+ /**
687
+ * Fetches the shape from the server using either long polling or SSE.
688
+ * Upon receiving a successfull response, the #onInitialResponse method is called.
689
+ * Afterwards, the #onMessages method is called for all the incoming updates.
690
+ * @param opts - The options for the request.
691
+ * @returns A promise that resolves when the request is complete (i.e. the long poll receives a response or the SSE connection is closed).
692
+ */
693
+ async #fetchShape(opts: {
694
+ fetchUrl: URL
695
+ requestAbortController: AbortController
696
+ headers: Record<string, string>
697
+ resumingFromPause?: boolean
698
+ }): Promise<void> {
699
+ if (
700
+ this.#isUpToDate &&
701
+ this.options.experimentalLiveSse &&
702
+ !this.#isRefreshing &&
703
+ !opts.resumingFromPause
704
+ ) {
705
+ opts.fetchUrl.searchParams.set(EXPERIMENTAL_LIVE_SSE_QUERY_PARAM, `true`)
706
+ return this.#requestShapeSSE(opts)
707
+ }
708
+
709
+ return this.#requestShapeLongPoll(opts)
710
+ }
711
+
712
+ async #requestShapeLongPoll(opts: {
713
+ fetchUrl: URL
714
+ requestAbortController: AbortController
715
+ headers: Record<string, string>
716
+ }): Promise<void> {
717
+ const { fetchUrl, requestAbortController, headers } = opts
718
+ const response = await this.#fetchClient(fetchUrl.toString(), {
719
+ signal: requestAbortController.signal,
720
+ headers,
721
+ })
722
+
723
+ this.#connected = true
724
+ await this.#onInitialResponse(response)
725
+
726
+ const schema = this.#schema! // we know that it is not undefined because it is set by `this.#onInitialResponse`
727
+ const res = await response.text()
728
+ const messages = res || `[]`
729
+ const batch = this.#messageParser.parse<Array<Message<T>>>(messages, schema)
730
+
731
+ await this.#onMessages(batch)
732
+ }
733
+
734
+ async #requestShapeSSE(opts: {
735
+ fetchUrl: URL
736
+ requestAbortController: AbortController
737
+ headers: Record<string, string>
738
+ }): Promise<void> {
739
+ const { fetchUrl, requestAbortController, headers } = opts
740
+ const fetch = this.#sseFetchClient
741
+ try {
742
+ let buffer: Array<Message<T>> = []
743
+ await fetchEventSource(fetchUrl.toString(), {
744
+ headers,
745
+ fetch,
746
+ onopen: async (response: Response) => {
747
+ this.#connected = true
748
+ await this.#onInitialResponse(response)
749
+ },
750
+ onmessage: (event: EventSourceMessage) => {
751
+ if (event.data) {
752
+ // event.data is a single JSON object
753
+ const schema = this.#schema! // we know that it is not undefined because it is set in onopen when we call this.#onInitialResponse
754
+ const message = this.#messageParser.parse<Message<T>>(
755
+ event.data,
756
+ schema
757
+ )
758
+ buffer.push(message)
759
+
760
+ if (isUpToDateMessage(message)) {
761
+ // Flush the buffer on up-to-date message.
762
+ // Ensures that we only process complete batches of operations.
763
+ this.#onMessages(buffer, true)
764
+ buffer = []
765
+ }
766
+ }
767
+ },
768
+ onerror: (error: Error) => {
769
+ // rethrow to close the SSE connection
770
+ throw error
771
+ },
772
+ signal: requestAbortController.signal,
773
+ })
774
+ } catch (error) {
775
+ if (requestAbortController.signal.aborted) {
776
+ // During an SSE request, the fetch might have succeeded
777
+ // and we are parsing the incoming stream.
778
+ // If the abort happens while we're parsing the stream,
779
+ // then it won't be caught by our `createFetchWithBackoff` wrapper
780
+ // and instead we will get a raw AbortError here
781
+ // which we need to turn into a `FetchBackoffAbortError`
782
+ // such that #start handles it correctly.`
783
+ throw new FetchBackoffAbortError()
784
+ }
785
+ throw error
786
+ }
629
787
  }
630
788
 
631
789
  #pause() {
@@ -722,18 +880,26 @@ export class ShapeStream<T extends Row<unknown> = Row>
722
880
  this.#isRefreshing = false
723
881
  }
724
882
 
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
- })
883
+ async #publish(messages: Message<T>[]): Promise<void[]> {
884
+ // We process messages asynchronously
885
+ // but SSE's `onmessage` handler is synchronous.
886
+ // We use a promise chain to ensure that the handlers
887
+ // execute sequentially in the order the messages were received.
888
+ this.#messageChain = this.#messageChain.then(() =>
889
+ Promise.all(
890
+ Array.from(this.#subscribers.values()).map(async ([callback, __]) => {
891
+ try {
892
+ await callback(messages)
893
+ } catch (err) {
894
+ queueMicrotask(() => {
895
+ throw err
896
+ })
897
+ }
898
+ })
899
+ )
736
900
  )
901
+
902
+ return this.#messageChain
737
903
  }
738
904
 
739
905
  #sendErrorToSubscribers(error: Error) {
@@ -830,8 +996,8 @@ function setQueryParam(
830
996
  }
831
997
 
832
998
  function convertWhereParamsToObj(
833
- allPgParams: ExternalParamsRecord
834
- ): ExternalParamsRecord {
999
+ allPgParams: ExternalParamsRecord<Row>
1000
+ ): ExternalParamsRecord<Row> {
835
1001
  if (Array.isArray(allPgParams.params)) {
836
1002
  return {
837
1003
  ...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
@@ -64,7 +64,10 @@ export function createFetchWithBackoff(
64
64
  try {
65
65
  const result = await fetchClient(...args)
66
66
  if (result.ok) return result
67
- else throw await FetchError.fromResponse(result, url.toString())
67
+
68
+ const err = await FetchError.fromResponse(result, url.toString())
69
+
70
+ throw err
68
71
  } catch (e) {
69
72
  onFailedAttempt?.()
70
73
  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,16 @@ 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 = message.headers.global_last_seen_lsn
62
+ if (!lsn) {
63
+ return
64
+ }
65
+ return `${lsn}_0` as Offset
66
+ }
package/src/parser.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ColumnInfo, GetExtensions, Message, Row, Schema, Value } from './types'
1
+ import { ColumnInfo, GetExtensions, Row, Schema, Value } from './types'
2
2
  import { ParserNullValueError } from './error'
3
3
 
4
4
  type NullToken = null | `NULL`
@@ -98,7 +98,7 @@ export class MessageParser<T extends Row<unknown>> {
98
98
  this.parser = { ...defaultParser, ...parser }
99
99
  }
100
100
 
101
- parse(messages: string, schema: Schema): Message<T>[] {
101
+ parse<Result>(messages: string, schema: Schema): Result {
102
102
  return JSON.parse(messages, (key, value) => {
103
103
  // typeof value === `object` && value !== null
104
104
  // is needed because there could be a column named `value`
@@ -117,7 +117,7 @@ export class MessageParser<T extends Row<unknown>> {
117
117
  })
118
118
  }
119
119
  return value
120
- }) as Message<T>[]
120
+ }) as Result
121
121
  }
122
122
 
123
123
  // Parses the message values using the provided parser based on the schema information
package/src/types.ts CHANGED
@@ -17,7 +17,7 @@ export type Row<Extensions = never> = Record<string, Value<Extensions>>
17
17
  export type GetExtensions<T extends Row<unknown>> =
18
18
  T extends Row<infer Extensions> ? Extensions : never
19
19
 
20
- export type Offset = `-1` | `${number}_${number}`
20
+ export type Offset = `-1` | `${number}_${number}` | `${bigint}_${number}`
21
21
 
22
22
  interface Header {
23
23
  [key: Exclude<string, `operation` | `control`>]: Value
@@ -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> = {