@electric-sql/client 1.0.0-beta.2 → 1.0.0-beta.4

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
@@ -36,6 +36,7 @@ import {
36
36
  WHERE_QUERY_PARAM,
37
37
  TABLE_QUERY_PARAM,
38
38
  REPLICA_PARAM,
39
+ FORCE_DISCONNECT_AND_REFRESH,
39
40
  } from './constants'
40
41
 
41
42
  const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
@@ -68,7 +69,8 @@ export interface PostgresParams {
68
69
  * changed columns in an update.
69
70
  *
70
71
  * If it's `full` Electric will send the entire row with both changed and
71
- * unchanged values.
72
+ * unchanged values. `old_value` will also be present on update messages,
73
+ * containing the previous value for changed columns.
72
74
  *
73
75
  * Setting `replica` to `full` will result in higher bandwidth
74
76
  * usage and so is not generally recommended.
@@ -254,11 +256,14 @@ export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
254
256
  lastSyncedAt(): number | undefined
255
257
  lastSynced(): number
256
258
  isConnected(): boolean
259
+ hasStarted(): boolean
257
260
 
258
261
  isUpToDate: boolean
259
262
  lastOffset: Offset
260
263
  shapeHandle?: string
261
264
  error?: unknown
265
+
266
+ forceDisconnectAndRefresh(): Promise<void>
262
267
  }
263
268
 
264
269
  /**
@@ -323,6 +328,11 @@ export class ShapeStream<T extends Row<unknown> = Row>
323
328
  #shapeHandle?: string
324
329
  #schema?: Schema
325
330
  #onError?: ShapeStreamErrorHandler
331
+ #requestAbortController?: AbortController
332
+ #isRefreshing = false
333
+ #tickPromise?: Promise<void>
334
+ #tickPromiseResolver?: () => void
335
+ #tickPromiseRejecter?: (reason?: unknown) => void
326
336
 
327
337
  constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
328
338
  this.options = { subscribe: true, ...options }
@@ -419,7 +429,9 @@ export class ShapeStream<T extends Row<unknown> = Row>
419
429
  fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, this.#lastOffset)
420
430
 
421
431
  if (this.#isUpToDate) {
422
- fetchUrl.searchParams.set(LIVE_QUERY_PARAM, `true`)
432
+ if (!this.#isRefreshing) {
433
+ fetchUrl.searchParams.set(LIVE_QUERY_PARAM, `true`)
434
+ }
423
435
  fetchUrl.searchParams.set(
424
436
  LIVE_CACHE_BUSTER_QUERY_PARAM,
425
437
  this.#liveCacheBuster
@@ -437,16 +449,44 @@ export class ShapeStream<T extends Row<unknown> = Row>
437
449
  // sort query params in-place for stable URLs and improved cache hits
438
450
  fetchUrl.searchParams.sort()
439
451
 
452
+ // Create a new AbortController for this request
453
+ this.#requestAbortController = new AbortController()
454
+
455
+ // If user provided a signal, listen to it and pass on the reason for the abort
456
+ let abortListener: (() => void) | undefined
457
+ if (signal) {
458
+ abortListener = () => {
459
+ this.#requestAbortController?.abort(signal.reason)
460
+ }
461
+ signal.addEventListener(`abort`, abortListener, { once: true })
462
+ if (signal.aborted) {
463
+ // If the signal is already aborted, abort the request immediately
464
+ this.#requestAbortController?.abort(signal.reason)
465
+ }
466
+ }
467
+
440
468
  let response!: Response
441
469
  try {
442
470
  response = await this.#fetchClient(fetchUrl.toString(), {
443
- signal,
471
+ signal: this.#requestAbortController.signal,
444
472
  headers: requestHeaders,
445
473
  })
446
474
  this.#connected = true
447
475
  } catch (e) {
476
+ // Handle abort error triggered by refresh
477
+ if (
478
+ (e instanceof FetchError || e instanceof FetchBackoffAbortError) &&
479
+ this.#requestAbortController.signal.aborted &&
480
+ this.#requestAbortController.signal.reason ===
481
+ FORCE_DISCONNECT_AND_REFRESH
482
+ ) {
483
+ // Loop back to the top of the while loop to start a new request
484
+ continue
485
+ }
486
+
448
487
  if (e instanceof FetchBackoffAbortError) break // interrupted
449
488
  if (!(e instanceof FetchError)) throw e // should never happen
489
+
450
490
  if (e.status == 409) {
451
491
  // Upon receiving a 409, we should start from scratch
452
492
  // with the newly provided shape handle
@@ -462,6 +502,11 @@ export class ShapeStream<T extends Row<unknown> = Row>
462
502
  // so we exit the loop
463
503
  throw e
464
504
  }
505
+ } finally {
506
+ if (abortListener && signal) {
507
+ signal.removeEventListener(`abort`, abortListener)
508
+ }
509
+ this.#requestAbortController = undefined
465
510
  }
466
511
 
467
512
  const { headers, status } = response
@@ -505,6 +550,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
505
550
 
506
551
  await this.#publish(batch)
507
552
  }
553
+
554
+ this.#tickPromiseResolver?.()
508
555
  }
509
556
  } catch (err) {
510
557
  this.#error = err
@@ -532,6 +579,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
532
579
  throw err
533
580
  } finally {
534
581
  this.#connected = false
582
+ this.#tickPromiseRejecter?.()
535
583
  }
536
584
  }
537
585
 
@@ -574,6 +622,44 @@ export class ShapeStream<T extends Row<unknown> = Row>
574
622
  return !this.#isUpToDate
575
623
  }
576
624
 
625
+ hasStarted(): boolean {
626
+ return this.#started
627
+ }
628
+
629
+ /** Await the next tick of the request loop */
630
+ async #nextTick() {
631
+ if (this.#tickPromise) {
632
+ return this.#tickPromise
633
+ }
634
+ this.#tickPromise = new Promise((resolve, reject) => {
635
+ this.#tickPromiseResolver = resolve
636
+ this.#tickPromiseRejecter = reject
637
+ })
638
+ this.#tickPromise.finally(() => {
639
+ this.#tickPromise = undefined
640
+ this.#tickPromiseResolver = undefined
641
+ this.#tickPromiseRejecter = undefined
642
+ })
643
+ return this.#tickPromise
644
+ }
645
+
646
+ /**
647
+ * Refreshes the shape stream.
648
+ * This preemptively aborts any ongoing long poll and reconnects without
649
+ * long polling, ensuring that the stream receives an up to date message with the
650
+ * latest LSN from Postgres at that point in time.
651
+ */
652
+ async forceDisconnectAndRefresh(): Promise<void> {
653
+ this.#isRefreshing = true
654
+ if (this.#isUpToDate && !this.#requestAbortController?.signal.aborted) {
655
+ // If we are "up to date", any current request will be a "live" request
656
+ // and needs to be aborted
657
+ this.#requestAbortController?.abort(FORCE_DISCONNECT_AND_REFRESH)
658
+ }
659
+ await this.#nextTick()
660
+ this.#isRefreshing = false
661
+ }
662
+
577
663
  async #publish(messages: Message<T>[]): Promise<void> {
578
664
  await Promise.all(
579
665
  Array.from(this.#subscribers.values()).map(async ([callback, __]) => {
package/src/constants.ts CHANGED
@@ -11,3 +11,4 @@ export const OFFSET_QUERY_PARAM = `offset`
11
11
  export const TABLE_QUERY_PARAM = `table`
12
12
  export const WHERE_QUERY_PARAM = `where`
13
13
  export const REPLICA_PARAM = `replica`
14
+ export const FORCE_DISCONNECT_AND_REFRESH = `force-disconnect-and-refresh`
package/src/parser.ts CHANGED
@@ -104,7 +104,12 @@ export class MessageParser<T extends Row<unknown>> {
104
104
  // is needed because there could be a column named `value`
105
105
  // and the value associated to that column will be a string or null.
106
106
  // But `typeof null === 'object'` so we need to make an explicit check.
107
- if (key === `value` && typeof value === `object` && value !== null) {
107
+ // We also parse the `old_value`, which appears on updates when `replica=full`.
108
+ if (
109
+ (key === `value` || key === `old_value`) &&
110
+ typeof value === `object` &&
111
+ value !== null
112
+ ) {
108
113
  // Parse the row values
109
114
  const row = value as Record<string, Value<GetExtensions<T>>>
110
115
  Object.keys(row).forEach((key) => {
package/src/types.ts CHANGED
@@ -32,8 +32,8 @@ export type ControlMessage = {
32
32
  export type ChangeMessage<T extends Row<unknown> = Row> = {
33
33
  key: string
34
34
  value: T
35
+ old_value?: Partial<T> // Only provided for updates if `replica` is `full`
35
36
  headers: Header & { operation: Operation }
36
- offset: Offset
37
37
  }
38
38
 
39
39
  // Define the type for a record