@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/dist/cjs/index.cjs +176 -58
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +28 -8
- package/dist/index.browser.mjs +3 -3
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +28 -8
- package/dist/index.legacy-esm.js +164 -58
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +178 -58
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -3
- package/src/client.ts +252 -85
- package/src/constants.ts +1 -0
- package/src/fetch.ts +12 -2
- package/src/helpers.ts +13 -1
- package/src/types.ts +4 -1
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?:
|
|
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
|
|
105
|
-
|
|
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: (
|
|
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
|
|
286
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
614
|
-
const batch = this.#messageParser.parse(messages,
|
|
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
|
-
|
|
628
|
-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
-
|
|
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 & {
|
|
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> = {
|