@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/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
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { isVisibleInSnapshot } from './helpers'
|
|
2
|
+
import { Row, SnapshotMetadata } from './types'
|
|
3
|
+
import { ChangeMessage } from './types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tracks active snapshots and filters out duplicate change messages that are already included in snapshots.
|
|
7
|
+
*
|
|
8
|
+
* When requesting a snapshot in changes_only mode, we need to track which transactions were included in the
|
|
9
|
+
* snapshot to avoid processing duplicate changes that arrive via the live stream. This class maintains that
|
|
10
|
+
* tracking state and provides methods to:
|
|
11
|
+
*
|
|
12
|
+
* - Add new snapshots for tracking via addSnapshot()
|
|
13
|
+
* - Remove completed snapshots via removeSnapshot()
|
|
14
|
+
* - Check if incoming changes should be filtered via shouldRejectMessage()
|
|
15
|
+
*/
|
|
16
|
+
export class SnapshotTracker {
|
|
17
|
+
private activeSnapshots: Map<
|
|
18
|
+
number,
|
|
19
|
+
{ xmin: bigint; xmax: bigint; xip_list: bigint[]; keys: Set<string> }
|
|
20
|
+
> = new Map()
|
|
21
|
+
private xmaxSnapshots: Map<bigint, Set<number>> = new Map()
|
|
22
|
+
private snapshotsByDatabaseLsn: Map<bigint, Set<number>> = new Map()
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Add a new snapshot for tracking
|
|
26
|
+
*/
|
|
27
|
+
addSnapshot(metadata: SnapshotMetadata, keys: Set<string>): void {
|
|
28
|
+
this.activeSnapshots.set(metadata.snapshot_mark, {
|
|
29
|
+
xmin: BigInt(metadata.xmin),
|
|
30
|
+
xmax: BigInt(metadata.xmax),
|
|
31
|
+
xip_list: metadata.xip_list.map(BigInt),
|
|
32
|
+
keys,
|
|
33
|
+
})
|
|
34
|
+
const xmaxSet =
|
|
35
|
+
this.xmaxSnapshots
|
|
36
|
+
.get(BigInt(metadata.xmax))
|
|
37
|
+
?.add(metadata.snapshot_mark) ?? new Set([metadata.snapshot_mark])
|
|
38
|
+
this.xmaxSnapshots.set(BigInt(metadata.xmax), xmaxSet)
|
|
39
|
+
const databaseLsnSet =
|
|
40
|
+
this.snapshotsByDatabaseLsn
|
|
41
|
+
.get(BigInt(metadata.database_lsn))
|
|
42
|
+
?.add(metadata.snapshot_mark) ?? new Set([metadata.snapshot_mark])
|
|
43
|
+
this.snapshotsByDatabaseLsn.set(
|
|
44
|
+
BigInt(metadata.database_lsn),
|
|
45
|
+
databaseLsnSet
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Remove a snapshot from tracking
|
|
51
|
+
*/
|
|
52
|
+
removeSnapshot(snapshotMark: number): void {
|
|
53
|
+
this.activeSnapshots.delete(snapshotMark)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check if a change message should be filtered because its already in an active snapshot
|
|
58
|
+
* Returns true if the message should be filtered out (not processed)
|
|
59
|
+
*/
|
|
60
|
+
shouldRejectMessage(message: ChangeMessage<Row<unknown>>): boolean {
|
|
61
|
+
const txids = message.headers.txids || []
|
|
62
|
+
if (txids.length === 0) return false
|
|
63
|
+
|
|
64
|
+
const xid = Math.max(...txids) // Use the maximum transaction ID
|
|
65
|
+
|
|
66
|
+
for (const [xmax, snapshots] of this.xmaxSnapshots.entries()) {
|
|
67
|
+
if (xid >= xmax) {
|
|
68
|
+
for (const snapshot of snapshots) {
|
|
69
|
+
this.removeSnapshot(snapshot)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return [...this.activeSnapshots.values()].some(
|
|
75
|
+
(x) => x.keys.has(message.key) && isVisibleInSnapshot(xid, x)
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
lastSeenUpdate(newDatabaseLsn: bigint): void {
|
|
80
|
+
for (const [dbLsn, snapshots] of this.snapshotsByDatabaseLsn.entries()) {
|
|
81
|
+
if (dbLsn <= newDatabaseLsn) {
|
|
82
|
+
for (const snapshot of snapshots) {
|
|
83
|
+
this.removeSnapshot(snapshot)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -14,10 +14,33 @@ export type Value<Extensions = never> =
|
|
|
14
14
|
|
|
15
15
|
export type Row<Extensions = never> = Record<string, Value<Extensions>>
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// Check if `T` extends the base Row type without extensions
|
|
18
|
+
// if yes, it has no extensions so we return `never`
|
|
19
|
+
// otherwise, we infer the extensions from the Row type
|
|
20
|
+
export type GetExtensions<T> = [T] extends [Row<never>]
|
|
21
|
+
? never
|
|
22
|
+
: [T] extends [Row<infer E>]
|
|
23
|
+
? E
|
|
24
|
+
: never
|
|
25
|
+
|
|
26
|
+
export type Offset =
|
|
27
|
+
| `-1`
|
|
28
|
+
| `now`
|
|
29
|
+
| `${number}_${number}`
|
|
30
|
+
| `${bigint}_${number}`
|
|
31
|
+
|
|
32
|
+
/** Information about transaction visibility for a snapshot. All fields are encoded as strings, but should be treated as uint64. */
|
|
33
|
+
export type PostgresSnapshot = {
|
|
34
|
+
xmin: `${bigint}`
|
|
35
|
+
xmax: `${bigint}`
|
|
36
|
+
xip_list: `${bigint}`[]
|
|
37
|
+
}
|
|
19
38
|
|
|
20
|
-
export type
|
|
39
|
+
export type NormalizedPgSnapshot = {
|
|
40
|
+
xmin: bigint
|
|
41
|
+
xmax: bigint
|
|
42
|
+
xip_list: bigint[]
|
|
43
|
+
}
|
|
21
44
|
|
|
22
45
|
interface Header {
|
|
23
46
|
[key: Exclude<string, `operation` | `control`>]: Value
|
|
@@ -26,17 +49,19 @@ interface Header {
|
|
|
26
49
|
export type Operation = `insert` | `update` | `delete`
|
|
27
50
|
|
|
28
51
|
export type ControlMessage = {
|
|
29
|
-
headers:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
52
|
+
headers:
|
|
53
|
+
| (Header & {
|
|
54
|
+
control: `up-to-date` | `must-refetch`
|
|
55
|
+
global_last_seen_lsn?: string
|
|
56
|
+
})
|
|
57
|
+
| (Header & { control: `snapshot-end` } & PostgresSnapshot)
|
|
33
58
|
}
|
|
34
59
|
|
|
35
60
|
export type ChangeMessage<T extends Row<unknown> = Row> = {
|
|
36
61
|
key: string
|
|
37
62
|
value: T
|
|
38
63
|
old_value?: Partial<T> // Only provided for updates if `replica` is `full`
|
|
39
|
-
headers: Header & { operation: Operation }
|
|
64
|
+
headers: Header & { operation: Operation; txids?: number[] }
|
|
40
65
|
}
|
|
41
66
|
|
|
42
67
|
// Define the type for a record
|
|
@@ -125,3 +150,18 @@ export type TypedMessages<T extends Row<unknown> = Row> = {
|
|
|
125
150
|
}
|
|
126
151
|
|
|
127
152
|
export type MaybePromise<T> = T | Promise<T>
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Metadata that allows the consumer to know which changes have been incorporated into this snapshot.
|
|
156
|
+
*
|
|
157
|
+
* For any data that has a known transaction ID `xid` (and e.g. a key that's part of the snapshot):
|
|
158
|
+
* - if `xid` < `xmin` - included, change can be skipped
|
|
159
|
+
* - if `xid` < `xmax` AND `xid` not in `xip` - included, change can be skipped
|
|
160
|
+
* - if `xid` < `xmax` AND `xid` in `xip` - parallel, not included, change must be processed
|
|
161
|
+
* - if `xid` >= `xmax` - not included, change must be processed, and we can stop filtering after we see this
|
|
162
|
+
*/
|
|
163
|
+
export type SnapshotMetadata = {
|
|
164
|
+
/** Random number that's reflected in the `snapshot_mark` header on the snapshot items. */
|
|
165
|
+
snapshot_mark: number
|
|
166
|
+
database_lsn: string
|
|
167
|
+
} & PostgresSnapshot
|