@electric-sql/client 1.1.5 → 1.2.1

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/types.ts CHANGED
@@ -43,10 +43,30 @@ export type NormalizedPgSnapshot = {
43
43
  }
44
44
 
45
45
  interface Header {
46
- [key: Exclude<string, `operation` | `control`>]: Value
46
+ [key: Exclude<string, `operation` | `control` | `event`>]: Value
47
47
  }
48
48
 
49
49
  export type Operation = `insert` | `update` | `delete`
50
+ /**
51
+ * A tag is a string identifying a reason for this row to be part of the shape.
52
+ *
53
+ * Tags can be composite, but they are always sent as a single string. Compound tags
54
+ * are separated by `|`. It's up to the client to split the tag into its components
55
+ * in order to react to move-outs correctly. Tag parts are guaranteed to not contain an
56
+ * unescaped `|` character (escaped as `\\|`) or be a literal `*`.
57
+ *
58
+ * Composite tag width is guaranteed to be fixed for a given shape.
59
+ */
60
+ export type MoveTag = string
61
+
62
+ /**
63
+ * A move-out pattern is a position and a value. The position is the index of the column
64
+ * that is being moved out. The value is the value of the column that is being moved out.
65
+ *
66
+ * Tag width and value order is fixed for a given shape, so the client can determine
67
+ * which tags match this pattern.
68
+ */
69
+ export type MoveOutPattern = { pos: number; value: string }
50
70
 
51
71
  export type ControlMessage = {
52
72
  headers:
@@ -57,16 +77,27 @@ export type ControlMessage = {
57
77
  | (Header & { control: `snapshot-end` } & PostgresSnapshot)
58
78
  }
59
79
 
80
+ export type EventMessage = {
81
+ headers: Header & { event: `move-out`; patterns: MoveOutPattern[] }
82
+ }
83
+
60
84
  export type ChangeMessage<T extends Row<unknown> = Row> = {
61
85
  key: string
62
86
  value: T
63
87
  old_value?: Partial<T> // Only provided for updates if `replica` is `full`
64
- headers: Header & { operation: Operation; txids?: number[] }
88
+ headers: Header & {
89
+ operation: Operation
90
+ txids?: number[]
91
+ /** Tags will always be present for changes if the shape has a subquery in its where clause, and are omitted otherwise.*/
92
+ tags?: MoveTag[]
93
+ removed_tags?: MoveTag[]
94
+ }
65
95
  }
66
96
 
67
97
  // Define the type for a record
68
98
  export type Message<T extends Row<unknown> = Row> =
69
99
  | ControlMessage
100
+ | EventMessage
70
101
  | ChangeMessage<T>
71
102
 
72
103
  /**
@@ -0,0 +1,157 @@
1
+ interface UpToDateEntry {
2
+ timestamp: number
3
+ cursor: string
4
+ }
5
+
6
+ /**
7
+ * Tracks up-to-date messages to detect when we're replaying cached responses.
8
+ *
9
+ * When a shape receives an up-to-date, we record the timestamp and cursor in localStorage.
10
+ * On page refresh, if we find a recent timestamp (< 60s), we know we'll be replaying
11
+ * cached responses. We suppress their up-to-date notifications until we see a NEW cursor
12
+ * (different from the last recorded one), which indicates fresh data from the server.
13
+ *
14
+ * localStorage writes are throttled to once per 60 seconds to avoid performance issues
15
+ * with frequent updates. In-memory data is always kept current.
16
+ */
17
+ export class UpToDateTracker {
18
+ private data: Record<string, UpToDateEntry> = {}
19
+ private readonly storageKey = `electric_up_to_date_tracker`
20
+ private readonly cacheTTL = 60_000 // 60s to match typical CDN s-maxage cache duration
21
+ private readonly maxEntries = 250
22
+ private readonly writeThrottleMs = 60_000 // Throttle localStorage writes to once per 60s
23
+ private lastWriteTime = 0
24
+ private pendingSaveTimer?: ReturnType<typeof setTimeout>
25
+
26
+ constructor() {
27
+ this.load()
28
+ this.cleanup()
29
+ }
30
+
31
+ /**
32
+ * Records that a shape received an up-to-date message with a specific cursor.
33
+ * This timestamp and cursor are used to detect cache replay scenarios.
34
+ * Updates in-memory immediately, but throttles localStorage writes.
35
+ */
36
+ recordUpToDate(shapeKey: string, cursor: string): void {
37
+ this.data[shapeKey] = {
38
+ timestamp: Date.now(),
39
+ cursor,
40
+ }
41
+
42
+ // Implement LRU eviction if we exceed max entries
43
+ const keys = Object.keys(this.data)
44
+ if (keys.length > this.maxEntries) {
45
+ const oldest = keys.reduce((min, k) =>
46
+ this.data[k].timestamp < this.data[min].timestamp ? k : min
47
+ )
48
+ delete this.data[oldest]
49
+ }
50
+
51
+ this.scheduleSave()
52
+ }
53
+
54
+ /**
55
+ * Schedules a throttled save to localStorage.
56
+ * Writes immediately if enough time has passed, otherwise schedules for later.
57
+ */
58
+ private scheduleSave(): void {
59
+ const now = Date.now()
60
+ const timeSinceLastWrite = now - this.lastWriteTime
61
+
62
+ if (timeSinceLastWrite >= this.writeThrottleMs) {
63
+ // Enough time has passed, write immediately
64
+ this.lastWriteTime = now
65
+ this.save()
66
+ } else if (!this.pendingSaveTimer) {
67
+ // Schedule a write for when the throttle period expires
68
+ const delay = this.writeThrottleMs - timeSinceLastWrite
69
+ this.pendingSaveTimer = setTimeout(() => {
70
+ this.lastWriteTime = Date.now()
71
+ this.pendingSaveTimer = undefined
72
+ this.save()
73
+ }, delay)
74
+ }
75
+ // else: a save is already scheduled, no need to do anything
76
+ }
77
+
78
+ /**
79
+ * Checks if we should enter replay mode for this shape.
80
+ * Returns the last seen cursor if there's a recent up-to-date (< 60s),
81
+ * which means we'll likely be replaying cached responses.
82
+ * Returns null if no recent up-to-date exists.
83
+ */
84
+ shouldEnterReplayMode(shapeKey: string): string | null {
85
+ const entry = this.data[shapeKey]
86
+ if (!entry) {
87
+ return null
88
+ }
89
+
90
+ const age = Date.now() - entry.timestamp
91
+ if (age >= this.cacheTTL) {
92
+ return null
93
+ }
94
+
95
+ return entry.cursor
96
+ }
97
+
98
+ /**
99
+ * Cleans up expired entries from the cache.
100
+ * Called on initialization and can be called periodically.
101
+ */
102
+ private cleanup(): void {
103
+ const now = Date.now()
104
+ const keys = Object.keys(this.data)
105
+ let modified = false
106
+
107
+ for (const key of keys) {
108
+ const age = now - this.data[key].timestamp
109
+ if (age > this.cacheTTL) {
110
+ delete this.data[key]
111
+ modified = true
112
+ }
113
+ }
114
+
115
+ if (modified) {
116
+ this.save()
117
+ }
118
+ }
119
+
120
+ private save(): void {
121
+ if (typeof localStorage === `undefined`) return
122
+ try {
123
+ localStorage.setItem(this.storageKey, JSON.stringify(this.data))
124
+ } catch {
125
+ // Ignore localStorage errors (quota exceeded, etc.)
126
+ }
127
+ }
128
+
129
+ private load(): void {
130
+ if (typeof localStorage === `undefined`) return
131
+ try {
132
+ const stored = localStorage.getItem(this.storageKey)
133
+ if (stored) {
134
+ this.data = JSON.parse(stored)
135
+ }
136
+ } catch {
137
+ // Ignore localStorage errors, start fresh
138
+ this.data = {}
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Clears all tracked up-to-date timestamps.
144
+ * Useful for testing or manual cache invalidation.
145
+ */
146
+ clear(): void {
147
+ this.data = {}
148
+ if (this.pendingSaveTimer) {
149
+ clearTimeout(this.pendingSaveTimer)
150
+ this.pendingSaveTimer = undefined
151
+ }
152
+ this.save()
153
+ }
154
+ }
155
+
156
+ // Module-level singleton instance
157
+ export const upToDateTracker = new UpToDateTracker()