@electric-sql/client 1.0.9 → 1.0.11
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/README.md +1 -1
- package/dist/cjs/index.cjs +421 -26
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +93 -6
- package/dist/index.browser.mjs +3 -3
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +93 -6
- package/dist/index.legacy-esm.js +408 -26
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +420 -26
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -2
- package/src/client.ts +243 -8
- package/src/constants.ts +14 -0
- package/src/expired-shapes-cache.ts +72 -0
- package/src/fetch.ts +19 -1
- package/src/helpers.ts +34 -1
- package/src/index.ts +5 -1
- package/src/parser.ts +12 -1
- package/src/shape.ts +104 -14
- package/src/snapshot-tracker.ts +88 -0
- package/src/types.ts +48 -8
package/src/client.ts
CHANGED
|
@@ -5,9 +5,11 @@ import {
|
|
|
5
5
|
Row,
|
|
6
6
|
MaybePromise,
|
|
7
7
|
GetExtensions,
|
|
8
|
+
ChangeMessage,
|
|
9
|
+
SnapshotMetadata,
|
|
8
10
|
} from './types'
|
|
9
|
-
import { MessageParser, Parser } from './parser'
|
|
10
|
-
import { getOffset, isUpToDateMessage } from './helpers'
|
|
11
|
+
import { MessageParser, Parser, TransformFunction } from './parser'
|
|
12
|
+
import { getOffset, isUpToDateMessage, isChangeMessage } from './helpers'
|
|
11
13
|
import {
|
|
12
14
|
FetchError,
|
|
13
15
|
FetchBackoffAbortError,
|
|
@@ -28,6 +30,7 @@ import {
|
|
|
28
30
|
CHUNK_LAST_OFFSET_HEADER,
|
|
29
31
|
LIVE_CACHE_BUSTER_HEADER,
|
|
30
32
|
LIVE_CACHE_BUSTER_QUERY_PARAM,
|
|
33
|
+
EXPIRED_HANDLE_QUERY_PARAM,
|
|
31
34
|
COLUMNS_QUERY_PARAM,
|
|
32
35
|
LIVE_QUERY_PARAM,
|
|
33
36
|
OFFSET_QUERY_PARAM,
|
|
@@ -41,11 +44,20 @@ import {
|
|
|
41
44
|
FORCE_DISCONNECT_AND_REFRESH,
|
|
42
45
|
PAUSE_STREAM,
|
|
43
46
|
EXPERIMENTAL_LIVE_SSE_QUERY_PARAM,
|
|
47
|
+
ELECTRIC_PROTOCOL_QUERY_PARAMS,
|
|
48
|
+
LOG_MODE_QUERY_PARAM,
|
|
49
|
+
SUBSET_PARAM_WHERE,
|
|
50
|
+
SUBSET_PARAM_WHERE_PARAMS,
|
|
51
|
+
SUBSET_PARAM_LIMIT,
|
|
52
|
+
SUBSET_PARAM_OFFSET,
|
|
53
|
+
SUBSET_PARAM_ORDER_BY,
|
|
44
54
|
} from './constants'
|
|
45
55
|
import {
|
|
46
56
|
EventSourceMessage,
|
|
47
57
|
fetchEventSource,
|
|
48
58
|
} from '@microsoft/fetch-event-source'
|
|
59
|
+
import { expiredShapesCache } from './expired-shapes-cache'
|
|
60
|
+
import { SnapshotTracker } from './snapshot-tracker'
|
|
49
61
|
|
|
50
62
|
const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
|
|
51
63
|
LIVE_CACHE_BUSTER_QUERY_PARAM,
|
|
@@ -55,6 +67,7 @@ const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
|
|
|
55
67
|
])
|
|
56
68
|
|
|
57
69
|
type Replica = `full` | `default`
|
|
70
|
+
export type LogMode = `changes_only` | `full`
|
|
58
71
|
|
|
59
72
|
/**
|
|
60
73
|
* PostgreSQL-specific shape parameters that can be provided externally
|
|
@@ -111,11 +124,20 @@ export type ExternalParamsRecord<T extends Row<unknown> = Row> = {
|
|
|
111
124
|
[K in string]: ParamValue | undefined
|
|
112
125
|
} & Partial<PostgresParams<T>> & { [K in ReservedParamKeys]?: never }
|
|
113
126
|
|
|
127
|
+
export type SubsetParams = {
|
|
128
|
+
where?: string
|
|
129
|
+
params?: Record<string, string>
|
|
130
|
+
limit?: number
|
|
131
|
+
offset?: number
|
|
132
|
+
orderBy?: string
|
|
133
|
+
}
|
|
134
|
+
|
|
114
135
|
type ReservedParamKeys =
|
|
115
136
|
| typeof LIVE_CACHE_BUSTER_QUERY_PARAM
|
|
116
137
|
| typeof SHAPE_HANDLE_QUERY_PARAM
|
|
117
138
|
| typeof LIVE_QUERY_PARAM
|
|
118
139
|
| typeof OFFSET_QUERY_PARAM
|
|
140
|
+
| `subset__${string}`
|
|
119
141
|
|
|
120
142
|
/**
|
|
121
143
|
* External headers type - what users provide.
|
|
@@ -255,10 +277,16 @@ export interface ShapeStreamOptions<T = never> {
|
|
|
255
277
|
*/
|
|
256
278
|
experimentalLiveSse?: boolean
|
|
257
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Initial data loading mode
|
|
282
|
+
*/
|
|
283
|
+
mode?: LogMode
|
|
284
|
+
|
|
258
285
|
signal?: AbortSignal
|
|
259
286
|
fetchClient?: typeof fetch
|
|
260
287
|
backoffOptions?: BackoffOptions
|
|
261
288
|
parser?: Parser<T>
|
|
289
|
+
transformer?: TransformFunction<T>
|
|
262
290
|
|
|
263
291
|
/**
|
|
264
292
|
* A function for handling shapestream errors.
|
|
@@ -289,8 +317,37 @@ export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
|
|
|
289
317
|
lastOffset: Offset
|
|
290
318
|
shapeHandle?: string
|
|
291
319
|
error?: unknown
|
|
320
|
+
mode: LogMode
|
|
292
321
|
|
|
293
322
|
forceDisconnectAndRefresh(): Promise<void>
|
|
323
|
+
|
|
324
|
+
requestSnapshot(params: {
|
|
325
|
+
where?: string
|
|
326
|
+
params?: Record<string, string>
|
|
327
|
+
limit: number
|
|
328
|
+
offset?: number
|
|
329
|
+
orderBy: string
|
|
330
|
+
}): Promise<{
|
|
331
|
+
metadata: SnapshotMetadata
|
|
332
|
+
data: Array<Message<T>>
|
|
333
|
+
}>
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Creates a canonical shape key from a URL excluding only Electric protocol parameters
|
|
338
|
+
*/
|
|
339
|
+
function canonicalShapeKey(url: URL): string {
|
|
340
|
+
const cleanUrl = new URL(url.origin + url.pathname)
|
|
341
|
+
|
|
342
|
+
// Copy all params except Electric protocol ones that vary between requests
|
|
343
|
+
for (const [key, value] of url.searchParams) {
|
|
344
|
+
if (!ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) {
|
|
345
|
+
cleanUrl.searchParams.set(key, value)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
cleanUrl.searchParams.sort()
|
|
350
|
+
return cleanUrl.toString()
|
|
294
351
|
}
|
|
295
352
|
|
|
296
353
|
/**
|
|
@@ -362,8 +419,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
362
419
|
#liveCacheBuster: string // Seconds since our Electric Epoch 😎
|
|
363
420
|
#lastSyncedAt?: number // unix time
|
|
364
421
|
#isUpToDate: boolean = false
|
|
422
|
+
#isMidStream: boolean = true
|
|
365
423
|
#connected: boolean = false
|
|
366
424
|
#shapeHandle?: string
|
|
425
|
+
#mode: LogMode
|
|
367
426
|
#schema?: Schema
|
|
368
427
|
#onError?: ShapeStreamErrorHandler
|
|
369
428
|
#requestAbortController?: AbortController
|
|
@@ -372,6 +431,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
372
431
|
#tickPromiseResolver?: () => void
|
|
373
432
|
#tickPromiseRejecter?: (reason?: unknown) => void
|
|
374
433
|
#messageChain = Promise.resolve<void[]>([]) // promise chain for incoming messages
|
|
434
|
+
#snapshotTracker = new SnapshotTracker()
|
|
435
|
+
#activeSnapshotRequests = 0 // counter for concurrent snapshot requests
|
|
436
|
+
#midStreamPromise?: Promise<void>
|
|
437
|
+
#midStreamPromiseResolver?: () => void
|
|
375
438
|
|
|
376
439
|
constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
|
|
377
440
|
this.options = { subscribe: true, ...options }
|
|
@@ -379,8 +442,12 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
379
442
|
this.#lastOffset = this.options.offset ?? `-1`
|
|
380
443
|
this.#liveCacheBuster = ``
|
|
381
444
|
this.#shapeHandle = this.options.handle
|
|
382
|
-
this.#messageParser = new MessageParser<T>(
|
|
445
|
+
this.#messageParser = new MessageParser<T>(
|
|
446
|
+
options.parser,
|
|
447
|
+
options.transformer
|
|
448
|
+
)
|
|
383
449
|
this.#onError = this.options.onError
|
|
450
|
+
this.#mode = this.options.mode ?? `full`
|
|
384
451
|
|
|
385
452
|
const baseFetchClient =
|
|
386
453
|
options.fetchClient ??
|
|
@@ -423,6 +490,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
423
490
|
return this.#lastOffset
|
|
424
491
|
}
|
|
425
492
|
|
|
493
|
+
get mode() {
|
|
494
|
+
return this.#mode
|
|
495
|
+
}
|
|
496
|
+
|
|
426
497
|
async #start(): Promise<void> {
|
|
427
498
|
this.#started = true
|
|
428
499
|
|
|
@@ -517,6 +588,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
517
588
|
// with the newly provided shape handle, or a fallback
|
|
518
589
|
// pseudo-handle based on the current one to act as a
|
|
519
590
|
// consistent cache buster
|
|
591
|
+
|
|
592
|
+
// Store the current shape URL as expired to avoid future 409s
|
|
593
|
+
if (this.#shapeHandle) {
|
|
594
|
+
const shapeKey = canonicalShapeKey(fetchUrl)
|
|
595
|
+
expiredShapesCache.markExpired(shapeKey, this.#shapeHandle)
|
|
596
|
+
}
|
|
597
|
+
|
|
520
598
|
const newShapeHandle =
|
|
521
599
|
e.headers[SHAPE_HANDLE_HEADER] || `${this.#shapeHandle!}-next`
|
|
522
600
|
this.#reset(newShapeHandle)
|
|
@@ -542,7 +620,11 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
542
620
|
return this.#requestShape()
|
|
543
621
|
}
|
|
544
622
|
|
|
545
|
-
async #constructUrl(
|
|
623
|
+
async #constructUrl(
|
|
624
|
+
url: string,
|
|
625
|
+
resumingFromPause: boolean,
|
|
626
|
+
subsetParams?: SubsetParams
|
|
627
|
+
) {
|
|
546
628
|
// Resolve headers and params in parallel
|
|
547
629
|
const [requestHeaders, params] = await Promise.all([
|
|
548
630
|
resolveHeaders(this.options.headers),
|
|
@@ -552,9 +634,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
552
634
|
])
|
|
553
635
|
|
|
554
636
|
// Validate params after resolution
|
|
555
|
-
if (params)
|
|
556
|
-
validateParams(params)
|
|
557
|
-
}
|
|
637
|
+
if (params) validateParams(params)
|
|
558
638
|
|
|
559
639
|
const fetchUrl = new URL(url)
|
|
560
640
|
|
|
@@ -581,8 +661,22 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
581
661
|
}
|
|
582
662
|
}
|
|
583
663
|
|
|
664
|
+
if (subsetParams) {
|
|
665
|
+
if (subsetParams.where)
|
|
666
|
+
setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, subsetParams.where)
|
|
667
|
+
if (subsetParams.params)
|
|
668
|
+
setQueryParam(fetchUrl, SUBSET_PARAM_WHERE_PARAMS, subsetParams.params)
|
|
669
|
+
if (subsetParams.limit)
|
|
670
|
+
setQueryParam(fetchUrl, SUBSET_PARAM_LIMIT, subsetParams.limit)
|
|
671
|
+
if (subsetParams.offset)
|
|
672
|
+
setQueryParam(fetchUrl, SUBSET_PARAM_OFFSET, subsetParams.offset)
|
|
673
|
+
if (subsetParams.orderBy)
|
|
674
|
+
setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, subsetParams.orderBy)
|
|
675
|
+
}
|
|
676
|
+
|
|
584
677
|
// Add Electric's internal parameters
|
|
585
678
|
fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, this.#lastOffset)
|
|
679
|
+
fetchUrl.searchParams.set(LOG_MODE_QUERY_PARAM, this.#mode)
|
|
586
680
|
|
|
587
681
|
if (this.#isUpToDate) {
|
|
588
682
|
// If we are resuming from a paused state, we don't want to perform a live request
|
|
@@ -602,6 +696,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
602
696
|
fetchUrl.searchParams.set(SHAPE_HANDLE_QUERY_PARAM, this.#shapeHandle!)
|
|
603
697
|
}
|
|
604
698
|
|
|
699
|
+
// Add cache buster for shapes known to be expired to prevent 409s
|
|
700
|
+
const shapeKey = canonicalShapeKey(fetchUrl)
|
|
701
|
+
const expiredHandle = expiredShapesCache.getExpiredHandle(shapeKey)
|
|
702
|
+
if (expiredHandle) {
|
|
703
|
+
fetchUrl.searchParams.set(EXPIRED_HANDLE_QUERY_PARAM, expiredHandle)
|
|
704
|
+
}
|
|
705
|
+
|
|
605
706
|
// sort query params in-place for stable URLs and improved cache hits
|
|
606
707
|
fetchUrl.searchParams.sort()
|
|
607
708
|
|
|
@@ -667,6 +768,9 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
667
768
|
async #onMessages(batch: Array<Message<T>>, isSseMessage = false) {
|
|
668
769
|
// Update isUpToDate
|
|
669
770
|
if (batch.length > 0) {
|
|
771
|
+
// Set isMidStream to true when we receive any data
|
|
772
|
+
this.#isMidStream = true
|
|
773
|
+
|
|
670
774
|
const lastMessage = batch[batch.length - 1]
|
|
671
775
|
if (isUpToDateMessage(lastMessage)) {
|
|
672
776
|
if (isSseMessage) {
|
|
@@ -680,9 +784,21 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
680
784
|
}
|
|
681
785
|
this.#lastSyncedAt = Date.now()
|
|
682
786
|
this.#isUpToDate = true
|
|
787
|
+
// Set isMidStream to false when we see an up-to-date message
|
|
788
|
+
this.#isMidStream = false
|
|
789
|
+
// Resolve the promise waiting for mid-stream to end
|
|
790
|
+
this.#midStreamPromiseResolver?.()
|
|
683
791
|
}
|
|
684
792
|
|
|
685
|
-
|
|
793
|
+
// Filter messages using snapshot tracker
|
|
794
|
+
const messagesToProcess = batch.filter((message) => {
|
|
795
|
+
if (isChangeMessage(message)) {
|
|
796
|
+
return !this.#snapshotTracker.shouldRejectMessage(message)
|
|
797
|
+
}
|
|
798
|
+
return true // Always process control messages
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
await this.#publish(messagesToProcess)
|
|
686
802
|
}
|
|
687
803
|
}
|
|
688
804
|
|
|
@@ -866,6 +982,24 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
866
982
|
return this.#tickPromise
|
|
867
983
|
}
|
|
868
984
|
|
|
985
|
+
/** Await until we're not in the middle of a stream (i.e., until we see an up-to-date message) */
|
|
986
|
+
async #waitForStreamEnd() {
|
|
987
|
+
if (!this.#isMidStream) {
|
|
988
|
+
return
|
|
989
|
+
}
|
|
990
|
+
if (this.#midStreamPromise) {
|
|
991
|
+
return this.#midStreamPromise
|
|
992
|
+
}
|
|
993
|
+
this.#midStreamPromise = new Promise((resolve) => {
|
|
994
|
+
this.#midStreamPromiseResolver = resolve
|
|
995
|
+
})
|
|
996
|
+
this.#midStreamPromise.finally(() => {
|
|
997
|
+
this.#midStreamPromise = undefined
|
|
998
|
+
this.#midStreamPromiseResolver = undefined
|
|
999
|
+
})
|
|
1000
|
+
return this.#midStreamPromise
|
|
1001
|
+
}
|
|
1002
|
+
|
|
869
1003
|
/**
|
|
870
1004
|
* Refreshes the shape stream.
|
|
871
1005
|
* This preemptively aborts any ongoing long poll and reconnects without
|
|
@@ -938,8 +1072,108 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
938
1072
|
this.#liveCacheBuster = ``
|
|
939
1073
|
this.#shapeHandle = handle
|
|
940
1074
|
this.#isUpToDate = false
|
|
1075
|
+
this.#isMidStream = true
|
|
941
1076
|
this.#connected = false
|
|
942
1077
|
this.#schema = undefined
|
|
1078
|
+
this.#activeSnapshotRequests = 0
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Request a snapshot for subset of data.
|
|
1083
|
+
*
|
|
1084
|
+
* Only available when mode is `changes_only`.
|
|
1085
|
+
* Returns the insertion point & the data, but more importantly injects the data
|
|
1086
|
+
* into the subscribed data stream. Returned value is unlikely to be useful for the caller,
|
|
1087
|
+
* unless the caller has complicated additional logic.
|
|
1088
|
+
*
|
|
1089
|
+
* Data will be injected in a way that's also tracking further incoming changes, and it'll
|
|
1090
|
+
* skip the ones that are already in the snapshot.
|
|
1091
|
+
*
|
|
1092
|
+
* @param opts - The options for the snapshot request.
|
|
1093
|
+
* @returns The metadata and the data for the snapshot.
|
|
1094
|
+
*/
|
|
1095
|
+
async requestSnapshot(opts: SubsetParams): Promise<{
|
|
1096
|
+
metadata: SnapshotMetadata
|
|
1097
|
+
data: Array<ChangeMessage<T>>
|
|
1098
|
+
}> {
|
|
1099
|
+
if (this.#mode === `full`) {
|
|
1100
|
+
throw new Error(
|
|
1101
|
+
`Snapshot requests are not supported in ${this.#mode} mode, as the consumer is guaranteed to observe all data`
|
|
1102
|
+
)
|
|
1103
|
+
}
|
|
1104
|
+
// We shouldn't be getting a snapshot on a shape that's not started
|
|
1105
|
+
if (!this.#started) await this.#start()
|
|
1106
|
+
|
|
1107
|
+
// Wait until we're not mid-stream before pausing
|
|
1108
|
+
// This ensures we don't pause in the middle of a transaction
|
|
1109
|
+
await this.#waitForStreamEnd()
|
|
1110
|
+
|
|
1111
|
+
// Pause the stream if this is the first snapshot request
|
|
1112
|
+
this.#activeSnapshotRequests++
|
|
1113
|
+
|
|
1114
|
+
try {
|
|
1115
|
+
if (this.#activeSnapshotRequests === 1) {
|
|
1116
|
+
// Currently this cannot throw, but in case it can later it's in this try block to not have a stuck counter
|
|
1117
|
+
this.#pause()
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const { fetchUrl, requestHeaders } = await this.#constructUrl(
|
|
1121
|
+
this.options.url,
|
|
1122
|
+
true,
|
|
1123
|
+
opts
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
const { metadata, data } = await this.#fetchSnapshot(
|
|
1127
|
+
fetchUrl,
|
|
1128
|
+
requestHeaders
|
|
1129
|
+
)
|
|
1130
|
+
|
|
1131
|
+
const dataWithEndBoundary = (data as Array<Message<T>>).concat([
|
|
1132
|
+
{ headers: { control: `snapshot-end`, ...metadata } },
|
|
1133
|
+
])
|
|
1134
|
+
|
|
1135
|
+
this.#snapshotTracker.addSnapshot(
|
|
1136
|
+
metadata,
|
|
1137
|
+
new Set(data.map((message) => message.key))
|
|
1138
|
+
)
|
|
1139
|
+
this.#onMessages(dataWithEndBoundary, false)
|
|
1140
|
+
|
|
1141
|
+
return {
|
|
1142
|
+
metadata,
|
|
1143
|
+
data,
|
|
1144
|
+
}
|
|
1145
|
+
} finally {
|
|
1146
|
+
// Resume the stream if this was the last snapshot request
|
|
1147
|
+
this.#activeSnapshotRequests--
|
|
1148
|
+
if (this.#activeSnapshotRequests === 0) {
|
|
1149
|
+
this.#resume()
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
async #fetchSnapshot(url: URL, headers: Record<string, string>) {
|
|
1155
|
+
const response = await this.#fetchClient(url.toString(), { headers })
|
|
1156
|
+
|
|
1157
|
+
if (!response.ok) {
|
|
1158
|
+
throw new FetchError(
|
|
1159
|
+
response.status,
|
|
1160
|
+
undefined,
|
|
1161
|
+
undefined,
|
|
1162
|
+
Object.fromEntries([...response.headers.entries()]),
|
|
1163
|
+
url.toString()
|
|
1164
|
+
)
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const { metadata, data } = await response.json()
|
|
1168
|
+
const batch = this.#messageParser.parse<Array<ChangeMessage<T>>>(
|
|
1169
|
+
JSON.stringify(data),
|
|
1170
|
+
this.#schema!
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
return {
|
|
1174
|
+
metadata,
|
|
1175
|
+
data: batch,
|
|
1176
|
+
}
|
|
943
1177
|
}
|
|
944
1178
|
}
|
|
945
1179
|
|
|
@@ -969,6 +1203,7 @@ function validateOptions<T>(options: Partial<ShapeStreamOptions<T>>): void {
|
|
|
969
1203
|
if (
|
|
970
1204
|
options.offset !== undefined &&
|
|
971
1205
|
options.offset !== `-1` &&
|
|
1206
|
+
options.offset !== `now` &&
|
|
972
1207
|
!options.handle
|
|
973
1208
|
) {
|
|
974
1209
|
throw new MissingShapeHandleError()
|
package/src/constants.ts
CHANGED
|
@@ -5,6 +5,7 @@ export const SHAPE_SCHEMA_HEADER = `electric-schema`
|
|
|
5
5
|
export const CHUNK_UP_TO_DATE_HEADER = `electric-up-to-date`
|
|
6
6
|
export const COLUMNS_QUERY_PARAM = `columns`
|
|
7
7
|
export const LIVE_CACHE_BUSTER_QUERY_PARAM = `cursor`
|
|
8
|
+
export const EXPIRED_HANDLE_QUERY_PARAM = `expired_handle`
|
|
8
9
|
export const SHAPE_HANDLE_QUERY_PARAM = `handle`
|
|
9
10
|
export const LIVE_QUERY_PARAM = `live`
|
|
10
11
|
export const OFFSET_QUERY_PARAM = `offset`
|
|
@@ -15,6 +16,12 @@ export const WHERE_PARAMS_PARAM = `params`
|
|
|
15
16
|
export const EXPERIMENTAL_LIVE_SSE_QUERY_PARAM = `experimental_live_sse`
|
|
16
17
|
export const FORCE_DISCONNECT_AND_REFRESH = `force-disconnect-and-refresh`
|
|
17
18
|
export const PAUSE_STREAM = `pause-stream`
|
|
19
|
+
export const LOG_MODE_QUERY_PARAM = `log`
|
|
20
|
+
export const SUBSET_PARAM_WHERE = `subset__where`
|
|
21
|
+
export const SUBSET_PARAM_LIMIT = `subset__limit`
|
|
22
|
+
export const SUBSET_PARAM_OFFSET = `subset__offset`
|
|
23
|
+
export const SUBSET_PARAM_ORDER_BY = `subset__order_by`
|
|
24
|
+
export const SUBSET_PARAM_WHERE_PARAMS = `subset__params`
|
|
18
25
|
|
|
19
26
|
// Query parameters that should be passed through when proxying Electric requests
|
|
20
27
|
export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
|
|
@@ -22,4 +29,11 @@ export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
|
|
|
22
29
|
SHAPE_HANDLE_QUERY_PARAM,
|
|
23
30
|
OFFSET_QUERY_PARAM,
|
|
24
31
|
LIVE_CACHE_BUSTER_QUERY_PARAM,
|
|
32
|
+
EXPIRED_HANDLE_QUERY_PARAM,
|
|
33
|
+
LOG_MODE_QUERY_PARAM,
|
|
34
|
+
SUBSET_PARAM_WHERE,
|
|
35
|
+
SUBSET_PARAM_LIMIT,
|
|
36
|
+
SUBSET_PARAM_OFFSET,
|
|
37
|
+
SUBSET_PARAM_ORDER_BY,
|
|
38
|
+
SUBSET_PARAM_WHERE_PARAMS,
|
|
25
39
|
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
interface ExpiredShapeCacheEntry {
|
|
2
|
+
expiredHandle: string
|
|
3
|
+
lastUsed: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* LRU cache for tracking expired shapes with automatic cleanup
|
|
8
|
+
*/
|
|
9
|
+
export class ExpiredShapesCache {
|
|
10
|
+
private data: Record<string, ExpiredShapeCacheEntry> = {}
|
|
11
|
+
private max: number = 250
|
|
12
|
+
private readonly storageKey = `electric_expired_shapes`
|
|
13
|
+
|
|
14
|
+
getExpiredHandle(shapeUrl: string): string | null {
|
|
15
|
+
const entry = this.data[shapeUrl]
|
|
16
|
+
if (entry) {
|
|
17
|
+
// Update last used time when accessed
|
|
18
|
+
entry.lastUsed = Date.now()
|
|
19
|
+
this.save()
|
|
20
|
+
return entry.expiredHandle
|
|
21
|
+
}
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
markExpired(shapeUrl: string, handle: string): void {
|
|
26
|
+
this.data[shapeUrl] = { expiredHandle: handle, lastUsed: Date.now() }
|
|
27
|
+
|
|
28
|
+
const keys = Object.keys(this.data)
|
|
29
|
+
if (keys.length > this.max) {
|
|
30
|
+
const oldest = keys.reduce((min, k) =>
|
|
31
|
+
this.data[k].lastUsed < this.data[min].lastUsed ? k : min
|
|
32
|
+
)
|
|
33
|
+
delete this.data[oldest]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
this.save()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private save(): void {
|
|
40
|
+
if (typeof localStorage === `undefined`) return
|
|
41
|
+
try {
|
|
42
|
+
localStorage.setItem(this.storageKey, JSON.stringify(this.data))
|
|
43
|
+
} catch {
|
|
44
|
+
// Ignore localStorage errors
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private load(): void {
|
|
49
|
+
if (typeof localStorage === `undefined`) return
|
|
50
|
+
try {
|
|
51
|
+
const stored = localStorage.getItem(this.storageKey)
|
|
52
|
+
if (stored) {
|
|
53
|
+
this.data = JSON.parse(stored)
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// Ignore localStorage errors, start fresh
|
|
57
|
+
this.data = {}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
constructor() {
|
|
62
|
+
this.load()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
clear(): void {
|
|
66
|
+
this.data = {}
|
|
67
|
+
this.save()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Module-level singleton instance
|
|
72
|
+
export const expiredShapesCache = new ExpiredShapesCache()
|
package/src/fetch.ts
CHANGED
|
@@ -5,6 +5,11 @@ import {
|
|
|
5
5
|
OFFSET_QUERY_PARAM,
|
|
6
6
|
SHAPE_HANDLE_HEADER,
|
|
7
7
|
SHAPE_HANDLE_QUERY_PARAM,
|
|
8
|
+
SUBSET_PARAM_LIMIT,
|
|
9
|
+
SUBSET_PARAM_OFFSET,
|
|
10
|
+
SUBSET_PARAM_ORDER_BY,
|
|
11
|
+
SUBSET_PARAM_WHERE,
|
|
12
|
+
SUBSET_PARAM_WHERE_PARAMS,
|
|
8
13
|
} from './constants'
|
|
9
14
|
import {
|
|
10
15
|
FetchError,
|
|
@@ -206,11 +211,24 @@ export function createFetchWithResponseHeadersCheck(
|
|
|
206
211
|
|
|
207
212
|
const addMissingHeaders = (requiredHeaders: Array<string>) =>
|
|
208
213
|
missingHeaders.push(...requiredHeaders.filter((h) => !headers.has(h)))
|
|
209
|
-
addMissingHeaders(requiredElectricResponseHeaders)
|
|
210
214
|
|
|
211
215
|
const input = args[0]
|
|
212
216
|
const urlString = input.toString()
|
|
213
217
|
const url = new URL(urlString)
|
|
218
|
+
|
|
219
|
+
// Snapshot responses (subset params) return a JSON object and do not include Electric chunk headers
|
|
220
|
+
const isSnapshotRequest = [
|
|
221
|
+
SUBSET_PARAM_WHERE,
|
|
222
|
+
SUBSET_PARAM_WHERE_PARAMS,
|
|
223
|
+
SUBSET_PARAM_LIMIT,
|
|
224
|
+
SUBSET_PARAM_OFFSET,
|
|
225
|
+
SUBSET_PARAM_ORDER_BY,
|
|
226
|
+
].some((p) => url.searchParams.has(p))
|
|
227
|
+
if (isSnapshotRequest) {
|
|
228
|
+
return response
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
addMissingHeaders(requiredElectricResponseHeaders)
|
|
214
232
|
if (url.searchParams.get(LIVE_QUERY_PARAM) === `true`) {
|
|
215
233
|
addMissingHeaders(requiredLiveResponseHeaders)
|
|
216
234
|
}
|
package/src/helpers.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ChangeMessage,
|
|
3
|
+
ControlMessage,
|
|
4
|
+
Message,
|
|
5
|
+
NormalizedPgSnapshot,
|
|
6
|
+
Offset,
|
|
7
|
+
PostgresSnapshot,
|
|
8
|
+
Row,
|
|
9
|
+
} from './types'
|
|
2
10
|
|
|
3
11
|
/**
|
|
4
12
|
* Type guard for checking {@link Message} is {@link ChangeMessage}.
|
|
@@ -64,3 +72,28 @@ export function getOffset(message: ControlMessage): Offset | undefined {
|
|
|
64
72
|
}
|
|
65
73
|
return `${lsn}_0` as Offset
|
|
66
74
|
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Checks if a transaction is visible in a snapshot.
|
|
78
|
+
*
|
|
79
|
+
* @param txid - the transaction id to check
|
|
80
|
+
* @param snapshot - the information about the snapshot
|
|
81
|
+
* @returns true if the transaction is visible in the snapshot
|
|
82
|
+
*/
|
|
83
|
+
export function isVisibleInSnapshot(
|
|
84
|
+
txid: number | bigint | `${bigint}`,
|
|
85
|
+
snapshot: PostgresSnapshot | NormalizedPgSnapshot
|
|
86
|
+
): boolean {
|
|
87
|
+
const xid = BigInt(txid)
|
|
88
|
+
const xmin = BigInt(snapshot.xmin)
|
|
89
|
+
const xmax = BigInt(snapshot.xmax)
|
|
90
|
+
const xip = snapshot.xip_list.map(BigInt)
|
|
91
|
+
|
|
92
|
+
// If the transaction id is less than the minimum transaction id, it is visible in the snapshot.
|
|
93
|
+
// If the transaction id is less than the maximum transaction id and not in the list of active
|
|
94
|
+
// transactions at the time of the snapshot, it has been committed before the snapshot was taken
|
|
95
|
+
// and is therefore visible in the snapshot.
|
|
96
|
+
// Otherwise, it is not visible in the snapshot.
|
|
97
|
+
|
|
98
|
+
return xid < xmin || (xid < xmax && !xip.includes(xid))
|
|
99
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
export * from './client'
|
|
2
2
|
export * from './shape'
|
|
3
3
|
export * from './types'
|
|
4
|
-
export {
|
|
4
|
+
export {
|
|
5
|
+
isChangeMessage,
|
|
6
|
+
isControlMessage,
|
|
7
|
+
isVisibleInSnapshot,
|
|
8
|
+
} from './helpers'
|
|
5
9
|
export { FetchError } from './error'
|
|
6
10
|
export { type BackoffOptions, BackoffDefaults } from './fetch'
|
|
7
11
|
export { ELECTRIC_PROTOCOL_QUERY_PARAMS } from './constants'
|
package/src/parser.ts
CHANGED
|
@@ -19,6 +19,10 @@ export type Parser<Extensions = never> = {
|
|
|
19
19
|
[key: string]: ParseFunction<Extensions>
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export type TransformFunction<Extensions = never> = (
|
|
23
|
+
message: Row<Extensions>
|
|
24
|
+
) => Row<Extensions>
|
|
25
|
+
|
|
22
26
|
const parseNumber = (value: string) => Number(value)
|
|
23
27
|
const parseBool = (value: string) => value === `true` || value === `t`
|
|
24
28
|
const parseBigInt = (value: string) => BigInt(value)
|
|
@@ -94,11 +98,16 @@ export function pgArrayParser<Extensions>(
|
|
|
94
98
|
|
|
95
99
|
export class MessageParser<T extends Row<unknown>> {
|
|
96
100
|
private parser: Parser<GetExtensions<T>>
|
|
97
|
-
|
|
101
|
+
private transformer?: TransformFunction<GetExtensions<T>>
|
|
102
|
+
constructor(
|
|
103
|
+
parser?: Parser<GetExtensions<T>>,
|
|
104
|
+
transformer?: TransformFunction<GetExtensions<T>>
|
|
105
|
+
) {
|
|
98
106
|
// Merge the provided parser with the default parser
|
|
99
107
|
// to use the provided parser whenever defined
|
|
100
108
|
// and otherwise fall back to the default parser
|
|
101
109
|
this.parser = { ...defaultParser, ...parser }
|
|
110
|
+
this.transformer = transformer
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
parse<Result>(messages: string, schema: Schema): Result {
|
|
@@ -118,6 +127,8 @@ export class MessageParser<T extends Row<unknown>> {
|
|
|
118
127
|
Object.keys(row).forEach((key) => {
|
|
119
128
|
row[key] = this.parseRow(key, row[key] as NullableToken, schema)
|
|
120
129
|
})
|
|
130
|
+
|
|
131
|
+
if (this.transformer) value = this.transformer(value)
|
|
121
132
|
}
|
|
122
133
|
return value
|
|
123
134
|
}) as Result
|