@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/dist/cjs/index.cjs +350 -14
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +245 -3
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +245 -3
- package/dist/index.legacy-esm.js +345 -13
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +345 -13
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/client.ts +190 -11
- package/src/column-mapper.ts +357 -0
- package/src/index.ts +7 -0
- package/src/types.ts +33 -2
- package/src/up-to-date-tracker.ts +157 -0
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 & {
|
|
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()
|