@electric-sql/client 1.0.10 → 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/dist/cjs/index.cjs +338 -24
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +90 -5
- package/dist/index.browser.mjs +3 -3
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +90 -5
- package/dist/index.legacy-esm.js +325 -24
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +337 -24
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +203 -6
- package/src/constants.ts +12 -0
- package/src/fetch.ts +19 -1
- package/src/helpers.ts +34 -1
- package/src/index.ts +5 -1
- package/src/shape.ts +104 -14
- package/src/snapshot-tracker.ts +88 -0
- package/src/types.ts +40 -6
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
11
|
import { MessageParser, Parser, TransformFunction } from './parser'
|
|
10
|
-
import { getOffset, isUpToDateMessage } from './helpers'
|
|
12
|
+
import { getOffset, isUpToDateMessage, isChangeMessage } from './helpers'
|
|
11
13
|
import {
|
|
12
14
|
FetchError,
|
|
13
15
|
FetchBackoffAbortError,
|
|
@@ -43,12 +45,19 @@ import {
|
|
|
43
45
|
PAUSE_STREAM,
|
|
44
46
|
EXPERIMENTAL_LIVE_SSE_QUERY_PARAM,
|
|
45
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,
|
|
46
54
|
} from './constants'
|
|
47
55
|
import {
|
|
48
56
|
EventSourceMessage,
|
|
49
57
|
fetchEventSource,
|
|
50
58
|
} from '@microsoft/fetch-event-source'
|
|
51
59
|
import { expiredShapesCache } from './expired-shapes-cache'
|
|
60
|
+
import { SnapshotTracker } from './snapshot-tracker'
|
|
52
61
|
|
|
53
62
|
const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
|
|
54
63
|
LIVE_CACHE_BUSTER_QUERY_PARAM,
|
|
@@ -58,6 +67,7 @@ const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
|
|
|
58
67
|
])
|
|
59
68
|
|
|
60
69
|
type Replica = `full` | `default`
|
|
70
|
+
export type LogMode = `changes_only` | `full`
|
|
61
71
|
|
|
62
72
|
/**
|
|
63
73
|
* PostgreSQL-specific shape parameters that can be provided externally
|
|
@@ -114,11 +124,20 @@ export type ExternalParamsRecord<T extends Row<unknown> = Row> = {
|
|
|
114
124
|
[K in string]: ParamValue | undefined
|
|
115
125
|
} & Partial<PostgresParams<T>> & { [K in ReservedParamKeys]?: never }
|
|
116
126
|
|
|
127
|
+
export type SubsetParams = {
|
|
128
|
+
where?: string
|
|
129
|
+
params?: Record<string, string>
|
|
130
|
+
limit?: number
|
|
131
|
+
offset?: number
|
|
132
|
+
orderBy?: string
|
|
133
|
+
}
|
|
134
|
+
|
|
117
135
|
type ReservedParamKeys =
|
|
118
136
|
| typeof LIVE_CACHE_BUSTER_QUERY_PARAM
|
|
119
137
|
| typeof SHAPE_HANDLE_QUERY_PARAM
|
|
120
138
|
| typeof LIVE_QUERY_PARAM
|
|
121
139
|
| typeof OFFSET_QUERY_PARAM
|
|
140
|
+
| `subset__${string}`
|
|
122
141
|
|
|
123
142
|
/**
|
|
124
143
|
* External headers type - what users provide.
|
|
@@ -258,6 +277,11 @@ export interface ShapeStreamOptions<T = never> {
|
|
|
258
277
|
*/
|
|
259
278
|
experimentalLiveSse?: boolean
|
|
260
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Initial data loading mode
|
|
282
|
+
*/
|
|
283
|
+
mode?: LogMode
|
|
284
|
+
|
|
261
285
|
signal?: AbortSignal
|
|
262
286
|
fetchClient?: typeof fetch
|
|
263
287
|
backoffOptions?: BackoffOptions
|
|
@@ -293,8 +317,20 @@ export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
|
|
|
293
317
|
lastOffset: Offset
|
|
294
318
|
shapeHandle?: string
|
|
295
319
|
error?: unknown
|
|
320
|
+
mode: LogMode
|
|
296
321
|
|
|
297
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
|
+
}>
|
|
298
334
|
}
|
|
299
335
|
|
|
300
336
|
/**
|
|
@@ -383,8 +419,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
383
419
|
#liveCacheBuster: string // Seconds since our Electric Epoch 😎
|
|
384
420
|
#lastSyncedAt?: number // unix time
|
|
385
421
|
#isUpToDate: boolean = false
|
|
422
|
+
#isMidStream: boolean = true
|
|
386
423
|
#connected: boolean = false
|
|
387
424
|
#shapeHandle?: string
|
|
425
|
+
#mode: LogMode
|
|
388
426
|
#schema?: Schema
|
|
389
427
|
#onError?: ShapeStreamErrorHandler
|
|
390
428
|
#requestAbortController?: AbortController
|
|
@@ -393,6 +431,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
393
431
|
#tickPromiseResolver?: () => void
|
|
394
432
|
#tickPromiseRejecter?: (reason?: unknown) => void
|
|
395
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
|
|
396
438
|
|
|
397
439
|
constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
|
|
398
440
|
this.options = { subscribe: true, ...options }
|
|
@@ -405,6 +447,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
405
447
|
options.transformer
|
|
406
448
|
)
|
|
407
449
|
this.#onError = this.options.onError
|
|
450
|
+
this.#mode = this.options.mode ?? `full`
|
|
408
451
|
|
|
409
452
|
const baseFetchClient =
|
|
410
453
|
options.fetchClient ??
|
|
@@ -447,6 +490,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
447
490
|
return this.#lastOffset
|
|
448
491
|
}
|
|
449
492
|
|
|
493
|
+
get mode() {
|
|
494
|
+
return this.#mode
|
|
495
|
+
}
|
|
496
|
+
|
|
450
497
|
async #start(): Promise<void> {
|
|
451
498
|
this.#started = true
|
|
452
499
|
|
|
@@ -573,7 +620,11 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
573
620
|
return this.#requestShape()
|
|
574
621
|
}
|
|
575
622
|
|
|
576
|
-
async #constructUrl(
|
|
623
|
+
async #constructUrl(
|
|
624
|
+
url: string,
|
|
625
|
+
resumingFromPause: boolean,
|
|
626
|
+
subsetParams?: SubsetParams
|
|
627
|
+
) {
|
|
577
628
|
// Resolve headers and params in parallel
|
|
578
629
|
const [requestHeaders, params] = await Promise.all([
|
|
579
630
|
resolveHeaders(this.options.headers),
|
|
@@ -583,9 +634,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
583
634
|
])
|
|
584
635
|
|
|
585
636
|
// Validate params after resolution
|
|
586
|
-
if (params)
|
|
587
|
-
validateParams(params)
|
|
588
|
-
}
|
|
637
|
+
if (params) validateParams(params)
|
|
589
638
|
|
|
590
639
|
const fetchUrl = new URL(url)
|
|
591
640
|
|
|
@@ -612,8 +661,22 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
612
661
|
}
|
|
613
662
|
}
|
|
614
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
|
+
|
|
615
677
|
// Add Electric's internal parameters
|
|
616
678
|
fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, this.#lastOffset)
|
|
679
|
+
fetchUrl.searchParams.set(LOG_MODE_QUERY_PARAM, this.#mode)
|
|
617
680
|
|
|
618
681
|
if (this.#isUpToDate) {
|
|
619
682
|
// If we are resuming from a paused state, we don't want to perform a live request
|
|
@@ -705,6 +768,9 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
705
768
|
async #onMessages(batch: Array<Message<T>>, isSseMessage = false) {
|
|
706
769
|
// Update isUpToDate
|
|
707
770
|
if (batch.length > 0) {
|
|
771
|
+
// Set isMidStream to true when we receive any data
|
|
772
|
+
this.#isMidStream = true
|
|
773
|
+
|
|
708
774
|
const lastMessage = batch[batch.length - 1]
|
|
709
775
|
if (isUpToDateMessage(lastMessage)) {
|
|
710
776
|
if (isSseMessage) {
|
|
@@ -718,9 +784,21 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
718
784
|
}
|
|
719
785
|
this.#lastSyncedAt = Date.now()
|
|
720
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?.()
|
|
721
791
|
}
|
|
722
792
|
|
|
723
|
-
|
|
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)
|
|
724
802
|
}
|
|
725
803
|
}
|
|
726
804
|
|
|
@@ -904,6 +982,24 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
904
982
|
return this.#tickPromise
|
|
905
983
|
}
|
|
906
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
|
+
|
|
907
1003
|
/**
|
|
908
1004
|
* Refreshes the shape stream.
|
|
909
1005
|
* This preemptively aborts any ongoing long poll and reconnects without
|
|
@@ -976,8 +1072,108 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
976
1072
|
this.#liveCacheBuster = ``
|
|
977
1073
|
this.#shapeHandle = handle
|
|
978
1074
|
this.#isUpToDate = false
|
|
1075
|
+
this.#isMidStream = true
|
|
979
1076
|
this.#connected = false
|
|
980
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
|
+
}
|
|
981
1177
|
}
|
|
982
1178
|
}
|
|
983
1179
|
|
|
@@ -1007,6 +1203,7 @@ function validateOptions<T>(options: Partial<ShapeStreamOptions<T>>): void {
|
|
|
1007
1203
|
if (
|
|
1008
1204
|
options.offset !== undefined &&
|
|
1009
1205
|
options.offset !== `-1` &&
|
|
1206
|
+
options.offset !== `now` &&
|
|
1010
1207
|
!options.handle
|
|
1011
1208
|
) {
|
|
1012
1209
|
throw new MissingShapeHandleError()
|
package/src/constants.ts
CHANGED
|
@@ -16,6 +16,12 @@ export const WHERE_PARAMS_PARAM = `params`
|
|
|
16
16
|
export const EXPERIMENTAL_LIVE_SSE_QUERY_PARAM = `experimental_live_sse`
|
|
17
17
|
export const FORCE_DISCONNECT_AND_REFRESH = `force-disconnect-and-refresh`
|
|
18
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`
|
|
19
25
|
|
|
20
26
|
// Query parameters that should be passed through when proxying Electric requests
|
|
21
27
|
export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
|
|
@@ -24,4 +30,10 @@ export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
|
|
|
24
30
|
OFFSET_QUERY_PARAM,
|
|
25
31
|
LIVE_CACHE_BUSTER_QUERY_PARAM,
|
|
26
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,
|
|
27
39
|
]
|
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/shape.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Message, Offset, Row } from './types'
|
|
2
2
|
import { isChangeMessage, isControlMessage } from './helpers'
|
|
3
3
|
import { FetchError } from './error'
|
|
4
|
-
import { ShapeStreamInterface } from './client'
|
|
4
|
+
import { LogMode, ShapeStreamInterface } from './client'
|
|
5
5
|
|
|
6
6
|
export type ShapeData<T extends Row<unknown> = Row> = Map<string, T>
|
|
7
7
|
export type ShapeChangedCallback<T extends Row<unknown> = Row> = (data: {
|
|
@@ -52,6 +52,9 @@ export class Shape<T extends Row<unknown> = Row> {
|
|
|
52
52
|
|
|
53
53
|
readonly #data: ShapeData<T> = new Map()
|
|
54
54
|
readonly #subscribers = new Map<number, ShapeChangedCallback<T>>()
|
|
55
|
+
readonly #insertedKeys = new Set<string>()
|
|
56
|
+
readonly #requestedSubSnapshots = new Set<string>()
|
|
57
|
+
#reexecuteSnapshotsPending = false
|
|
55
58
|
#status: ShapeStatus = `syncing`
|
|
56
59
|
#error: FetchError | false = false
|
|
57
60
|
|
|
@@ -125,6 +128,26 @@ export class Shape<T extends Row<unknown> = Row> {
|
|
|
125
128
|
return this.stream.isConnected()
|
|
126
129
|
}
|
|
127
130
|
|
|
131
|
+
/** Current log mode of the underlying stream */
|
|
132
|
+
get mode(): LogMode {
|
|
133
|
+
return this.stream.mode
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Request a snapshot for subset of data. Only available when mode is changes_only.
|
|
138
|
+
* Returns void; data will be emitted via the stream and processed by this Shape.
|
|
139
|
+
*/
|
|
140
|
+
async requestSnapshot(
|
|
141
|
+
params: Parameters<ShapeStreamInterface<T>[`requestSnapshot`]>[0]
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
// Track this snapshot request for future re-execution on shape rotation
|
|
144
|
+
const key = JSON.stringify(params)
|
|
145
|
+
this.#requestedSubSnapshots.add(key)
|
|
146
|
+
// Ensure the stream is up-to-date so schema is available for parsing
|
|
147
|
+
await this.#awaitUpToDate()
|
|
148
|
+
await this.stream.requestSnapshot(params)
|
|
149
|
+
}
|
|
150
|
+
|
|
128
151
|
subscribe(callback: ShapeChangedCallback<T>): () => void {
|
|
129
152
|
const subscriptionId = Math.random()
|
|
130
153
|
|
|
@@ -149,19 +172,43 @@ export class Shape<T extends Row<unknown> = Row> {
|
|
|
149
172
|
messages.forEach((message) => {
|
|
150
173
|
if (isChangeMessage(message)) {
|
|
151
174
|
shouldNotify = this.#updateShapeStatus(`syncing`)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
175
|
+
if (this.mode === `full`) {
|
|
176
|
+
switch (message.headers.operation) {
|
|
177
|
+
case `insert`:
|
|
178
|
+
this.#data.set(message.key, message.value)
|
|
179
|
+
break
|
|
180
|
+
case `update`:
|
|
181
|
+
this.#data.set(message.key, {
|
|
182
|
+
...this.#data.get(message.key)!,
|
|
183
|
+
...message.value,
|
|
184
|
+
})
|
|
185
|
+
break
|
|
186
|
+
case `delete`:
|
|
187
|
+
this.#data.delete(message.key)
|
|
188
|
+
break
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
// changes_only: only apply updates/deletes for keys for which we observed an insert
|
|
192
|
+
switch (message.headers.operation) {
|
|
193
|
+
case `insert`:
|
|
194
|
+
this.#insertedKeys.add(message.key)
|
|
195
|
+
this.#data.set(message.key, message.value)
|
|
196
|
+
break
|
|
197
|
+
case `update`:
|
|
198
|
+
if (this.#insertedKeys.has(message.key)) {
|
|
199
|
+
this.#data.set(message.key, {
|
|
200
|
+
...this.#data.get(message.key)!,
|
|
201
|
+
...message.value,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
break
|
|
205
|
+
case `delete`:
|
|
206
|
+
if (this.#insertedKeys.has(message.key)) {
|
|
207
|
+
this.#data.delete(message.key)
|
|
208
|
+
this.#insertedKeys.delete(message.key)
|
|
209
|
+
}
|
|
210
|
+
break
|
|
211
|
+
}
|
|
165
212
|
}
|
|
166
213
|
}
|
|
167
214
|
|
|
@@ -169,11 +216,18 @@ export class Shape<T extends Row<unknown> = Row> {
|
|
|
169
216
|
switch (message.headers.control) {
|
|
170
217
|
case `up-to-date`:
|
|
171
218
|
shouldNotify = this.#updateShapeStatus(`up-to-date`)
|
|
219
|
+
if (this.#reexecuteSnapshotsPending) {
|
|
220
|
+
this.#reexecuteSnapshotsPending = false
|
|
221
|
+
void this.#reexecuteSnapshots()
|
|
222
|
+
}
|
|
172
223
|
break
|
|
173
224
|
case `must-refetch`:
|
|
174
225
|
this.#data.clear()
|
|
226
|
+
this.#insertedKeys.clear()
|
|
175
227
|
this.#error = false
|
|
176
228
|
shouldNotify = this.#updateShapeStatus(`syncing`)
|
|
229
|
+
// Flag to re-execute sub-snapshots once the new shape is up-to-date
|
|
230
|
+
this.#reexecuteSnapshotsPending = true
|
|
177
231
|
break
|
|
178
232
|
}
|
|
179
233
|
}
|
|
@@ -182,6 +236,42 @@ export class Shape<T extends Row<unknown> = Row> {
|
|
|
182
236
|
if (shouldNotify) this.#notify()
|
|
183
237
|
}
|
|
184
238
|
|
|
239
|
+
async #reexecuteSnapshots(): Promise<void> {
|
|
240
|
+
// Wait until stream is up-to-date again (ensures schema is available)
|
|
241
|
+
await this.#awaitUpToDate()
|
|
242
|
+
|
|
243
|
+
// Re-execute all snapshots concurrently
|
|
244
|
+
await Promise.all(
|
|
245
|
+
Array.from(this.#requestedSubSnapshots).map(async (jsonParams) => {
|
|
246
|
+
try {
|
|
247
|
+
const snapshot = JSON.parse(jsonParams)
|
|
248
|
+
await this.stream.requestSnapshot(snapshot)
|
|
249
|
+
} catch (_) {
|
|
250
|
+
// Ignore and continue; errors will be surfaced via stream onError
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async #awaitUpToDate(): Promise<void> {
|
|
257
|
+
if (this.stream.isUpToDate) return
|
|
258
|
+
await new Promise<void>((resolve) => {
|
|
259
|
+
const check = () => {
|
|
260
|
+
if (this.stream.isUpToDate) {
|
|
261
|
+
clearInterval(interval)
|
|
262
|
+
unsub()
|
|
263
|
+
resolve()
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const interval = setInterval(check, 10)
|
|
267
|
+
const unsub = this.stream.subscribe(
|
|
268
|
+
() => check(),
|
|
269
|
+
() => check()
|
|
270
|
+
)
|
|
271
|
+
check()
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
|
|
185
275
|
#updateShapeStatus(status: ShapeStatus): boolean {
|
|
186
276
|
const stateChanged = this.#status !== status
|
|
187
277
|
this.#status = status
|