@electric-sql/client 0.8.0 → 0.9.1

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/src/client.ts CHANGED
@@ -34,27 +34,49 @@ import {
34
34
  SHAPE_HANDLE_QUERY_PARAM,
35
35
  SHAPE_SCHEMA_HEADER,
36
36
  WHERE_QUERY_PARAM,
37
- DATABASE_ID_QUERY_PARAM,
38
37
  TABLE_QUERY_PARAM,
39
38
  REPLICA_PARAM,
40
39
  } from './constants'
41
40
 
42
41
  const RESERVED_PARAMS = new Set([
43
- DATABASE_ID_QUERY_PARAM,
44
- COLUMNS_QUERY_PARAM,
45
42
  LIVE_CACHE_BUSTER_QUERY_PARAM,
46
43
  SHAPE_HANDLE_QUERY_PARAM,
47
44
  LIVE_QUERY_PARAM,
48
45
  OFFSET_QUERY_PARAM,
49
- TABLE_QUERY_PARAM,
50
- WHERE_QUERY_PARAM,
51
- REPLICA_PARAM,
52
46
  ])
53
47
 
54
48
  type Replica = `full` | `default`
55
49
 
50
+ /**
51
+ * PostgreSQL-specific shape parameters that can be provided externally
52
+ */
53
+ type PostgresParams = {
54
+ /** The root table for the shape. Not required if you set the table in your proxy. */
55
+ table?: string
56
+
57
+ /**
58
+ * The columns to include in the shape.
59
+ * Must include primary keys, and can only include valid columns.
60
+ */
61
+ columns?: string[]
62
+
63
+ /** The where clauses for the shape */
64
+ where?: string
65
+
66
+ /**
67
+ * If `replica` is `default` (the default) then Electric will only send the
68
+ * changed columns in an update.
69
+ *
70
+ * If it's `full` Electric will send the entire row with both changed and
71
+ * unchanged values.
72
+ *
73
+ * Setting `replica` to `full` will result in higher bandwidth
74
+ * usage and so is not generally recommended.
75
+ */
76
+ replica?: Replica
77
+ }
78
+
56
79
  type ReservedParamKeys =
57
- | typeof DATABASE_ID_QUERY_PARAM
58
80
  | typeof COLUMNS_QUERY_PARAM
59
81
  | typeof LIVE_CACHE_BUSTER_QUERY_PARAM
60
82
  | typeof SHAPE_HANDLE_QUERY_PARAM
@@ -64,12 +86,38 @@ type ReservedParamKeys =
64
86
  | typeof WHERE_QUERY_PARAM
65
87
  | typeof REPLICA_PARAM
66
88
 
67
- type ParamsRecord = Omit<Record<string, string>, ReservedParamKeys>
89
+ /**
90
+ * External params type - what users provide.
91
+ * Includes documented PostgreSQL params and allows string or string[] values for any additional params.
92
+ */
93
+ type ExternalParamsRecord = Partial<PostgresParams> & {
94
+ [K in string as K extends ReservedParamKeys ? never : K]: string | string[]
95
+ }
96
+
97
+ /**
98
+ * Internal params type - used within the library.
99
+ * All values are converted to strings.
100
+ */
101
+ type InternalParamsRecord = {
102
+ [K in string as K extends ReservedParamKeys ? never : K]: string
103
+ }
104
+
105
+ /**
106
+ * Helper function to convert external params to internal format
107
+ */
108
+ function toInternalParams(params: ExternalParamsRecord): InternalParamsRecord {
109
+ const result: InternalParamsRecord = {}
110
+ for (const [key, value] of Object.entries(params)) {
111
+ result[key] = Array.isArray(value) ? value.join(`,`) : value
112
+ }
113
+ return result
114
+ }
68
115
 
69
116
  type RetryOpts = {
70
- params?: ParamsRecord
117
+ params?: ExternalParamsRecord
71
118
  headers?: Record<string, string>
72
119
  }
120
+
73
121
  type ShapeStreamErrorHandler = (
74
122
  error: Error
75
123
  ) => void | RetryOpts | Promise<void | RetryOpts>
@@ -84,40 +132,6 @@ export interface ShapeStreamOptions<T = never> {
84
132
  */
85
133
  url: string
86
134
 
87
- /**
88
- * Which database to use.
89
- * This is optional unless Electric is used with multiple databases.
90
- */
91
- databaseId?: string
92
-
93
- /**
94
- * The root table for the shape. Passed as a query parameter. Not required if you set the table in your proxy.
95
- */
96
- table?: string
97
-
98
- /**
99
- * The where clauses for the shape.
100
- */
101
- where?: string
102
-
103
- /**
104
- * The columns to include in the shape.
105
- * Must include primary keys, and can only inlude valid columns.
106
- */
107
- columns?: string[]
108
-
109
- /**
110
- * If `replica` is `default` (the default) then Electric will only send the
111
- * changed columns in an update.
112
- *
113
- * If it's `full` Electric will send the entire row with both changed and
114
- * unchanged values.
115
- *
116
- * Setting `replica` to `full` will obviously result in higher bandwidth
117
- * usage and so is not recommended.
118
- */
119
- replica?: Replica
120
-
121
135
  /**
122
136
  * The "offset" on the shape log. This is typically not set as the ShapeStream
123
137
  * will handle this automatically. A common scenario where you might pass an offset
@@ -144,9 +158,12 @@ export interface ShapeStreamOptions<T = never> {
144
158
  * Additional request parameters to attach to the URL.
145
159
  * These will be merged with Electric's standard parameters.
146
160
  * Note: You cannot use Electric's reserved parameter names
147
- * (table, where, columns, offset, handle, live, cursor, database_id, replica).
161
+ * (offset, handle, live, cursor).
162
+ *
163
+ * PostgreSQL-specific options like table, where, columns, and replica
164
+ * should be specified here.
148
165
  */
149
- params?: ParamsRecord
166
+ params?: ExternalParamsRecord
150
167
 
151
168
  /**
152
169
  * Automatically fetch updates to the Shape. If you just want to sync the current
@@ -162,7 +179,7 @@ export interface ShapeStreamOptions<T = never> {
162
179
  /**
163
180
  * A function for handling shapestream errors.
164
181
  * This is optional, when it is not provided any shapestream errors will be thrown.
165
- * If the function is provided and returns an object containing parameters and/or headers
182
+ * If the function returns an object containing parameters and/or headers
166
183
  * the shapestream will apply those changes and try syncing again.
167
184
  * If the function returns void the shapestream is stopped.
168
185
  */
@@ -173,7 +190,7 @@ export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
173
190
  subscribe(
174
191
  callback: (messages: Message<T>[]) => MaybePromise<void>,
175
192
  onError?: (error: FetchError | Error) => void
176
- ): void
193
+ ): () => void
177
194
  unsubscribeAll(): void
178
195
 
179
196
  isLoading(): boolean
@@ -246,10 +263,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
246
263
  #isUpToDate: boolean = false
247
264
  #connected: boolean = false
248
265
  #shapeHandle?: string
249
- #databaseId?: string
250
266
  #schema?: Schema
251
267
  #onError?: ShapeStreamErrorHandler
252
- #replica?: Replica
253
268
 
254
269
  constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
255
270
  this.options = { subscribe: true, ...options }
@@ -257,9 +272,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
257
272
  this.#lastOffset = this.options.offset ?? `-1`
258
273
  this.#liveCacheBuster = ``
259
274
  this.#shapeHandle = this.options.handle
260
- this.#databaseId = this.options.databaseId
261
275
  this.#messageParser = new MessageParser<T>(options.parser)
262
- this.#replica = this.options.replica
263
276
  this.#onError = this.options.onError
264
277
 
265
278
  const baseFetchClient =
@@ -303,7 +316,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
303
316
  (!this.options.signal?.aborted && !this.#isUpToDate) ||
304
317
  this.options.subscribe
305
318
  ) {
306
- const { url, table, where, columns, signal } = this.options
319
+ const { url, signal } = this.options
307
320
 
308
321
  const fetchUrl = new URL(url)
309
322
 
@@ -319,16 +332,30 @@ export class ShapeStream<T extends Row<unknown> = Row>
319
332
  )
320
333
  }
321
334
 
322
- for (const [key, value] of Object.entries(this.options.params)) {
323
- fetchUrl.searchParams.set(key, value)
335
+ // Add PostgreSQL-specific parameters from params
336
+ const params = toInternalParams(this.options.params)
337
+ if (params.table)
338
+ fetchUrl.searchParams.set(TABLE_QUERY_PARAM, params.table)
339
+ if (params.where)
340
+ fetchUrl.searchParams.set(WHERE_QUERY_PARAM, params.where)
341
+ if (params.columns)
342
+ fetchUrl.searchParams.set(COLUMNS_QUERY_PARAM, params.columns)
343
+ if (params.replica)
344
+ fetchUrl.searchParams.set(REPLICA_PARAM, params.replica)
345
+
346
+ // Add any remaining custom parameters
347
+ const customParams = { ...params }
348
+ delete customParams.table
349
+ delete customParams.where
350
+ delete customParams.columns
351
+ delete customParams.replica
352
+
353
+ for (const [key, value] of Object.entries(customParams)) {
354
+ fetchUrl.searchParams.set(key, value as string)
324
355
  }
325
356
  }
326
357
 
327
358
  // Add Electric's internal parameters
328
- if (table) fetchUrl.searchParams.set(TABLE_QUERY_PARAM, table)
329
- if (where) fetchUrl.searchParams.set(WHERE_QUERY_PARAM, where)
330
- if (columns && columns.length > 0)
331
- fetchUrl.searchParams.set(COLUMNS_QUERY_PARAM, columns.join(`,`))
332
359
  fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, this.#lastOffset)
333
360
 
334
361
  if (this.#isUpToDate) {
@@ -347,16 +374,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
347
374
  )
348
375
  }
349
376
 
350
- if (this.#databaseId) {
351
- fetchUrl.searchParams.set(DATABASE_ID_QUERY_PARAM, this.#databaseId!)
352
- }
353
-
354
- if (
355
- (this.#replica ?? ShapeStream.Replica.DEFAULT) !=
356
- ShapeStream.Replica.DEFAULT
357
- ) {
358
- fetchUrl.searchParams.set(REPLICA_PARAM, this.#replica as string)
359
- }
377
+ // sort query params in-place for stable URLs and improved cache hits
378
+ fetchUrl.searchParams.sort()
360
379
 
361
380
  let response!: Response
362
381
  try {
package/src/constants.ts CHANGED
@@ -3,7 +3,6 @@ export const SHAPE_HANDLE_HEADER = `electric-handle`
3
3
  export const CHUNK_LAST_OFFSET_HEADER = `electric-offset`
4
4
  export const SHAPE_SCHEMA_HEADER = `electric-schema`
5
5
  export const CHUNK_UP_TO_DATE_HEADER = `electric-up-to-date`
6
- export const DATABASE_ID_QUERY_PARAM = `database_id`
7
6
  export const COLUMNS_QUERY_PARAM = `columns`
8
7
  export const LIVE_CACHE_BUSTER_QUERY_PARAM = `cursor`
9
8
  export const SHAPE_HANDLE_QUERY_PARAM = `handle`
package/src/fetch.ts CHANGED
@@ -177,13 +177,13 @@ export function createFetchWithResponseHeadersCheck(
177
177
  const input = args[0]
178
178
  const urlString = input.toString()
179
179
  const url = new URL(urlString)
180
- if (url.searchParams.has(LIVE_QUERY_PARAM, `true`)) {
180
+ if (url.searchParams.get(LIVE_QUERY_PARAM) === `true`) {
181
181
  addMissingHeaders(requiredLiveResponseHeaders)
182
182
  }
183
183
 
184
184
  if (
185
185
  !url.searchParams.has(LIVE_QUERY_PARAM) ||
186
- url.searchParams.has(LIVE_QUERY_PARAM, `false`)
186
+ url.searchParams.get(LIVE_QUERY_PARAM) === `false`
187
187
  ) {
188
188
  addMissingHeaders(requiredNonLiveResponseHeaders)
189
189
  }
@@ -312,6 +312,7 @@ function getNextChunkUrl(url: string, res: Response): string | void {
312
312
 
313
313
  nextUrl.searchParams.set(SHAPE_HANDLE_QUERY_PARAM, shapeHandle)
314
314
  nextUrl.searchParams.set(OFFSET_QUERY_PARAM, lastOffset)
315
+ nextUrl.searchParams.sort()
315
316
  return nextUrl.toString()
316
317
  }
317
318
 
package/src/shape.ts CHANGED
@@ -21,7 +21,12 @@ export type ShapeChangedCallback<T extends Row<unknown> = Row> = (data: {
21
21
  * @param {ShapeStream<T extends Row>} - the underlying shape stream
22
22
  * @example
23
23
  * ```
24
- * const shapeStream = new ShapeStream<{ foo: number }>(url: `http://localhost:3000/v1/shape`, table: `foo`})
24
+ * const shapeStream = new ShapeStream<{ foo: number }>({
25
+ * url: `http://localhost:3000/v1/shape`,
26
+ * params: {
27
+ * table: `foo`
28
+ * }
29
+ * })
25
30
  * const shape = new Shape(shapeStream)
26
31
  * ```
27
32
  *
@@ -41,7 +46,7 @@ export type ShapeChangedCallback<T extends Row<unknown> = Row> = (data: {
41
46
  * })
42
47
  */
43
48
  export class Shape<T extends Row<unknown> = Row> {
44
- readonly #stream: ShapeStreamInterface<T>
49
+ readonly stream: ShapeStreamInterface<T>
45
50
 
46
51
  readonly #data: ShapeData<T> = new Map()
47
52
  readonly #subscribers = new Map<number, ShapeChangedCallback<T>>()
@@ -50,23 +55,23 @@ export class Shape<T extends Row<unknown> = Row> {
50
55
  #error: FetchError | false = false
51
56
 
52
57
  constructor(stream: ShapeStreamInterface<T>) {
53
- this.#stream = stream
54
- this.#stream.subscribe(
58
+ this.stream = stream
59
+ this.stream.subscribe(
55
60
  this.#process.bind(this),
56
61
  this.#handleError.bind(this)
57
62
  )
58
63
  }
59
64
 
60
65
  get isUpToDate(): boolean {
61
- return this.#stream.isUpToDate
66
+ return this.stream.isUpToDate
62
67
  }
63
68
 
64
69
  get lastOffset(): Offset {
65
- return this.#stream.lastOffset
70
+ return this.stream.lastOffset
66
71
  }
67
72
 
68
73
  get handle(): string | undefined {
69
- return this.#stream.shapeHandle
74
+ return this.stream.shapeHandle
70
75
  }
71
76
 
72
77
  get rows(): Promise<T[]> {
@@ -79,7 +84,7 @@ export class Shape<T extends Row<unknown> = Row> {
79
84
 
80
85
  get value(): Promise<ShapeData<T>> {
81
86
  return new Promise((resolve, reject) => {
82
- if (this.#stream.isUpToDate) {
87
+ if (this.stream.isUpToDate) {
83
88
  resolve(this.currentValue)
84
89
  } else {
85
90
  const unsubscribe = this.subscribe(({ value }) => {
@@ -101,22 +106,22 @@ export class Shape<T extends Row<unknown> = Row> {
101
106
 
102
107
  /** Unix time at which we last synced. Undefined when `isLoading` is true. */
103
108
  lastSyncedAt(): number | undefined {
104
- return this.#stream.lastSyncedAt()
109
+ return this.stream.lastSyncedAt()
105
110
  }
106
111
 
107
112
  /** Time elapsed since last sync (in ms). Infinity if we did not yet sync. */
108
113
  lastSynced() {
109
- return this.#stream.lastSynced()
114
+ return this.stream.lastSynced()
110
115
  }
111
116
 
112
117
  /** True during initial fetch. False afterwise. */
113
118
  isLoading() {
114
- return this.#stream.isLoading()
119
+ return this.stream.isLoading()
115
120
  }
116
121
 
117
122
  /** Indicates if we are connected to the Electric sync service. */
118
123
  isConnected(): boolean {
119
- return this.#stream.isConnected()
124
+ return this.stream.isConnected()
120
125
  }
121
126
 
122
127
  subscribe(callback: ShapeChangedCallback<T>): () => void {
package/src/types.ts CHANGED
@@ -23,6 +23,8 @@ interface Header {
23
23
  [key: Exclude<string, `operation` | `control`>]: Value
24
24
  }
25
25
 
26
+ export type Operation = `insert` | `update` | `delete`
27
+
26
28
  export type ControlMessage = {
27
29
  headers: Header & { control: `up-to-date` | `must-refetch` }
28
30
  }
@@ -30,7 +32,7 @@ export type ControlMessage = {
30
32
  export type ChangeMessage<T extends Row<unknown> = Row> = {
31
33
  key: string
32
34
  value: T
33
- headers: Header & { operation: `insert` | `update` | `delete` }
35
+ headers: Header & { operation: Operation }
34
36
  offset: Offset
35
37
  }
36
38