@electric-sql/client 1.0.0-beta.4 → 1.0.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/src/client.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  SHAPE_HANDLE_QUERY_PARAM,
35
35
  SHAPE_SCHEMA_HEADER,
36
36
  WHERE_QUERY_PARAM,
37
+ WHERE_PARAMS_PARAM,
37
38
  TABLE_QUERY_PARAM,
38
39
  REPLICA_PARAM,
39
40
  FORCE_DISCONNECT_AND_REFRESH,
@@ -64,6 +65,17 @@ export interface PostgresParams {
64
65
  /** The where clauses for the shape */
65
66
  where?: string
66
67
 
68
+ /**
69
+ * Positional where clause paramater values. These will be passed to the server
70
+ * and will substitute `$i` parameters in the where clause.
71
+ *
72
+ * It can be an array (note that positional arguments start at 1, the array will be mapped
73
+ * accordingly), or an object with keys matching the used positional parameters in the where clause.
74
+ *
75
+ * If where clause is `id = $1 or id = $2`, params must have keys `"1"` and `"2"`, or be an array with length 2.
76
+ */
77
+ params?: Record<`${number}`, string> | string[]
78
+
67
79
  /**
68
80
  * If `replica` is `default` (the default) then Electric will only send the
69
81
  * changed columns in an update.
@@ -77,11 +89,10 @@ export interface PostgresParams {
77
89
  */
78
90
  replica?: Replica
79
91
  }
80
-
92
+ type SerializableParamValue = string | string[] | Record<string, string>
81
93
  type ParamValue =
82
- | string
83
- | string[]
84
- | (() => string | string[] | Promise<string | string[]>)
94
+ | SerializableParamValue
95
+ | (() => SerializableParamValue | Promise<SerializableParamValue>)
85
96
 
86
97
  /**
87
98
  * External params type - what users provide.
@@ -112,7 +123,9 @@ export type ExternalHeadersRecord = {
112
123
  * All values are converted to strings.
113
124
  */
114
125
  type InternalParamsRecord = {
115
- [K in string as K extends ReservedParamKeys ? never : K]: string
126
+ [K in string as K extends ReservedParamKeys ? never : K]:
127
+ | string
128
+ | Record<string, string>
116
129
  }
117
130
 
118
131
  /**
@@ -391,7 +404,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
391
404
  const [requestHeaders, params] = await Promise.all([
392
405
  resolveHeaders(this.options.headers),
393
406
  this.options.params
394
- ? toInternalParams(this.options.params)
407
+ ? toInternalParams(convertWhereParamsToObj(this.options.params))
395
408
  : undefined,
396
409
  ])
397
410
 
@@ -405,13 +418,15 @@ export class ShapeStream<T extends Row<unknown> = Row>
405
418
  // Add PostgreSQL-specific parameters
406
419
  if (params) {
407
420
  if (params.table)
408
- fetchUrl.searchParams.set(TABLE_QUERY_PARAM, params.table)
421
+ setQueryParam(fetchUrl, TABLE_QUERY_PARAM, params.table)
409
422
  if (params.where)
410
- fetchUrl.searchParams.set(WHERE_QUERY_PARAM, params.where)
423
+ setQueryParam(fetchUrl, WHERE_QUERY_PARAM, params.where)
411
424
  if (params.columns)
412
- fetchUrl.searchParams.set(COLUMNS_QUERY_PARAM, params.columns)
425
+ setQueryParam(fetchUrl, COLUMNS_QUERY_PARAM, params.columns)
413
426
  if (params.replica)
414
- fetchUrl.searchParams.set(REPLICA_PARAM, params.replica)
427
+ setQueryParam(fetchUrl, REPLICA_PARAM, params.replica)
428
+ if (params.params)
429
+ setQueryParam(fetchUrl, WHERE_PARAMS_PARAM, params.params)
415
430
 
416
431
  // Add any remaining custom parameters
417
432
  const customParams = { ...params }
@@ -419,9 +434,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
419
434
  delete customParams.where
420
435
  delete customParams.columns
421
436
  delete customParams.replica
437
+ delete customParams.params
422
438
 
423
439
  for (const [key, value] of Object.entries(customParams)) {
424
- fetchUrl.searchParams.set(key, value as string)
440
+ setQueryParam(fetchUrl, key, value)
425
441
  }
426
442
  }
427
443
 
@@ -729,3 +745,34 @@ function validateOptions<T>(options: Partial<ShapeStreamOptions<T>>): void {
729
745
 
730
746
  return
731
747
  }
748
+
749
+ // `unknown` being in the value is a bit of defensive programming if user doesn't use TS
750
+ function setQueryParam(
751
+ url: URL,
752
+ key: string,
753
+ value: Record<string, string> | string | unknown
754
+ ): void {
755
+ if (value === undefined || value == null) {
756
+ return
757
+ } else if (typeof value === `string`) {
758
+ url.searchParams.set(key, value)
759
+ } else if (typeof value === `object`) {
760
+ for (const [k, v] of Object.entries(value)) {
761
+ url.searchParams.set(`${key}[${k}]`, v)
762
+ }
763
+ } else {
764
+ url.searchParams.set(key, value.toString())
765
+ }
766
+ }
767
+
768
+ function convertWhereParamsToObj(
769
+ allPgParams: ExternalParamsRecord
770
+ ): ExternalParamsRecord {
771
+ if (Array.isArray(allPgParams.params)) {
772
+ return {
773
+ ...allPgParams,
774
+ params: Object.fromEntries(allPgParams.params.map((v, i) => [i + 1, v])),
775
+ }
776
+ }
777
+ return allPgParams
778
+ }
package/src/constants.ts CHANGED
@@ -11,4 +11,5 @@ export const OFFSET_QUERY_PARAM = `offset`
11
11
  export const TABLE_QUERY_PARAM = `table`
12
12
  export const WHERE_QUERY_PARAM = `where`
13
13
  export const REPLICA_PARAM = `replica`
14
+ export const WHERE_PARAMS_PARAM = `params`
14
15
  export const FORCE_DISCONNECT_AND_REFRESH = `force-disconnect-and-refresh`
package/src/fetch.ts CHANGED
@@ -264,10 +264,8 @@ class PrefetchQueue {
264
264
  const aborter = new AbortController()
265
265
 
266
266
  try {
267
- const request = this.#fetchClient(url, {
268
- ...(args[1] ?? {}),
269
- signal: chainAborter(aborter, args[1]?.signal),
270
- })
267
+ const { signal, cleanup } = chainAborter(aborter, args[1]?.signal)
268
+ const request = this.#fetchClient(url, { ...(args[1] ?? {}), signal })
271
269
  this.#prefetchQueue.set(url, [request, aborter])
272
270
  request
273
271
  .then((response) => {
@@ -286,6 +284,7 @@ class PrefetchQueue {
286
284
  return this.#prefetch(nextUrl, args[1])
287
285
  })
288
286
  .catch(() => {})
287
+ .finally(cleanup)
289
288
  } catch (_) {
290
289
  // ignore prefetch errors
291
290
  }
@@ -324,12 +323,31 @@ function getNextChunkUrl(url: string, res: Response): string | void {
324
323
  function chainAborter(
325
324
  aborter: AbortController,
326
325
  sourceSignal?: AbortSignal | null
327
- ): AbortSignal {
328
- if (!sourceSignal) return aborter.signal
329
- if (sourceSignal.aborted) aborter.abort()
330
- else
331
- sourceSignal.addEventListener(`abort`, () => aborter.abort(), {
326
+ ): {
327
+ signal: AbortSignal
328
+ cleanup: () => void
329
+ } {
330
+ let cleanup = noop
331
+ if (!sourceSignal) {
332
+ // no-op, nothing to chain to
333
+ } else if (sourceSignal.aborted) {
334
+ // source signal is already aborted, abort immediately
335
+ aborter.abort()
336
+ } else {
337
+ // chain to source signal abort event, and add callback to unlink
338
+ // the aborter to avoid memory leaks
339
+ const abortParent = () => aborter.abort()
340
+ sourceSignal.addEventListener(`abort`, abortParent, {
332
341
  once: true,
342
+ signal: aborter.signal,
333
343
  })
334
- return aborter.signal
344
+ cleanup = () => sourceSignal.removeEventListener(`abort`, abortParent)
345
+ }
346
+
347
+ return {
348
+ signal: aborter.signal,
349
+ cleanup,
350
+ }
335
351
  }
352
+
353
+ function noop() {}
package/src/shape.ts CHANGED
@@ -9,6 +9,8 @@ export type ShapeChangedCallback<T extends Row<unknown> = Row> = (data: {
9
9
  rows: T[]
10
10
  }) => void
11
11
 
12
+ type ShapeStatus = `syncing` | `up-to-date`
13
+
12
14
  /**
13
15
  * A Shape is an object that subscribes to a shape log,
14
16
  * keeps a materialised shape `.rows` in memory and
@@ -50,8 +52,7 @@ export class Shape<T extends Row<unknown> = Row> {
50
52
 
51
53
  readonly #data: ShapeData<T> = new Map()
52
54
  readonly #subscribers = new Map<number, ShapeChangedCallback<T>>()
53
-
54
- #hasNotifiedSubscribersUpToDate: boolean = false
55
+ #status: ShapeStatus = `syncing`
55
56
  #error: FetchError | false = false
56
57
 
57
58
  constructor(stream: ShapeStreamInterface<T>) {
@@ -63,7 +64,7 @@ export class Shape<T extends Row<unknown> = Row> {
63
64
  }
64
65
 
65
66
  get isUpToDate(): boolean {
66
- return this.stream.isUpToDate
67
+ return this.#status === `up-to-date`
67
68
  }
68
69
 
69
70
  get lastOffset(): Offset {
@@ -143,16 +144,11 @@ export class Shape<T extends Row<unknown> = Row> {
143
144
  }
144
145
 
145
146
  #process(messages: Message<T>[]): void {
146
- let dataMayHaveChanged = false
147
- let isUpToDate = false
148
- let newlyUpToDate = false
147
+ let shouldNotify = false
149
148
 
150
149
  messages.forEach((message) => {
151
150
  if (isChangeMessage(message)) {
152
- dataMayHaveChanged = [`insert`, `update`, `delete`].includes(
153
- message.headers.operation
154
- )
155
-
151
+ shouldNotify = this.#updateShapeStatus(`syncing`)
156
152
  switch (message.headers.operation) {
157
153
  case `insert`:
158
154
  this.#data.set(message.key, message.value)
@@ -172,28 +168,24 @@ export class Shape<T extends Row<unknown> = Row> {
172
168
  if (isControlMessage(message)) {
173
169
  switch (message.headers.control) {
174
170
  case `up-to-date`:
175
- isUpToDate = true
176
- if (!this.#hasNotifiedSubscribersUpToDate) {
177
- newlyUpToDate = true
178
- }
171
+ shouldNotify = this.#updateShapeStatus(`up-to-date`)
179
172
  break
180
173
  case `must-refetch`:
181
174
  this.#data.clear()
182
175
  this.#error = false
183
- this.#hasNotifiedSubscribersUpToDate = false
184
- isUpToDate = false
185
- newlyUpToDate = false
176
+ shouldNotify = this.#updateShapeStatus(`syncing`)
186
177
  break
187
178
  }
188
179
  }
189
180
  })
190
181
 
191
- // Always notify subscribers when the Shape first is up to date.
192
- // FIXME this would be cleaner with a simple state machine.
193
- if (newlyUpToDate || (isUpToDate && dataMayHaveChanged)) {
194
- this.#hasNotifiedSubscribersUpToDate = true
195
- this.#notify()
196
- }
182
+ if (shouldNotify) this.#notify()
183
+ }
184
+
185
+ #updateShapeStatus(status: ShapeStatus): boolean {
186
+ const stateChanged = this.#status !== status
187
+ this.#status = status
188
+ return stateChanged && status === `up-to-date`
197
189
  }
198
190
 
199
191
  #handleError(e: Error): void {