@electric-sql/client 1.1.4 → 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 +426 -50
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +229 -9
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +229 -9
- package/dist/index.legacy-esm.js +419 -47
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +421 -49
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +254 -42
- package/src/column-mapper.ts +357 -0
- package/src/index.ts +7 -0
- package/src/parser.ts +45 -7
- package/src/up-to-date-tracker.ts +157 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { Schema } from './types'
|
|
2
|
+
|
|
3
|
+
type DbColumnName = string
|
|
4
|
+
type AppColumnName = string
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A bidirectional column mapper that handles transforming column **names**
|
|
8
|
+
* between database format (e.g., snake_case) and application format (e.g., camelCase).
|
|
9
|
+
*
|
|
10
|
+
* **Important**: ColumnMapper only transforms column names, not column values or types.
|
|
11
|
+
* For type conversions (e.g., string → Date), use the `parser` option.
|
|
12
|
+
* For value transformations (e.g., encryption), use the `transformer` option.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* const mapper = snakeCamelMapper()
|
|
17
|
+
* mapper.decode('user_id') // 'userId'
|
|
18
|
+
* mapper.encode('userId') // 'user_id'
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export interface ColumnMapper {
|
|
22
|
+
/**
|
|
23
|
+
* Transform a column name from database format to application format.
|
|
24
|
+
* Applied to column names in query results.
|
|
25
|
+
*/
|
|
26
|
+
decode: (dbColumnName: DbColumnName) => AppColumnName
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Transform a column name from application format to database format.
|
|
30
|
+
* Applied to column names in WHERE clauses and other query parameters.
|
|
31
|
+
*/
|
|
32
|
+
encode: (appColumnName: AppColumnName) => DbColumnName
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Converts a snake_case string to camelCase.
|
|
37
|
+
*
|
|
38
|
+
* Handles edge cases:
|
|
39
|
+
* - Preserves leading underscores: `_user_id` → `_userId`
|
|
40
|
+
* - Preserves trailing underscores: `user_id_` → `userId_`
|
|
41
|
+
* - Collapses multiple underscores: `user__id` → `userId`
|
|
42
|
+
* - Normalizes to lowercase first: `user_Column` → `userColumn`
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* snakeToCamel('user_id') // 'userId'
|
|
46
|
+
* snakeToCamel('project_id') // 'projectId'
|
|
47
|
+
* snakeToCamel('created_at') // 'createdAt'
|
|
48
|
+
* snakeToCamel('_private') // '_private'
|
|
49
|
+
* snakeToCamel('user__id') // 'userId'
|
|
50
|
+
* snakeToCamel('user_id_') // 'userId_'
|
|
51
|
+
*/
|
|
52
|
+
export function snakeToCamel(str: string): string {
|
|
53
|
+
// Preserve leading underscores
|
|
54
|
+
const leadingUnderscores = str.match(/^_+/)?.[0] ?? ``
|
|
55
|
+
const withoutLeading = str.slice(leadingUnderscores.length)
|
|
56
|
+
|
|
57
|
+
// Preserve trailing underscores for round-trip safety
|
|
58
|
+
const trailingUnderscores = withoutLeading.match(/_+$/)?.[0] ?? ``
|
|
59
|
+
const core = trailingUnderscores
|
|
60
|
+
? withoutLeading.slice(
|
|
61
|
+
0,
|
|
62
|
+
withoutLeading.length - trailingUnderscores.length
|
|
63
|
+
)
|
|
64
|
+
: withoutLeading
|
|
65
|
+
|
|
66
|
+
// Convert to lowercase
|
|
67
|
+
const normalized = core.toLowerCase()
|
|
68
|
+
|
|
69
|
+
// Convert snake_case to camelCase (handling multiple underscores)
|
|
70
|
+
const camelCased = normalized.replace(/_+([a-z])/g, (_, letter) =>
|
|
71
|
+
letter.toUpperCase()
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return leadingUnderscores + camelCased + trailingUnderscores
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Converts a camelCase string to snake_case.
|
|
79
|
+
*
|
|
80
|
+
* Handles consecutive capitals (acronyms) properly:
|
|
81
|
+
* - `userID` → `user_id`
|
|
82
|
+
* - `userHTTPSURL` → `user_https_url`
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* camelToSnake('userId') // 'user_id'
|
|
86
|
+
* camelToSnake('projectId') // 'project_id'
|
|
87
|
+
* camelToSnake('createdAt') // 'created_at'
|
|
88
|
+
* camelToSnake('userID') // 'user_id'
|
|
89
|
+
* camelToSnake('parseHTMLString') // 'parse_html_string'
|
|
90
|
+
*/
|
|
91
|
+
export function camelToSnake(str: string): string {
|
|
92
|
+
return (
|
|
93
|
+
str
|
|
94
|
+
// Insert underscore before uppercase letters that follow lowercase letters
|
|
95
|
+
// e.g., userId -> user_Id
|
|
96
|
+
.replace(/([a-z])([A-Z])/g, `$1_$2`)
|
|
97
|
+
// Insert underscore before uppercase letters that are followed by lowercase letters
|
|
98
|
+
// This handles acronyms: userID -> user_ID, but parseHTMLString -> parse_HTML_String
|
|
99
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, `$1_$2`)
|
|
100
|
+
.toLowerCase()
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Creates a column mapper from an explicit mapping of database columns to application columns.
|
|
106
|
+
*
|
|
107
|
+
* @param mapping - Object mapping database column names (keys) to application column names (values)
|
|
108
|
+
* @returns A ColumnMapper that can encode and decode column names bidirectionally
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* const mapper = createColumnMapper({
|
|
112
|
+
* user_id: 'userId',
|
|
113
|
+
* project_id: 'projectId',
|
|
114
|
+
* created_at: 'createdAt'
|
|
115
|
+
* })
|
|
116
|
+
*
|
|
117
|
+
* // Use with ShapeStream
|
|
118
|
+
* const stream = new ShapeStream({
|
|
119
|
+
* url: 'http://localhost:3000/v1/shape',
|
|
120
|
+
* params: { table: 'todos' },
|
|
121
|
+
* columnMapper: mapper
|
|
122
|
+
* })
|
|
123
|
+
*/
|
|
124
|
+
export function createColumnMapper(
|
|
125
|
+
mapping: Record<string, string>
|
|
126
|
+
): ColumnMapper {
|
|
127
|
+
// Build reverse mapping: app name -> db name
|
|
128
|
+
const reverseMapping: Record<string, string> = {}
|
|
129
|
+
for (const [dbName, appName] of Object.entries(mapping)) {
|
|
130
|
+
reverseMapping[appName] = dbName
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
decode: (dbColumnName: string) => {
|
|
135
|
+
return mapping[dbColumnName] ?? dbColumnName
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
encode: (appColumnName: string) => {
|
|
139
|
+
return reverseMapping[appColumnName] ?? appColumnName
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Encodes column names in a WHERE clause using the provided encoder function.
|
|
146
|
+
* Uses regex to identify column references and replace them.
|
|
147
|
+
*
|
|
148
|
+
* Handles common SQL patterns:
|
|
149
|
+
* - Simple comparisons: columnName = $1
|
|
150
|
+
* - Function calls: LOWER(columnName)
|
|
151
|
+
* - Qualified names: table.columnName
|
|
152
|
+
* - Operators: columnName IS NULL, columnName IN (...)
|
|
153
|
+
* - Quoted strings: Preserves string literals unchanged
|
|
154
|
+
*
|
|
155
|
+
* Note: This uses regex-based replacement which works for most common cases
|
|
156
|
+
* but may not handle all complex SQL expressions perfectly. For complex queries,
|
|
157
|
+
* test thoroughly or use database column names directly in WHERE clauses.
|
|
158
|
+
*
|
|
159
|
+
* @param whereClause - The WHERE clause string to encode
|
|
160
|
+
* @param encode - Optional encoder function. If undefined, returns whereClause unchanged.
|
|
161
|
+
* @returns The encoded WHERE clause
|
|
162
|
+
*
|
|
163
|
+
* @internal
|
|
164
|
+
*/
|
|
165
|
+
export function encodeWhereClause(
|
|
166
|
+
whereClause: string | undefined,
|
|
167
|
+
encode?: (columnName: string) => string
|
|
168
|
+
): string {
|
|
169
|
+
if (!whereClause || !encode) return whereClause ?? ``
|
|
170
|
+
|
|
171
|
+
// SQL keywords that should not be transformed (common ones)
|
|
172
|
+
const sqlKeywords = new Set([
|
|
173
|
+
`SELECT`,
|
|
174
|
+
`FROM`,
|
|
175
|
+
`WHERE`,
|
|
176
|
+
`AND`,
|
|
177
|
+
`OR`,
|
|
178
|
+
`NOT`,
|
|
179
|
+
`IN`,
|
|
180
|
+
`IS`,
|
|
181
|
+
`NULL`,
|
|
182
|
+
`NULLS`,
|
|
183
|
+
`FIRST`,
|
|
184
|
+
`LAST`,
|
|
185
|
+
`TRUE`,
|
|
186
|
+
`FALSE`,
|
|
187
|
+
`LIKE`,
|
|
188
|
+
`ILIKE`,
|
|
189
|
+
`BETWEEN`,
|
|
190
|
+
`ASC`,
|
|
191
|
+
`DESC`,
|
|
192
|
+
`LIMIT`,
|
|
193
|
+
`OFFSET`,
|
|
194
|
+
`ORDER`,
|
|
195
|
+
`BY`,
|
|
196
|
+
`GROUP`,
|
|
197
|
+
`HAVING`,
|
|
198
|
+
`DISTINCT`,
|
|
199
|
+
`AS`,
|
|
200
|
+
`ON`,
|
|
201
|
+
`JOIN`,
|
|
202
|
+
`LEFT`,
|
|
203
|
+
`RIGHT`,
|
|
204
|
+
`INNER`,
|
|
205
|
+
`OUTER`,
|
|
206
|
+
`CROSS`,
|
|
207
|
+
`CASE`,
|
|
208
|
+
`WHEN`,
|
|
209
|
+
`THEN`,
|
|
210
|
+
`ELSE`,
|
|
211
|
+
`END`,
|
|
212
|
+
`CAST`,
|
|
213
|
+
`LOWER`,
|
|
214
|
+
`UPPER`,
|
|
215
|
+
`COALESCE`,
|
|
216
|
+
`NULLIF`,
|
|
217
|
+
])
|
|
218
|
+
|
|
219
|
+
// Track positions of quoted strings and double-quoted identifiers to skip them
|
|
220
|
+
const quotedRanges: Array<{ start: number; end: number }> = []
|
|
221
|
+
|
|
222
|
+
// Find all single-quoted strings and double-quoted identifiers
|
|
223
|
+
let pos = 0
|
|
224
|
+
while (pos < whereClause.length) {
|
|
225
|
+
const ch = whereClause[pos]
|
|
226
|
+
if (ch === `'` || ch === `"`) {
|
|
227
|
+
const start = pos
|
|
228
|
+
const quoteChar = ch
|
|
229
|
+
pos++ // Skip opening quote
|
|
230
|
+
// Find closing quote, handling escaped quotes ('' or "")
|
|
231
|
+
while (pos < whereClause.length) {
|
|
232
|
+
if (whereClause[pos] === quoteChar) {
|
|
233
|
+
if (whereClause[pos + 1] === quoteChar) {
|
|
234
|
+
pos += 2 // Skip escaped quote
|
|
235
|
+
} else {
|
|
236
|
+
pos++ // Skip closing quote
|
|
237
|
+
break
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
pos++
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
quotedRanges.push({ start, end: pos })
|
|
244
|
+
} else {
|
|
245
|
+
pos++
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Helper to check if position is within a quoted string or double-quoted identifier
|
|
250
|
+
const isInQuotedString = (pos: number): boolean => {
|
|
251
|
+
return quotedRanges.some((range) => pos >= range.start && pos < range.end)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Pattern explanation:
|
|
255
|
+
// (?<![a-zA-Z0-9_]) - negative lookbehind: not preceded by identifier char
|
|
256
|
+
// ([a-zA-Z_][a-zA-Z0-9_]*) - capture: valid SQL identifier
|
|
257
|
+
// (?![a-zA-Z0-9_]) - negative lookahead: not followed by identifier char
|
|
258
|
+
//
|
|
259
|
+
// This avoids matching:
|
|
260
|
+
// - Parts of longer identifiers
|
|
261
|
+
// - SQL keywords (handled by checking if result differs from input)
|
|
262
|
+
const identifierPattern =
|
|
263
|
+
/(?<![a-zA-Z0-9_])([a-zA-Z_][a-zA-Z0-9_]*)(?![a-zA-Z0-9_])/g
|
|
264
|
+
|
|
265
|
+
return whereClause.replace(identifierPattern, (match, _p1, offset) => {
|
|
266
|
+
// Don't transform if inside quoted string
|
|
267
|
+
if (isInQuotedString(offset)) {
|
|
268
|
+
return match
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Don't transform SQL keywords
|
|
272
|
+
if (sqlKeywords.has(match.toUpperCase())) {
|
|
273
|
+
return match
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Don't transform parameter placeholders ($1, $2, etc.)
|
|
277
|
+
// This regex won't match them anyway, but being explicit
|
|
278
|
+
if (match.startsWith(`$`)) {
|
|
279
|
+
return match
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Apply encoding
|
|
283
|
+
const encoded = encode(match)
|
|
284
|
+
return encoded
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Creates a column mapper that automatically converts between snake_case and camelCase.
|
|
290
|
+
* This is the most common use case for column mapping.
|
|
291
|
+
*
|
|
292
|
+
* When a schema is provided, it will only map columns that exist in the schema.
|
|
293
|
+
* Otherwise, it will map any column name it encounters.
|
|
294
|
+
*
|
|
295
|
+
* **⚠️ Limitations and Edge Cases:**
|
|
296
|
+
* - **WHERE clause encoding**: Uses regex-based parsing which may not handle all complex
|
|
297
|
+
* SQL expressions. Test thoroughly with your queries, especially those with:
|
|
298
|
+
* - Complex nested expressions
|
|
299
|
+
* - Custom operators or functions
|
|
300
|
+
* - Column names that conflict with SQL keywords
|
|
301
|
+
* - Quoted identifiers (e.g., `"$price"`, `"user-id"`) - not supported
|
|
302
|
+
* - Column names with special characters (non-alphanumeric except underscore)
|
|
303
|
+
* - **Acronym ambiguity**: `userID` → `user_id` → `userId` (ID becomes Id after roundtrip)
|
|
304
|
+
* Use `createColumnMapper()` with explicit mapping if you need exact control
|
|
305
|
+
* - **Type conversion**: This only renames columns, not values. Use `parser` for type conversion
|
|
306
|
+
*
|
|
307
|
+
* **When to use explicit mapping instead:**
|
|
308
|
+
* - You have column names that don't follow snake_case/camelCase patterns
|
|
309
|
+
* - You need exact control over mappings (e.g., `id` → `identifier`)
|
|
310
|
+
* - Your WHERE clauses are complex and automatic encoding fails
|
|
311
|
+
* - You have quoted identifiers or column names with special characters
|
|
312
|
+
*
|
|
313
|
+
* @param schema - Optional database schema to constrain mapping to known columns
|
|
314
|
+
* @returns A ColumnMapper for snake_case ↔ camelCase conversion
|
|
315
|
+
*
|
|
316
|
+
* @example
|
|
317
|
+
* // Basic usage
|
|
318
|
+
* const mapper = snakeCamelMapper()
|
|
319
|
+
*
|
|
320
|
+
* // With schema - only maps columns in schema (recommended)
|
|
321
|
+
* const mapper = snakeCamelMapper(schema)
|
|
322
|
+
*
|
|
323
|
+
* // Use with ShapeStream
|
|
324
|
+
* const stream = new ShapeStream({
|
|
325
|
+
* url: 'http://localhost:3000/v1/shape',
|
|
326
|
+
* params: { table: 'todos' },
|
|
327
|
+
* columnMapper: snakeCamelMapper()
|
|
328
|
+
* })
|
|
329
|
+
*
|
|
330
|
+
* @example
|
|
331
|
+
* // If automatic encoding fails, fall back to manual column names in WHERE clauses:
|
|
332
|
+
* stream.requestSnapshot({
|
|
333
|
+
* where: "user_id = $1", // Use database column names directly if needed
|
|
334
|
+
* params: { "1": "123" }
|
|
335
|
+
* })
|
|
336
|
+
*/
|
|
337
|
+
export function snakeCamelMapper(schema?: Schema): ColumnMapper {
|
|
338
|
+
// If schema provided, build explicit mapping
|
|
339
|
+
if (schema) {
|
|
340
|
+
const mapping: Record<string, string> = {}
|
|
341
|
+
for (const dbColumn of Object.keys(schema)) {
|
|
342
|
+
mapping[dbColumn] = snakeToCamel(dbColumn)
|
|
343
|
+
}
|
|
344
|
+
return createColumnMapper(mapping)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Otherwise, map dynamically
|
|
348
|
+
return {
|
|
349
|
+
decode: (dbColumnName: string) => {
|
|
350
|
+
return snakeToCamel(dbColumnName)
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
encode: (appColumnName: string) => {
|
|
354
|
+
return camelToSnake(appColumnName)
|
|
355
|
+
},
|
|
356
|
+
}
|
|
357
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,3 +9,10 @@ export {
|
|
|
9
9
|
export { FetchError } from './error'
|
|
10
10
|
export { type BackoffOptions, BackoffDefaults } from './fetch'
|
|
11
11
|
export { ELECTRIC_PROTOCOL_QUERY_PARAMS } from './constants'
|
|
12
|
+
export {
|
|
13
|
+
type ColumnMapper,
|
|
14
|
+
createColumnMapper,
|
|
15
|
+
snakeCamelMapper,
|
|
16
|
+
snakeToCamel,
|
|
17
|
+
camelToSnake,
|
|
18
|
+
} from './column-mapper'
|
package/src/parser.ts
CHANGED
|
@@ -122,18 +122,56 @@ export class MessageParser<T extends Row<unknown>> {
|
|
|
122
122
|
typeof value === `object` &&
|
|
123
123
|
value !== null
|
|
124
124
|
) {
|
|
125
|
-
|
|
126
|
-
const row = value as Record<string, Value<GetExtensions<T>>>
|
|
127
|
-
Object.keys(row).forEach((key) => {
|
|
128
|
-
row[key] = this.parseRow(key, row[key] as NullableToken, schema)
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
if (this.transformer) value = this.transformer(value)
|
|
125
|
+
return this.transformMessageValue(value, schema)
|
|
132
126
|
}
|
|
133
127
|
return value
|
|
134
128
|
}) as Result
|
|
135
129
|
}
|
|
136
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Parse an array of ChangeMessages from a snapshot response.
|
|
133
|
+
* Applies type parsing and transformations to the value and old_value properties.
|
|
134
|
+
*/
|
|
135
|
+
parseSnapshotData<Result>(
|
|
136
|
+
messages: Array<unknown>,
|
|
137
|
+
schema: Schema
|
|
138
|
+
): Array<Result> {
|
|
139
|
+
return messages.map((message) => {
|
|
140
|
+
const msg = message as Record<string, unknown>
|
|
141
|
+
|
|
142
|
+
// Transform the value property if it exists
|
|
143
|
+
if (msg.value && typeof msg.value === `object` && msg.value !== null) {
|
|
144
|
+
msg.value = this.transformMessageValue(msg.value, schema)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Transform the old_value property if it exists
|
|
148
|
+
if (
|
|
149
|
+
msg.old_value &&
|
|
150
|
+
typeof msg.old_value === `object` &&
|
|
151
|
+
msg.old_value !== null
|
|
152
|
+
) {
|
|
153
|
+
msg.old_value = this.transformMessageValue(msg.old_value, schema)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return msg as Result
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Transform a message value or old_value object by parsing its columns.
|
|
162
|
+
*/
|
|
163
|
+
private transformMessageValue(
|
|
164
|
+
value: unknown,
|
|
165
|
+
schema: Schema
|
|
166
|
+
): Row<GetExtensions<T>> {
|
|
167
|
+
const row = value as Record<string, Value<GetExtensions<T>>>
|
|
168
|
+
Object.keys(row).forEach((key) => {
|
|
169
|
+
row[key] = this.parseRow(key, row[key] as NullableToken, schema)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
return this.transformer ? this.transformer(row) : row
|
|
173
|
+
}
|
|
174
|
+
|
|
137
175
|
// Parses the message values using the provided parser based on the schema information
|
|
138
176
|
private parseRow(
|
|
139
177
|
key: string,
|
|
@@ -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()
|