@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.
@@ -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
- // Parse the row values
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()