@electric-sql/client 1.0.0-beta.3 → 1.0.0-beta.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@electric-sql/client",
3
3
  "description": "Postgres everywhere - your data, in sync, wherever you need it.",
4
- "version": "1.0.0-beta.3",
4
+ "version": "1.0.0-beta.5",
5
5
  "author": "ElectricSQL team and contributors.",
6
6
  "bugs": {
7
7
  "url": "https://github.com/electric-sql/electric/issues"
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/fetch.ts CHANGED
@@ -264,10 +264,8 @@ class PrefetchQueue {
264
264
  const aborter = new AbortController()
265
265
 
266
266
  try {
267
- const request = this.#fetchClient(url, {
268
- ...(args[1] ?? {}),
269
- signal: chainAborter(aborter, args[1]?.signal),
270
- })
267
+ const { signal, cleanup } = chainAborter(aborter, args[1]?.signal)
268
+ const request = this.#fetchClient(url, { ...(args[1] ?? {}), signal })
271
269
  this.#prefetchQueue.set(url, [request, aborter])
272
270
  request
273
271
  .then((response) => {
@@ -286,6 +284,7 @@ class PrefetchQueue {
286
284
  return this.#prefetch(nextUrl, args[1])
287
285
  })
288
286
  .catch(() => {})
287
+ .finally(cleanup)
289
288
  } catch (_) {
290
289
  // ignore prefetch errors
291
290
  }
@@ -324,12 +323,31 @@ function getNextChunkUrl(url: string, res: Response): string | void {
324
323
  function chainAborter(
325
324
  aborter: AbortController,
326
325
  sourceSignal?: AbortSignal | null
327
- ): AbortSignal {
328
- if (!sourceSignal) return aborter.signal
329
- if (sourceSignal.aborted) aborter.abort()
330
- else
331
- sourceSignal.addEventListener(`abort`, () => aborter.abort(), {
326
+ ): {
327
+ signal: AbortSignal
328
+ cleanup: () => void
329
+ } {
330
+ let cleanup = noop
331
+ if (!sourceSignal) {
332
+ // no-op, nothing to chain to
333
+ } else if (sourceSignal.aborted) {
334
+ // source signal is already aborted, abort immediately
335
+ aborter.abort()
336
+ } else {
337
+ // chain to source signal abort event, and add callback to unlink
338
+ // the aborter to avoid memory leaks
339
+ const abortParent = () => aborter.abort()
340
+ sourceSignal.addEventListener(`abort`, abortParent, {
332
341
  once: true,
342
+ signal: aborter.signal,
333
343
  })
334
- return aborter.signal
344
+ cleanup = () => sourceSignal.removeEventListener(`abort`, abortParent)
345
+ }
346
+
347
+ return {
348
+ signal: aborter.signal,
349
+ cleanup,
350
+ }
335
351
  }
352
+
353
+ function noop() {}
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/shape.ts CHANGED
@@ -9,6 +9,8 @@ export type ShapeChangedCallback<T extends Row<unknown> = Row> = (data: {
9
9
  rows: T[]
10
10
  }) => void
11
11
 
12
+ type ShapeStatus = `syncing` | `up-to-date`
13
+
12
14
  /**
13
15
  * A Shape is an object that subscribes to a shape log,
14
16
  * keeps a materialised shape `.rows` in memory and
@@ -50,8 +52,7 @@ export class Shape<T extends Row<unknown> = Row> {
50
52
 
51
53
  readonly #data: ShapeData<T> = new Map()
52
54
  readonly #subscribers = new Map<number, ShapeChangedCallback<T>>()
53
-
54
- #hasNotifiedSubscribersUpToDate: boolean = false
55
+ #status: ShapeStatus = `syncing`
55
56
  #error: FetchError | false = false
56
57
 
57
58
  constructor(stream: ShapeStreamInterface<T>) {
@@ -63,7 +64,7 @@ export class Shape<T extends Row<unknown> = Row> {
63
64
  }
64
65
 
65
66
  get isUpToDate(): boolean {
66
- return this.stream.isUpToDate
67
+ return this.#status === `up-to-date`
67
68
  }
68
69
 
69
70
  get lastOffset(): Offset {
@@ -143,16 +144,11 @@ export class Shape<T extends Row<unknown> = Row> {
143
144
  }
144
145
 
145
146
  #process(messages: Message<T>[]): void {
146
- let dataMayHaveChanged = false
147
- let isUpToDate = false
148
- let newlyUpToDate = false
147
+ let shouldNotify = false
149
148
 
150
149
  messages.forEach((message) => {
151
150
  if (isChangeMessage(message)) {
152
- dataMayHaveChanged = [`insert`, `update`, `delete`].includes(
153
- message.headers.operation
154
- )
155
-
151
+ shouldNotify = this.#updateShapeStatus(`syncing`)
156
152
  switch (message.headers.operation) {
157
153
  case `insert`:
158
154
  this.#data.set(message.key, message.value)
@@ -172,28 +168,24 @@ export class Shape<T extends Row<unknown> = Row> {
172
168
  if (isControlMessage(message)) {
173
169
  switch (message.headers.control) {
174
170
  case `up-to-date`:
175
- isUpToDate = true
176
- if (!this.#hasNotifiedSubscribersUpToDate) {
177
- newlyUpToDate = true
178
- }
171
+ shouldNotify = this.#updateShapeStatus(`up-to-date`)
179
172
  break
180
173
  case `must-refetch`:
181
174
  this.#data.clear()
182
175
  this.#error = false
183
- this.#hasNotifiedSubscribersUpToDate = false
184
- isUpToDate = false
185
- newlyUpToDate = false
176
+ shouldNotify = this.#updateShapeStatus(`syncing`)
186
177
  break
187
178
  }
188
179
  }
189
180
  })
190
181
 
191
- // Always notify subscribers when the Shape first is up to date.
192
- // FIXME this would be cleaner with a simple state machine.
193
- if (newlyUpToDate || (isUpToDate && dataMayHaveChanged)) {
194
- this.#hasNotifiedSubscribersUpToDate = true
195
- this.#notify()
196
- }
182
+ if (shouldNotify) this.#notify()
183
+ }
184
+
185
+ #updateShapeStatus(status: ShapeStatus): boolean {
186
+ const stateChanged = this.#status !== status
187
+ this.#status = status
188
+ return stateChanged && status === `up-to-date`
197
189
  }
198
190
 
199
191
  #handleError(e: Error): void {
package/src/types.ts CHANGED
@@ -32,6 +32,7 @@ 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
37
  }
37
38