@electric-sql/client 1.1.5 → 1.2.0
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 +346 -13
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +212 -1
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +212 -1
- package/dist/index.legacy-esm.js +341 -12
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +341 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +183 -10
- package/src/column-mapper.ts +357 -0
- package/src/index.ts +7 -0
- package/src/up-to-date-tracker.ts +157 -0
|
@@ -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()
|