@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
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.
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|