@electric-sql/client 1.5.10 → 1.5.12

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,339 @@
1
+ ---
2
+ name: electric-shapes
3
+ description: >
4
+ Configure ShapeStream and Shape to sync a Postgres table to the client.
5
+ Covers ShapeStreamOptions (url, table, where, columns, replica, offset,
6
+ handle), custom type parsers (timestamptz, jsonb, int8), column mappers
7
+ (snakeCamelMapper, createColumnMapper), onError retry semantics, backoff
8
+ options, log modes (full, changes_only), requestSnapshot, fetchSnapshot,
9
+ subscribe/unsubscribe, and Shape materialized view. Load when setting up
10
+ sync, configuring shapes, parsing types, or handling sync errors.
11
+ type: core
12
+ library: electric
13
+ library_version: '1.5.10'
14
+ sources:
15
+ - 'electric-sql/electric:packages/typescript-client/src/client.ts'
16
+ - 'electric-sql/electric:packages/typescript-client/src/shape.ts'
17
+ - 'electric-sql/electric:packages/typescript-client/src/types.ts'
18
+ - 'electric-sql/electric:packages/typescript-client/src/parser.ts'
19
+ - 'electric-sql/electric:packages/typescript-client/src/column-mapper.ts'
20
+ - 'electric-sql/electric:website/docs/guides/shapes.md'
21
+ ---
22
+
23
+ # Electric — Shape Streaming
24
+
25
+ ## Setup
26
+
27
+ ```ts
28
+ import { ShapeStream, Shape } from '@electric-sql/client'
29
+
30
+ const stream = new ShapeStream({
31
+ url: '/api/todos', // Your proxy route, NOT direct Electric URL
32
+ // Built-in parsers auto-handle: bool, int2, int4, float4, float8, json, jsonb
33
+ // Add custom parsers for other types (see references/type-parsers.md)
34
+ parser: {
35
+ timestamptz: (date: string) => new Date(date),
36
+ },
37
+ })
38
+
39
+ const shape = new Shape(stream)
40
+
41
+ shape.subscribe(({ rows }) => {
42
+ console.log('synced rows:', rows)
43
+ })
44
+
45
+ // Wait for initial sync
46
+ const rows = await shape.rows
47
+ ```
48
+
49
+ ## Core Patterns
50
+
51
+ ### Filter rows with WHERE clause and positional params
52
+
53
+ ```ts
54
+ const stream = new ShapeStream({
55
+ url: '/api/todos',
56
+ params: {
57
+ table: 'todos',
58
+ where: 'user_id = $1 AND status = $2',
59
+ params: { '1': userId, '2': 'active' },
60
+ },
61
+ })
62
+ ```
63
+
64
+ ### Select specific columns (must include primary key)
65
+
66
+ ```ts
67
+ const stream = new ShapeStream({
68
+ url: '/api/todos',
69
+ params: {
70
+ table: 'todos',
71
+ columns: ['id', 'title', 'status'], // PK required
72
+ },
73
+ })
74
+ ```
75
+
76
+ ### Map column names between snake_case and camelCase
77
+
78
+ ```ts
79
+ import { ShapeStream, snakeCamelMapper } from '@electric-sql/client'
80
+
81
+ const stream = new ShapeStream({
82
+ url: '/api/todos',
83
+ columnMapper: snakeCamelMapper(),
84
+ })
85
+ // DB column "created_at" arrives as "createdAt" in client
86
+ // WHERE clauses auto-translate: "createdAt" → "created_at"
87
+ ```
88
+
89
+ ### Handle errors with retry
90
+
91
+ ```ts
92
+ const stream = new ShapeStream({
93
+ url: '/api/todos',
94
+ onError: (error) => {
95
+ console.error('sync error', error)
96
+ return {} // Return {} to retry; returning void stops the stream
97
+ },
98
+ })
99
+ ```
100
+
101
+ For auth token refresh on 401 errors, see electric-proxy-auth/SKILL.md.
102
+
103
+ ### Resume from stored offset
104
+
105
+ ```ts
106
+ const stream = new ShapeStream({
107
+ url: '/api/todos',
108
+ offset: storedOffset, // Both offset AND handle required
109
+ handle: storedHandle,
110
+ })
111
+ ```
112
+
113
+ ### Get replica with old values on update
114
+
115
+ ```ts
116
+ const stream = new ShapeStream({
117
+ url: '/api/todos',
118
+ params: {
119
+ table: 'todos',
120
+ replica: 'full', // Sends unchanged columns + old_value on updates
121
+ },
122
+ })
123
+ ```
124
+
125
+ ## Common Mistakes
126
+
127
+ ### CRITICAL Returning void from onError stops sync permanently
128
+
129
+ Wrong:
130
+
131
+ ```ts
132
+ const stream = new ShapeStream({
133
+ url: '/api/todos',
134
+ onError: (error) => {
135
+ console.error('sync error', error)
136
+ // Returning nothing = stream stops forever
137
+ },
138
+ })
139
+ ```
140
+
141
+ Correct:
142
+
143
+ ```ts
144
+ const stream = new ShapeStream({
145
+ url: '/api/todos',
146
+ onError: (error) => {
147
+ console.error('sync error', error)
148
+ return {} // Return {} to retry
149
+ },
150
+ })
151
+ ```
152
+
153
+ `onError` returning `undefined` signals the stream to permanently stop. Return at least `{}` to retry, or return `{ headers, params }` to retry with updated values.
154
+
155
+ Source: `packages/typescript-client/src/client.ts:409-418`
156
+
157
+ ### HIGH Using columns without including primary key
158
+
159
+ Wrong:
160
+
161
+ ```ts
162
+ const stream = new ShapeStream({
163
+ url: '/api/todos',
164
+ params: {
165
+ table: 'todos',
166
+ columns: ['title', 'status'],
167
+ },
168
+ })
169
+ ```
170
+
171
+ Correct:
172
+
173
+ ```ts
174
+ const stream = new ShapeStream({
175
+ url: '/api/todos',
176
+ params: {
177
+ table: 'todos',
178
+ columns: ['id', 'title', 'status'],
179
+ },
180
+ })
181
+ ```
182
+
183
+ Server returns 400 error. The `columns` list must always include the primary key column(s).
184
+
185
+ Source: `website/docs/guides/shapes.md`
186
+
187
+ ### HIGH Setting offset without handle for resumption
188
+
189
+ Wrong:
190
+
191
+ ```ts
192
+ new ShapeStream({
193
+ url: '/api/todos',
194
+ offset: storedOffset,
195
+ })
196
+ ```
197
+
198
+ Correct:
199
+
200
+ ```ts
201
+ new ShapeStream({
202
+ url: '/api/todos',
203
+ offset: storedOffset,
204
+ handle: storedHandle,
205
+ })
206
+ ```
207
+
208
+ Throws `MissingShapeHandleError`. Both `offset` AND `handle` are required to resume a stream from a stored position.
209
+
210
+ Source: `packages/typescript-client/src/client.ts:1997-2003`
211
+
212
+ ### HIGH Using non-deterministic functions in WHERE clause
213
+
214
+ Wrong:
215
+
216
+ ```ts
217
+ const stream = new ShapeStream({
218
+ url: '/api/events',
219
+ params: {
220
+ table: 'events',
221
+ where: 'start_time > now()',
222
+ },
223
+ })
224
+ ```
225
+
226
+ Correct:
227
+
228
+ ```ts
229
+ const stream = new ShapeStream({
230
+ url: '/api/events',
231
+ params: {
232
+ table: 'events',
233
+ where: 'start_time > $1',
234
+ params: { '1': new Date().toISOString() },
235
+ },
236
+ })
237
+ ```
238
+
239
+ Server rejects WHERE clauses with non-deterministic functions like `now()`, `random()`, `count()`. Use static values or positional params.
240
+
241
+ Source: `packages/sync-service/lib/electric/replication/eval/env/known_functions.ex`
242
+
243
+ ### HIGH Not parsing custom Postgres types
244
+
245
+ Wrong:
246
+
247
+ ```ts
248
+ const stream = new ShapeStream({
249
+ url: '/api/events',
250
+ })
251
+ // createdAt will be string "2024-01-15T10:30:00.000Z", not a Date
252
+ ```
253
+
254
+ Correct:
255
+
256
+ ```ts
257
+ const stream = new ShapeStream({
258
+ url: '/api/events',
259
+ parser: {
260
+ timestamptz: (date: string) => new Date(date),
261
+ timestamp: (date: string) => new Date(date),
262
+ },
263
+ })
264
+ ```
265
+
266
+ Electric auto-parses `bool`, `int2`, `int4`, `float4`, `float8`, `json`, `jsonb`, and `int8` (→ BigInt). All other types arrive as strings — add custom parsers for `timestamptz`, `date`, `numeric`, etc. See [references/type-parsers.md](references/type-parsers.md) for the full list.
267
+
268
+ Source: `AGENTS.md:300-308`
269
+
270
+ ### MEDIUM Using reserved parameter names in params
271
+
272
+ Wrong:
273
+
274
+ ```ts
275
+ const stream = new ShapeStream({
276
+ url: '/api/todos',
277
+ params: {
278
+ table: 'todos',
279
+ cursor: 'abc', // Reserved!
280
+ offset: '0', // Reserved!
281
+ },
282
+ })
283
+ ```
284
+
285
+ Correct:
286
+
287
+ ```ts
288
+ const stream = new ShapeStream({
289
+ url: '/api/todos',
290
+ params: {
291
+ table: 'todos',
292
+ page_cursor: 'abc',
293
+ page_offset: '0',
294
+ },
295
+ })
296
+ ```
297
+
298
+ Throws `ReservedParamError`. Names `cursor`, `handle`, `live`, `offset`, `cache-buster`, and all `subset__*` prefixed params are reserved by the Electric protocol.
299
+
300
+ Source: `packages/typescript-client/src/client.ts:1984-1985`
301
+
302
+ ### MEDIUM Mutating shape options on a running stream
303
+
304
+ Wrong:
305
+
306
+ ```ts
307
+ const stream = new ShapeStream({
308
+ url: '/api/todos',
309
+ params: { table: 'todos', where: "status = 'active'" },
310
+ })
311
+ // Later...
312
+ stream.options.params.where = "status = 'done'" // No effect!
313
+ ```
314
+
315
+ Correct:
316
+
317
+ ```ts
318
+ // Create a new stream with different params
319
+ const newStream = new ShapeStream({
320
+ url: '/api/todos',
321
+ params: { table: 'todos', where: "status = 'done'" },
322
+ })
323
+ ```
324
+
325
+ Shapes are immutable per subscription. Changing params on a running stream has no effect. Create a new `ShapeStream` instance for different filters.
326
+
327
+ Source: `AGENTS.md:106`
328
+
329
+ ## References
330
+
331
+ - [WHERE clause supported types and functions](references/where-clause.md)
332
+ - [Built-in type parsers](references/type-parsers.md)
333
+
334
+ See also: electric-proxy-auth/SKILL.md — Shape URLs must point to proxy routes, not directly to Electric.
335
+ See also: electric-debugging/SKILL.md — onError semantics and backoff are essential for diagnosing sync problems.
336
+
337
+ ## Version
338
+
339
+ Targets @electric-sql/client v1.5.10.
@@ -0,0 +1,64 @@
1
+ # Electric Shapes — Type Parser Reference
2
+
3
+ ## Built-in Parsers
4
+
5
+ These parsers are applied automatically. All other types arrive as strings.
6
+
7
+ | Postgres Type | Parser | Output Type | Notes |
8
+ | ------------- | ------------- | ----------- | -------------------------- |
9
+ | `int2` | `parseNumber` | `number` | |
10
+ | `int4` | `parseNumber` | `number` | |
11
+ | `int8` | `parseBigInt` | `BigInt` | Returns BigInt, not number |
12
+ | `float4` | `parseNumber` | `number` | |
13
+ | `float8` | `parseNumber` | `number` | |
14
+ | `bool` | `parseBool` | `boolean` | |
15
+ | `json` | `parseJson` | `object` | |
16
+ | `jsonb` | `parseJson` | `object` | |
17
+
18
+ ## Common Custom Parsers
19
+
20
+ ```ts
21
+ const stream = new ShapeStream({
22
+ url: '/api/items',
23
+ parser: {
24
+ timestamptz: (date: string) => new Date(date),
25
+ timestamp: (date: string) => new Date(date),
26
+ date: (date: string) => new Date(date),
27
+ numeric: (n: string) => parseFloat(n),
28
+ interval: (i: string) => i, // Keep as string or use a library
29
+ },
30
+ })
31
+ ```
32
+
33
+ ## Parser Signature
34
+
35
+ ```ts
36
+ type ParseFunction<Extensions = never> = (
37
+ value: string,
38
+ additionalInfo?: Omit<ColumnInfo, 'type' | 'dims'>
39
+ ) => Value<Extensions>
40
+ ```
41
+
42
+ The `additionalInfo` parameter provides column metadata like `precision`, `scale`, `max_length`, `not_null`.
43
+
44
+ ## NULL Handling
45
+
46
+ If a column has `not_null: true` in the schema and a `NULL` value is received, the parser throws `ParserNullValueError`. This indicates a schema mismatch.
47
+
48
+ ## Transformer vs Parser
49
+
50
+ - **Parser**: converts individual column values by Postgres type name
51
+ - **Transformer**: transforms the entire row object after parsing
52
+
53
+ ```ts
54
+ const stream = new ShapeStream({
55
+ url: '/api/items',
56
+ parser: {
57
+ timestamptz: (date: string) => new Date(date),
58
+ },
59
+ transformer: (row) => ({
60
+ ...row,
61
+ fullName: `${row.firstName} ${row.lastName}`,
62
+ }),
63
+ })
64
+ ```
@@ -0,0 +1,64 @@
1
+ # Electric Shapes — WHERE Clause Reference
2
+
3
+ ## Supported Column Types
4
+
5
+ | Type | Example | Notes |
6
+ | -------------------------- | ------------------------------------- | --------------------- |
7
+ | `text`, `varchar`, `char` | `name = 'Alice'` | String comparison |
8
+ | `int2`, `int4`, `int8` | `age > 21` | Numeric comparison |
9
+ | `float4`, `float8` | `price < 9.99` | Float comparison |
10
+ | `bool` | `active = true` | Boolean |
11
+ | `uuid` | `id = '550e8400-...'` | UUID comparison |
12
+ | `date` | `created > '2024-01-01'` | Date comparison |
13
+ | `timestamp`, `timestamptz` | `updated_at > '2024-01-01T00:00:00Z'` | Timestamp comparison |
14
+ | `interval` | `duration > '1 hour'` | Interval comparison |
15
+ | `numeric` | `amount >= 100.50` | Arbitrary precision |
16
+ | `arrays` | `tags && ARRAY['urgent']` | Array operations |
17
+ | `enum` | `status::text IN ('active', 'done')` | **Must cast to text** |
18
+
19
+ ## Unsupported
20
+
21
+ - `timetz` — not supported in WHERE
22
+ - Non-deterministic functions: `now()`, `random()`, `count()`, `current_timestamp`
23
+ - Aggregate functions
24
+ - Subqueries (experimental, requires `ELECTRIC_FEATURE_FLAGS=allow_subqueries`)
25
+
26
+ ## Positional Parameters
27
+
28
+ ```ts
29
+ // Array format
30
+ params: { where: 'org_id = $1 AND role = $2', params: ['org-123', 'admin'] }
31
+
32
+ // Object format
33
+ params: { where: 'org_id = $1 AND role = $2', params: { '1': 'org-123', '2': 'admin' } }
34
+ ```
35
+
36
+ ## Operators
37
+
38
+ | Operator | Example |
39
+ | ------------------------ | --------------------------------- |
40
+ | `=`, `!=`, `<>` | `status = 'active'` |
41
+ | `<`, `>`, `<=`, `>=` | `age >= 18` |
42
+ | `IN` | `status IN ('active', 'pending')` |
43
+ | `NOT IN` | `status NOT IN ('deleted')` |
44
+ | `LIKE`, `ILIKE` | `name ILIKE '%john%'` |
45
+ | `IS NULL`, `IS NOT NULL` | `deleted_at IS NULL` |
46
+ | `AND`, `OR`, `NOT` | `active = true AND age > 18` |
47
+ | `BETWEEN` | `age BETWEEN 18 AND 65` |
48
+ | `ANY`, `ALL` | Array comparisons |
49
+
50
+ ## Enum Gotcha
51
+
52
+ Enum columns require explicit `::text` cast:
53
+
54
+ ```ts
55
+ // Wrong — fails silently or errors
56
+ params: {
57
+ where: "status IN ('active', 'done')"
58
+ }
59
+
60
+ // Correct
61
+ params: {
62
+ where: "status::text IN ('active', 'done')"
63
+ }
64
+ ```
package/src/client.ts CHANGED
@@ -96,6 +96,10 @@ const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
96
96
 
97
97
  const TROUBLESHOOTING_URL = `https://electric-sql.com/docs/guides/troubleshooting`
98
98
 
99
+ function createCacheBuster(): string {
100
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
101
+ }
102
+
99
103
  type Replica = `full` | `default`
100
104
  export type LogMode = `changes_only` | `full`
101
105
 
@@ -617,6 +621,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
617
621
  #fastLoopBackoffMaxMs = 5_000
618
622
  #fastLoopConsecutiveCount = 0
619
623
  #fastLoopMaxCount = 5
624
+ #refetchCacheBuster?: string
620
625
 
621
626
  constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
622
627
  this.options = { subscribe: true, ...options }
@@ -875,10 +880,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
875
880
  if (!(e instanceof FetchError)) throw e // should never happen
876
881
 
877
882
  if (e.status == 409) {
878
- // Upon receiving a 409, we should start from scratch
879
- // with the newly provided shape handle, or a fallback
880
- // pseudo-handle based on the current one to act as a
881
- // consistent cache buster
883
+ // Upon receiving a 409, start from scratch with the newly
884
+ // provided shape handle. If the header is missing (e.g. proxy
885
+ // stripped it), reset without a handle and use a random
886
+ // cache-buster query param to ensure the retry URL is unique.
882
887
 
883
888
  // Store the current shape URL as expired to avoid future 409s
884
889
  if (this.#syncState.handle) {
@@ -886,8 +891,14 @@ export class ShapeStream<T extends Row<unknown> = Row>
886
891
  expiredShapesCache.markExpired(shapeKey, this.#syncState.handle)
887
892
  }
888
893
 
889
- const newShapeHandle =
890
- e.headers[SHAPE_HANDLE_HEADER] || `${this.#syncState.handle!}-next`
894
+ const newShapeHandle = e.headers[SHAPE_HANDLE_HEADER]
895
+ if (!newShapeHandle) {
896
+ console.warn(
897
+ `[Electric] Received 409 response without a shape handle header. ` +
898
+ `This likely indicates a proxy or CDN stripping required headers.`
899
+ )
900
+ this.#refetchCacheBuster = createCacheBuster()
901
+ }
891
902
  this.#reset(newShapeHandle)
892
903
 
893
904
  // must refetch control message might be in a list or not depending
@@ -1144,6 +1155,16 @@ export class ShapeStream<T extends Row<unknown> = Row>
1144
1155
  fetchUrl.searchParams.set(EXPIRED_HANDLE_QUERY_PARAM, expiredHandle)
1145
1156
  }
1146
1157
 
1158
+ // Add one-shot cache buster when a 409 response lacked a handle header
1159
+ // (e.g. proxy stripped it). Ensures each retry has a unique URL.
1160
+ if (this.#refetchCacheBuster) {
1161
+ fetchUrl.searchParams.set(
1162
+ CACHE_BUSTER_QUERY_PARAM,
1163
+ this.#refetchCacheBuster
1164
+ )
1165
+ this.#refetchCacheBuster = undefined
1166
+ }
1167
+
1147
1168
  // sort query params in-place for stable URLs and improved cache hits
1148
1169
  fetchUrl.searchParams.sort()
1149
1170
 
@@ -1199,8 +1220,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1199
1220
  expiredHandle,
1200
1221
  now: Date.now(),
1201
1222
  maxStaleCacheRetries: this.#maxStaleCacheRetries,
1202
- createCacheBuster: () =>
1203
- `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
1223
+ createCacheBuster,
1204
1224
  })
1205
1225
 
1206
1226
  this.#syncState = transition.state
@@ -1786,8 +1806,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1786
1806
  expiredHandle: null,
1787
1807
  now: Date.now(),
1788
1808
  maxStaleCacheRetries: this.#maxStaleCacheRetries,
1789
- createCacheBuster: () =>
1790
- `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
1809
+ createCacheBuster,
1791
1810
  })
1792
1811
  if (transition.action === `accepted`) {
1793
1812
  this.#syncState = transition.state
@@ -1869,9 +1888,16 @@ export class ShapeStream<T extends Row<unknown> = Row>
1869
1888
 
1870
1889
  // For snapshot 409s, only update the handle — don't reset offset/schema/etc.
1871
1890
  // The main stream is paused and should not be disturbed.
1872
- const nextHandle =
1873
- e.headers[SHAPE_HANDLE_HEADER] || `${usedHandle ?? `handle`}-next`
1874
- this.#syncState = this.#syncState.withHandle(nextHandle)
1891
+ const nextHandle = e.headers[SHAPE_HANDLE_HEADER]
1892
+ if (nextHandle) {
1893
+ this.#syncState = this.#syncState.withHandle(nextHandle)
1894
+ } else {
1895
+ console.warn(
1896
+ `[Electric] Received 409 response without a shape handle header. ` +
1897
+ `This likely indicates a proxy or CDN stripping required headers.`
1898
+ )
1899
+ this.#refetchCacheBuster = createCacheBuster()
1900
+ }
1875
1901
 
1876
1902
  return this.fetchSnapshot(opts)
1877
1903
  }