@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/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
- switch (message.headers.operation) {
153
- case `insert`:
154
- this.#data.set(message.key, message.value)
155
- break
156
- case `update`:
157
- this.#data.set(message.key, {
158
- ...this.#data.get(message.key)!,
159
- ...message.value,
160
- })
161
- break
162
- case `delete`:
163
- this.#data.delete(message.key)
164
- break
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
- export type GetExtensions<T extends Row<unknown>> =
18
- T extends Row<infer Extensions> ? Extensions : never
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 Offset = `-1` | `${number}_${number}` | `${bigint}_${number}`
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: Header & {
30
- control: `up-to-date` | `must-refetch`
31
- global_last_seen_lsn?: string
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