@electric-sql/client 1.0.10 → 1.0.12

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.
@@ -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
@@ -23,7 +23,24 @@ export type GetExtensions<T> = [T] extends [Row<never>]
23
23
  ? E
24
24
  : never
25
25
 
26
- export type Offset = `-1` | `${number}_${number}` | `${bigint}_${number}`
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
+ }
38
+
39
+ export type NormalizedPgSnapshot = {
40
+ xmin: bigint
41
+ xmax: bigint
42
+ xip_list: bigint[]
43
+ }
27
44
 
28
45
  interface Header {
29
46
  [key: Exclude<string, `operation` | `control`>]: Value
@@ -32,17 +49,19 @@ interface Header {
32
49
  export type Operation = `insert` | `update` | `delete`
33
50
 
34
51
  export type ControlMessage = {
35
- headers: Header & {
36
- control: `up-to-date` | `must-refetch`
37
- global_last_seen_lsn?: string
38
- }
52
+ headers:
53
+ | (Header & {
54
+ control: `up-to-date` | `must-refetch`
55
+ global_last_seen_lsn?: string
56
+ })
57
+ | (Header & { control: `snapshot-end` } & PostgresSnapshot)
39
58
  }
40
59
 
41
60
  export type ChangeMessage<T extends Row<unknown> = Row> = {
42
61
  key: string
43
62
  value: T
44
63
  old_value?: Partial<T> // Only provided for updates if `replica` is `full`
45
- headers: Header & { operation: Operation }
64
+ headers: Header & { operation: Operation; txids?: number[] }
46
65
  }
47
66
 
48
67
  // Define the type for a record
@@ -131,3 +150,18 @@ export type TypedMessages<T extends Row<unknown> = Row> = {
131
150
  }
132
151
 
133
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