@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/dist/cjs/index.cjs +78 -6
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +13 -2
- package/dist/index.browser.mjs +3 -3
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +13 -2
- package/dist/index.legacy-esm.js +74 -6
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +78 -6
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +89 -3
- package/src/constants.ts +1 -0
- package/src/parser.ts +6 -1
- package/src/types.ts +1 -1
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
|
-
|
|
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
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
|
-
|
|
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
|