@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@electric-sql/client",
3
3
  "description": "Postgres everywhere - your data, in sync, wherever you need it.",
4
- "version": "1.1.5",
4
+ "version": "1.2.0",
5
5
  "author": "ElectricSQL team and contributors.",
6
6
  "bugs": {
7
7
  "url": "https://github.com/electric-sql/electric/issues"
package/src/client.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  SnapshotMetadata,
10
10
  } from './types'
11
11
  import { MessageParser, Parser, TransformFunction } from './parser'
12
+ import { ColumnMapper, encodeWhereClause } from './column-mapper'
12
13
  import { getOffset, isUpToDateMessage, isChangeMessage } from './helpers'
13
14
  import {
14
15
  FetchError,
@@ -59,6 +60,7 @@ import {
59
60
  fetchEventSource,
60
61
  } from '@microsoft/fetch-event-source'
61
62
  import { expiredShapesCache } from './expired-shapes-cache'
63
+ import { upToDateTracker } from './up-to-date-tracker'
62
64
  import { SnapshotTracker } from './snapshot-tracker'
63
65
 
64
66
  const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
@@ -293,8 +295,87 @@ export interface ShapeStreamOptions<T = never> {
293
295
  fetchClient?: typeof fetch
294
296
  backoffOptions?: BackoffOptions
295
297
  parser?: Parser<T>
298
+
299
+ /**
300
+ * Function to transform rows after parsing (e.g., for encryption, type coercion).
301
+ * Applied to data received from Electric.
302
+ *
303
+ * **Note**: If you're using `transformer` solely for column name transformation
304
+ * (e.g., snake_case → camelCase), consider using `columnMapper` instead, which
305
+ * provides bidirectional transformation and automatically encodes WHERE clauses.
306
+ *
307
+ * **Execution order** when both are provided:
308
+ * 1. `columnMapper.decode` runs first (renames columns)
309
+ * 2. `transformer` runs second (transforms values)
310
+ *
311
+ * @example
312
+ * ```typescript
313
+ * // For column renaming only - use columnMapper
314
+ * import { snakeCamelMapper } from '@electric-sql/client'
315
+ * const stream = new ShapeStream({ columnMapper: snakeCamelMapper() })
316
+ * ```
317
+ *
318
+ * @example
319
+ * ```typescript
320
+ * // For value transformation (encryption, etc.) - use transformer
321
+ * const stream = new ShapeStream({
322
+ * transformer: (row) => ({
323
+ * ...row,
324
+ * encrypted_field: decrypt(row.encrypted_field)
325
+ * })
326
+ * })
327
+ * ```
328
+ *
329
+ * @example
330
+ * ```typescript
331
+ * // Use both together
332
+ * const stream = new ShapeStream({
333
+ * columnMapper: snakeCamelMapper(), // Runs first: renames columns
334
+ * transformer: (row) => ({ // Runs second: transforms values
335
+ * ...row,
336
+ * encryptedData: decrypt(row.encryptedData)
337
+ * })
338
+ * })
339
+ * ```
340
+ */
296
341
  transformer?: TransformFunction<T>
297
342
 
343
+ /**
344
+ * Bidirectional column name mapper for transforming between database column names
345
+ * (e.g., snake_case) and application column names (e.g., camelCase).
346
+ *
347
+ * The mapper handles both:
348
+ * - **Decoding**: Database → Application (applied to query results)
349
+ * - **Encoding**: Application → Database (applied to WHERE clauses)
350
+ *
351
+ * @example
352
+ * ```typescript
353
+ * // Most common case: snake_case ↔ camelCase
354
+ * import { snakeCamelMapper } from '@electric-sql/client'
355
+ *
356
+ * const stream = new ShapeStream({
357
+ * url: 'http://localhost:3000/v1/shape',
358
+ * params: { table: 'todos' },
359
+ * columnMapper: snakeCamelMapper()
360
+ * })
361
+ * ```
362
+ *
363
+ * @example
364
+ * ```typescript
365
+ * // Custom mapping
366
+ * import { createColumnMapper } from '@electric-sql/client'
367
+ *
368
+ * const stream = new ShapeStream({
369
+ * columnMapper: createColumnMapper({
370
+ * user_id: 'userId',
371
+ * project_id: 'projectId',
372
+ * created_at: 'createdAt'
373
+ * })
374
+ * })
375
+ * ```
376
+ */
377
+ columnMapper?: ColumnMapper
378
+
298
379
  /**
299
380
  * A function for handling shapestream errors.
300
381
  *
@@ -482,6 +563,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
482
563
  #activeSnapshotRequests = 0 // counter for concurrent snapshot requests
483
564
  #midStreamPromise?: Promise<void>
484
565
  #midStreamPromiseResolver?: () => void
566
+ #lastSeenCursor?: string // Last seen cursor from previous session (used to detect cached responses)
567
+ #currentFetchUrl?: URL // Current fetch URL for computing shape key
485
568
  #lastSseConnectionStartTime?: number
486
569
  #minSseConnectionDuration = 1000 // Minimum expected SSE connection duration (1 second)
487
570
  #consecutiveShortSseConnections = 0
@@ -491,16 +574,44 @@ export class ShapeStream<T extends Row<unknown> = Row>
491
574
  #sseBackoffMaxDelay = 5000 // Maximum delay cap (ms)
492
575
  #unsubscribeFromVisibilityChanges?: () => void
493
576
 
577
+ // Derived state: we're in replay mode if we have a last seen cursor
578
+ get #replayMode(): boolean {
579
+ return this.#lastSeenCursor !== undefined
580
+ }
581
+
494
582
  constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
495
583
  this.options = { subscribe: true, ...options }
496
584
  validateOptions(this.options)
497
585
  this.#lastOffset = this.options.offset ?? `-1`
498
586
  this.#liveCacheBuster = ``
499
587
  this.#shapeHandle = this.options.handle
500
- this.#messageParser = new MessageParser<T>(
501
- options.parser,
502
- options.transformer
503
- )
588
+
589
+ // Build transformer chain: columnMapper.decode -> transformer
590
+ // columnMapper transforms column names, transformer transforms values
591
+ let transformer: TransformFunction<GetExtensions<T>> | undefined
592
+
593
+ if (options.columnMapper) {
594
+ const applyColumnMapper = (
595
+ row: Row<GetExtensions<T>>
596
+ ): Row<GetExtensions<T>> => {
597
+ const result: Record<string, unknown> = {}
598
+ for (const [dbKey, value] of Object.entries(row)) {
599
+ const appKey = options.columnMapper!.decode(dbKey)
600
+ result[appKey] = value
601
+ }
602
+ return result as Row<GetExtensions<T>>
603
+ }
604
+
605
+ transformer = options.transformer
606
+ ? (row: Row<GetExtensions<T>>) =>
607
+ options.transformer!(applyColumnMapper(row))
608
+ : applyColumnMapper
609
+ } else {
610
+ transformer = options.transformer
611
+ }
612
+
613
+ this.#messageParser = new MessageParser<T>(options.parser, transformer)
614
+
504
615
  this.#onError = this.options.onError
505
616
  this.#mode = this.options.log ?? `full`
506
617
 
@@ -737,7 +848,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
737
848
  // Add PostgreSQL-specific parameters
738
849
  if (params) {
739
850
  if (params.table) setQueryParam(fetchUrl, TABLE_QUERY_PARAM, params.table)
740
- if (params.where) setQueryParam(fetchUrl, WHERE_QUERY_PARAM, params.where)
851
+ if (params.where && typeof params.where === `string`) {
852
+ const encodedWhere = encodeWhereClause(
853
+ params.where,
854
+ this.options.columnMapper?.encode
855
+ )
856
+ setQueryParam(fetchUrl, WHERE_QUERY_PARAM, encodedWhere)
857
+ }
741
858
  if (params.columns)
742
859
  setQueryParam(fetchUrl, COLUMNS_QUERY_PARAM, params.columns)
743
860
  if (params.replica) setQueryParam(fetchUrl, REPLICA_PARAM, params.replica)
@@ -758,16 +875,30 @@ export class ShapeStream<T extends Row<unknown> = Row>
758
875
  }
759
876
 
760
877
  if (subsetParams) {
761
- if (subsetParams.where)
762
- setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, subsetParams.where)
878
+ if (subsetParams.where && typeof subsetParams.where === `string`) {
879
+ const encodedWhere = encodeWhereClause(
880
+ subsetParams.where,
881
+ this.options.columnMapper?.encode
882
+ )
883
+ setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, encodedWhere)
884
+ }
763
885
  if (subsetParams.params)
764
- setQueryParam(fetchUrl, SUBSET_PARAM_WHERE_PARAMS, subsetParams.params)
886
+ // Serialize params as JSON to keep the parameter name constant for proxy configs
887
+ fetchUrl.searchParams.set(
888
+ SUBSET_PARAM_WHERE_PARAMS,
889
+ JSON.stringify(subsetParams.params)
890
+ )
765
891
  if (subsetParams.limit)
766
892
  setQueryParam(fetchUrl, SUBSET_PARAM_LIMIT, subsetParams.limit)
767
893
  if (subsetParams.offset)
768
894
  setQueryParam(fetchUrl, SUBSET_PARAM_OFFSET, subsetParams.offset)
769
- if (subsetParams.orderBy)
770
- setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, subsetParams.orderBy)
895
+ if (subsetParams.orderBy && typeof subsetParams.orderBy === `string`) {
896
+ const encodedOrderBy = encodeWhereClause(
897
+ subsetParams.orderBy,
898
+ this.options.columnMapper?.encode
899
+ )
900
+ setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, encodedOrderBy)
901
+ }
771
902
  }
772
903
 
773
904
  // Add Electric's internal parameters
@@ -883,6 +1014,33 @@ export class ShapeStream<T extends Row<unknown> = Row>
883
1014
  this.#isMidStream = false
884
1015
  // Resolve the promise waiting for mid-stream to end
885
1016
  this.#midStreamPromiseResolver?.()
1017
+
1018
+ // Check if we should suppress this up-to-date notification
1019
+ // to prevent multiple renders from cached responses
1020
+ if (this.#replayMode && !isSseMessage) {
1021
+ // We're in replay mode (replaying cached responses during initial sync).
1022
+ // Check if the cursor has changed - cursors are time-based and always
1023
+ // increment, so a new cursor means fresh data from the server.
1024
+ const currentCursor = this.#liveCacheBuster
1025
+
1026
+ if (currentCursor === this.#lastSeenCursor) {
1027
+ // Same cursor = still replaying cached responses
1028
+ // Suppress this up-to-date notification
1029
+ return
1030
+ }
1031
+ }
1032
+
1033
+ // We're either:
1034
+ // 1. Not in replay mode (normal operation), or
1035
+ // 2. This is a live/SSE message (always fresh), or
1036
+ // 3. Cursor has changed (exited replay mode with fresh data)
1037
+ // In all cases, notify subscribers and record the up-to-date.
1038
+ this.#lastSeenCursor = undefined // Exit replay mode
1039
+
1040
+ if (this.#currentFetchUrl) {
1041
+ const shapeKey = canonicalShapeKey(this.#currentFetchUrl)
1042
+ upToDateTracker.recordUpToDate(shapeKey, this.#liveCacheBuster)
1043
+ }
886
1044
  }
887
1045
 
888
1046
  // Filter messages using snapshot tracker
@@ -910,6 +1068,21 @@ export class ShapeStream<T extends Row<unknown> = Row>
910
1068
  headers: Record<string, string>
911
1069
  resumingFromPause?: boolean
912
1070
  }): Promise<void> {
1071
+ // Store current fetch URL for shape key computation
1072
+ this.#currentFetchUrl = opts.fetchUrl
1073
+
1074
+ // Check if we should enter replay mode (replaying cached responses)
1075
+ // This happens when we're starting fresh (offset=-1 or before first up-to-date)
1076
+ // and there's a recent up-to-date in localStorage (< 60s)
1077
+ if (!this.#isUpToDate && !this.#replayMode) {
1078
+ const shapeKey = canonicalShapeKey(opts.fetchUrl)
1079
+ const lastSeenCursor = upToDateTracker.shouldEnterReplayMode(shapeKey)
1080
+ if (lastSeenCursor) {
1081
+ // Enter replay mode and store the last seen cursor
1082
+ this.#lastSeenCursor = lastSeenCursor
1083
+ }
1084
+ }
1085
+
913
1086
  const useSse = this.options.liveSse ?? this.options.experimentalLiveSse
914
1087
  if (
915
1088
  this.#isUpToDate &&
@@ -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'