@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/dist/cjs/index.cjs +176 -61
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +29 -9
- package/dist/index.browser.mjs +3 -3
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +29 -9
- package/dist/index.legacy-esm.js +164 -61
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +178 -61
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -3
- package/src/client.ts +256 -90
- package/src/constants.ts +1 -0
- package/src/fetch.ts +4 -1
- package/src/helpers.ts +14 -1
- package/src/parser.ts +3 -3
- package/src/types.ts +5 -2
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,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
|
|
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.#
|
|
375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
628
|
-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
-
|
|
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,
|
|
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):
|
|
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
|
|
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 & {
|
|
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> = {
|