@electric-sql/client 0.6.4 → 0.7.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
@@ -22,21 +22,38 @@ import {
22
22
  COLUMNS_QUERY_PARAM,
23
23
  LIVE_QUERY_PARAM,
24
24
  OFFSET_QUERY_PARAM,
25
- SHAPE_ID_HEADER,
26
- SHAPE_ID_QUERY_PARAM,
25
+ SHAPE_HANDLE_HEADER,
26
+ SHAPE_HANDLE_QUERY_PARAM,
27
27
  SHAPE_SCHEMA_HEADER,
28
28
  WHERE_QUERY_PARAM,
29
+ DATABASE_ID_QUERY_PARAM,
30
+ TABLE_QUERY_PARAM,
31
+ REPLICA_PARAM,
29
32
  } from './constants'
30
33
 
34
+ type Replica = `full` | `default`
35
+
31
36
  /**
32
37
  * Options for constructing a ShapeStream.
33
38
  */
34
39
  export interface ShapeStreamOptions<T = never> {
35
40
  /**
36
- * The full URL to where the Shape is hosted. This can either be the Electric server
37
- * directly or a proxy. E.g. for a local Electric instance, you might set `http://localhost:3000/v1/shape/foo`
41
+ * The full URL to where the Shape is served. This can either be the Electric server
42
+ * directly or a proxy. E.g. for a local Electric instance, you might set `http://localhost:3000/v1/shape`
38
43
  */
39
44
  url: string
45
+
46
+ /**
47
+ * Which database to use.
48
+ * This is optional unless Electric is used with multiple databases.
49
+ */
50
+ databaseId?: string
51
+
52
+ /**
53
+ * The root table for the shape.
54
+ */
55
+ table: string
56
+
40
57
  /**
41
58
  * The where clauses for the shape.
42
59
  */
@@ -48,12 +65,23 @@ export interface ShapeStreamOptions<T = never> {
48
65
  */
49
66
  columns?: string[]
50
67
 
68
+ /**
69
+ * If `replica` is `default` (the default) then Electric will only send the
70
+ * changed columns in an update.
71
+ *
72
+ * If it's `full` Electric will send the entire row with both changed and
73
+ * unchanged values.
74
+ *
75
+ * Setting `replica` to `full` will obviously result in higher bandwidth
76
+ * usage and so is not recommended.
77
+ */
78
+ replica?: Replica
51
79
  /**
52
80
  * The "offset" on the shape log. This is typically not set as the ShapeStream
53
81
  * will handle this automatically. A common scenario where you might pass an offset
54
82
  * is if you're maintaining a local cache of the log. If you've gone offline
55
83
  * and are re-starting a ShapeStream to catch-up to the latest state of the Shape,
56
- * you'd pass in the last offset and shapeId you'd seen from the Electric server
84
+ * you'd pass in the last offset and shapeHandle you'd seen from the Electric server
57
85
  * so it knows at what point in the shape to catch you up from.
58
86
  */
59
87
  offset?: Offset
@@ -61,7 +89,7 @@ export interface ShapeStreamOptions<T = never> {
61
89
  * Similar to `offset`, this isn't typically used unless you're maintaining
62
90
  * a cache of the shape log.
63
91
  */
64
- shapeId?: string
92
+ shapeHandle?: string
65
93
  backoffOptions?: BackoffOptions
66
94
 
67
95
  /**
@@ -98,7 +126,7 @@ export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
98
126
  isConnected(): boolean
99
127
 
100
128
  isUpToDate: boolean
101
- shapeId?: string
129
+ shapeHandle?: string
102
130
  }
103
131
 
104
132
  /**
@@ -135,6 +163,11 @@ export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
135
163
  export class ShapeStream<T extends Row<unknown> = Row>
136
164
  implements ShapeStreamInterface<T>
137
165
  {
166
+ static readonly Replica = {
167
+ FULL: `full` as Replica,
168
+ DEFAULT: `default` as Replica,
169
+ }
170
+
138
171
  readonly options: ShapeStreamOptions<GetExtensions<T>>
139
172
 
140
173
  readonly #fetchClient: typeof fetch
@@ -157,16 +190,21 @@ export class ShapeStream<T extends Row<unknown> = Row>
157
190
  #lastSyncedAt?: number // unix time
158
191
  #isUpToDate: boolean = false
159
192
  #connected: boolean = false
160
- #shapeId?: string
193
+ #shapeHandle?: string
194
+ #databaseId?: string
161
195
  #schema?: Schema
196
+ #error?: unknown
197
+ #replica?: Replica
162
198
 
163
199
  constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
164
200
  validateOptions(options)
165
201
  this.options = { subscribe: true, ...options }
166
202
  this.#lastOffset = this.options.offset ?? `-1`
167
203
  this.#liveCacheBuster = ``
168
- this.#shapeId = this.options.shapeId
204
+ this.#shapeHandle = this.options.shapeHandle
205
+ this.#databaseId = this.options.databaseId
169
206
  this.#messageParser = new MessageParser<T>(options.parser)
207
+ this.#replica = this.options.replica
170
208
 
171
209
  const baseFetchClient =
172
210
  options.fetchClient ??
@@ -185,18 +223,22 @@ export class ShapeStream<T extends Row<unknown> = Row>
185
223
  this.start()
186
224
  }
187
225
 
188
- get shapeId() {
189
- return this.#shapeId
226
+ get shapeHandle() {
227
+ return this.#shapeHandle
190
228
  }
191
229
 
192
230
  get isUpToDate() {
193
231
  return this.#isUpToDate
194
232
  }
195
233
 
234
+ get error() {
235
+ return this.#error
236
+ }
237
+
196
238
  async start() {
197
239
  this.#isUpToDate = false
198
240
 
199
- const { url, where, columns, signal } = this.options
241
+ const { url, table, where, columns, signal } = this.options
200
242
 
201
243
  try {
202
244
  while (
@@ -204,6 +246,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
204
246
  this.options.subscribe
205
247
  ) {
206
248
  const fetchUrl = new URL(url)
249
+ fetchUrl.searchParams.set(TABLE_QUERY_PARAM, table)
207
250
  if (where) fetchUrl.searchParams.set(WHERE_QUERY_PARAM, where)
208
251
  if (columns && columns.length > 0)
209
252
  fetchUrl.searchParams.set(COLUMNS_QUERY_PARAM, columns.join(`,`))
@@ -217,9 +260,23 @@ export class ShapeStream<T extends Row<unknown> = Row>
217
260
  )
218
261
  }
219
262
 
220
- if (this.#shapeId) {
263
+ if (this.#shapeHandle) {
221
264
  // This should probably be a header for better cache breaking?
222
- fetchUrl.searchParams.set(SHAPE_ID_QUERY_PARAM, this.#shapeId!)
265
+ fetchUrl.searchParams.set(
266
+ SHAPE_HANDLE_QUERY_PARAM,
267
+ this.#shapeHandle!
268
+ )
269
+ }
270
+
271
+ if (this.#databaseId) {
272
+ fetchUrl.searchParams.set(DATABASE_ID_QUERY_PARAM, this.#databaseId!)
273
+ }
274
+
275
+ if (
276
+ (this.#replica ?? ShapeStream.Replica.DEFAULT) !=
277
+ ShapeStream.Replica.DEFAULT
278
+ ) {
279
+ fetchUrl.searchParams.set(REPLICA_PARAM, this.#replica as string)
223
280
  }
224
281
 
225
282
  let response!: Response
@@ -232,17 +289,11 @@ export class ShapeStream<T extends Row<unknown> = Row>
232
289
  } catch (e) {
233
290
  if (e instanceof FetchBackoffAbortError) break // interrupted
234
291
  if (!(e instanceof FetchError)) throw e // should never happen
235
- if (e.status == 400) {
236
- // The request is invalid, most likely because the shape has been deleted.
237
- // We should start from scratch, this will force the shape to be recreated.
238
- this.#reset()
239
- await this.#publish(e.json as Message<T>[])
240
- continue
241
- } else if (e.status == 409) {
292
+ if (e.status == 409) {
242
293
  // Upon receiving a 409, we should start from scratch
243
- // with the newly provided shape ID
244
- const newShapeId = e.headers[SHAPE_ID_HEADER]
245
- this.#reset(newShapeId)
294
+ // with the newly provided shape handle
295
+ const newShapeHandle = e.headers[SHAPE_HANDLE_HEADER]
296
+ this.#reset(newShapeHandle)
246
297
  await this.#publish(e.json as Message<T>[])
247
298
  continue
248
299
  } else if (e.status >= 400 && e.status < 500) {
@@ -250,15 +301,16 @@ export class ShapeStream<T extends Row<unknown> = Row>
250
301
  this.#sendErrorToUpToDateSubscribers(e)
251
302
  this.#sendErrorToSubscribers(e)
252
303
 
253
- // 400 errors are not actionable without additional user input, so we're throwing them.
304
+ // 400 errors are not actionable without additional user input,
305
+ // so we exit the loop
254
306
  throw e
255
307
  }
256
308
  }
257
309
 
258
310
  const { headers, status } = response
259
- const shapeId = headers.get(SHAPE_ID_HEADER)
260
- if (shapeId) {
261
- this.#shapeId = shapeId
311
+ const shapeHandle = headers.get(SHAPE_HANDLE_HEADER)
312
+ if (shapeHandle) {
313
+ this.#shapeHandle = shapeHandle
262
314
  }
263
315
 
264
316
  const lastOffset = headers.get(CHUNK_LAST_OFFSET_HEADER)
@@ -301,6 +353,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
301
353
  }
302
354
  }
303
355
  }
356
+ } catch (err) {
357
+ this.#error = err
304
358
  } finally {
305
359
  this.#connected = false
306
360
  }
@@ -395,12 +449,12 @@ export class ShapeStream<T extends Row<unknown> = Row>
395
449
 
396
450
  /**
397
451
  * Resets the state of the stream, optionally with a provided
398
- * shape ID
452
+ * shape handle
399
453
  */
400
- #reset(shapeId?: string) {
454
+ #reset(shapeHandle?: string) {
401
455
  this.#lastOffset = `-1`
402
456
  this.#liveCacheBuster = ``
403
- this.#shapeId = shapeId
457
+ this.#shapeHandle = shapeHandle
404
458
  this.#isUpToDate = false
405
459
  this.#connected = false
406
460
  this.#schema = undefined
@@ -409,7 +463,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
409
463
 
410
464
  function validateOptions<T>(options: Partial<ShapeStreamOptions<T>>): void {
411
465
  if (!options.url) {
412
- throw new Error(`Invalid shape option. It must provide the url`)
466
+ throw new Error(`Invalid shape options. It must provide the url`)
467
+ }
468
+ if (!options.table) {
469
+ throw new Error(`Invalid shape options. It must provide the table`)
413
470
  }
414
471
  if (options.signal && !(options.signal instanceof AbortSignal)) {
415
472
  throw new Error(
@@ -420,10 +477,10 @@ function validateOptions<T>(options: Partial<ShapeStreamOptions<T>>): void {
420
477
  if (
421
478
  options.offset !== undefined &&
422
479
  options.offset !== `-1` &&
423
- !options.shapeId
480
+ !options.shapeHandle
424
481
  ) {
425
482
  throw new Error(
426
- `shapeId is required if this isn't an initial fetch (i.e. offset > -1)`
483
+ `shapeHandle is required if this isn't an initial fetch (i.e. offset > -1)`
427
484
  )
428
485
  }
429
486
  return
package/src/constants.ts CHANGED
@@ -1,11 +1,14 @@
1
- export const SHAPE_ID_HEADER = `electric-shape-id`
2
- export const LIVE_CACHE_BUSTER_HEADER = `electric-next-cursor`
3
- export const LIVE_CACHE_BUSTER_QUERY_PARAM = `cursor`
4
- export const CHUNK_LAST_OFFSET_HEADER = `electric-chunk-last-offset`
5
- export const CHUNK_UP_TO_DATE_HEADER = `electric-chunk-up-to-date`
1
+ export const LIVE_CACHE_BUSTER_HEADER = `electric-cursor`
2
+ export const SHAPE_HANDLE_HEADER = `electric-handle`
3
+ export const CHUNK_LAST_OFFSET_HEADER = `electric-offset`
6
4
  export const SHAPE_SCHEMA_HEADER = `electric-schema`
7
- export const SHAPE_ID_QUERY_PARAM = `shape_id`
8
- export const OFFSET_QUERY_PARAM = `offset`
9
- export const WHERE_QUERY_PARAM = `where`
5
+ export const CHUNK_UP_TO_DATE_HEADER = `electric-up-to-date`
6
+ export const DATABASE_ID_QUERY_PARAM = `database_id`
10
7
  export const COLUMNS_QUERY_PARAM = `columns`
8
+ export const LIVE_CACHE_BUSTER_QUERY_PARAM = `cursor`
9
+ export const SHAPE_HANDLE_QUERY_PARAM = `handle`
11
10
  export const LIVE_QUERY_PARAM = `live`
11
+ export const OFFSET_QUERY_PARAM = `offset`
12
+ export const TABLE_QUERY_PARAM = `table`
13
+ export const WHERE_QUERY_PARAM = `where`
14
+ export const REPLICA_PARAM = `replica`
package/src/fetch.ts CHANGED
@@ -3,8 +3,8 @@ import {
3
3
  CHUNK_UP_TO_DATE_HEADER,
4
4
  LIVE_QUERY_PARAM,
5
5
  OFFSET_QUERY_PARAM,
6
- SHAPE_ID_HEADER,
7
- SHAPE_ID_QUERY_PARAM,
6
+ SHAPE_HANDLE_HEADER,
7
+ SHAPE_HANDLE_QUERY_PARAM,
8
8
  } from './constants'
9
9
  import { FetchError, FetchBackoffAbortError } from './error'
10
10
 
@@ -245,13 +245,13 @@ class PrefetchQueue {
245
245
  * Generate the next chunk's URL if the url and response are valid
246
246
  */
247
247
  function getNextChunkUrl(url: string, res: Response): string | void {
248
- const shapeId = res.headers.get(SHAPE_ID_HEADER)
248
+ const shapeHandle = res.headers.get(SHAPE_HANDLE_HEADER)
249
249
  const lastOffset = res.headers.get(CHUNK_LAST_OFFSET_HEADER)
250
250
  const isUpToDate = res.headers.has(CHUNK_UP_TO_DATE_HEADER)
251
251
 
252
- // only prefetch if shape ID and offset for next chunk are available, and
252
+ // only prefetch if shape handle and offset for next chunk are available, and
253
253
  // response is not already up-to-date
254
- if (!shapeId || !lastOffset || isUpToDate) return
254
+ if (!shapeHandle || !lastOffset || isUpToDate) return
255
255
 
256
256
  const nextUrl = new URL(url)
257
257
 
@@ -259,7 +259,7 @@ function getNextChunkUrl(url: string, res: Response): string | void {
259
259
  // potentially miss more recent data
260
260
  if (nextUrl.searchParams.has(LIVE_QUERY_PARAM)) return
261
261
 
262
- nextUrl.searchParams.set(SHAPE_ID_QUERY_PARAM, shapeId)
262
+ nextUrl.searchParams.set(SHAPE_HANDLE_QUERY_PARAM, shapeHandle)
263
263
  nextUrl.searchParams.set(OFFSET_QUERY_PARAM, lastOffset)
264
264
  return nextUrl.toString()
265
265
  }
@@ -271,7 +271,7 @@ function getNextChunkUrl(url: string, res: Response): string | void {
271
271
  */
272
272
  function chainAborter(
273
273
  aborter: AbortController,
274
- sourceSignal?: AbortSignal
274
+ sourceSignal?: AbortSignal | null
275
275
  ): AbortSignal {
276
276
  if (!sourceSignal) return aborter.signal
277
277
  if (sourceSignal.aborted) aborter.abort()
package/src/shape.ts CHANGED
@@ -4,13 +4,14 @@ import { FetchError } from './error'
4
4
  import { ShapeStreamInterface } from './client'
5
5
 
6
6
  export type ShapeData<T extends Row<unknown> = Row> = Map<string, T>
7
- export type ShapeChangedCallback<T extends Row<unknown> = Row> = (
7
+ export type ShapeChangedCallback<T extends Row<unknown> = Row> = (data: {
8
8
  value: ShapeData<T>
9
- ) => void
9
+ rows: T[]
10
+ }) => void
10
11
 
11
12
  /**
12
13
  * A Shape is an object that subscribes to a shape log,
13
- * keeps a materialised shape `.value` in memory and
14
+ * keeps a materialised shape `.rows` in memory and
14
15
  * notifies subscribers when the value has changed.
15
16
  *
16
17
  * It can be used without a framework and as a primitive
@@ -20,23 +21,23 @@ export type ShapeChangedCallback<T extends Row<unknown> = Row> = (
20
21
  * @param {ShapeStream<T extends Row>} - the underlying shape stream
21
22
  * @example
22
23
  * ```
23
- * const shapeStream = new ShapeStream<{ foo: number }>(url: 'http://localhost:3000/v1/shape/foo'})
24
+ * const shapeStream = new ShapeStream<{ foo: number }>(url: `http://localhost:3000/v1/shape`, table: `foo`})
24
25
  * const shape = new Shape(shapeStream)
25
26
  * ```
26
27
  *
27
- * `value` returns a promise that resolves the Shape data once the Shape has been
28
+ * `rows` returns a promise that resolves the Shape data once the Shape has been
28
29
  * fully loaded (and when resuming from being offline):
29
30
  *
30
- * const value = await shape.value
31
+ * const rows = await shape.rows
31
32
  *
32
- * `valueSync` returns the current data synchronously:
33
+ * `currentRows` returns the current data synchronously:
33
34
  *
34
- * const value = shape.valueSync
35
+ * const rows = shape.currentRows
35
36
  *
36
37
  * Subscribe to updates. Called whenever the shape updates in Postgres.
37
38
  *
38
- * shape.subscribe(shapeData => {
39
- * console.log(shapeData)
39
+ * shape.subscribe(({ rows }) => {
40
+ * console.log(rows)
40
41
  * })
41
42
  */
42
43
  export class Shape<T extends Row<unknown> = Row> {
@@ -69,21 +70,29 @@ export class Shape<T extends Row<unknown> = Row> {
69
70
  return this.#stream.isUpToDate
70
71
  }
71
72
 
73
+ get rows(): Promise<T[]> {
74
+ return this.value.then((v) => Array.from(v.values()))
75
+ }
76
+
77
+ get currentRows(): T[] {
78
+ return Array.from(this.currentValue.values())
79
+ }
80
+
72
81
  get value(): Promise<ShapeData<T>> {
73
82
  return new Promise((resolve, reject) => {
74
83
  if (this.#stream.isUpToDate) {
75
- resolve(this.valueSync)
84
+ resolve(this.currentValue)
76
85
  } else {
77
- const unsubscribe = this.subscribe((shapeData) => {
86
+ const unsubscribe = this.subscribe(({ value }) => {
78
87
  unsubscribe()
79
88
  if (this.#error) reject(this.#error)
80
- resolve(shapeData)
89
+ resolve(value)
81
90
  })
82
91
  }
83
92
  })
84
93
  }
85
94
 
86
- get valueSync() {
95
+ get currentValue() {
87
96
  return this.#data
88
97
  }
89
98
 
@@ -192,7 +201,7 @@ export class Shape<T extends Row<unknown> = Row> {
192
201
 
193
202
  #notify(): void {
194
203
  this.#subscribers.forEach((callback) => {
195
- callback(this.valueSync)
204
+ callback({ value: this.currentValue, rows: this.currentRows })
196
205
  })
197
206
  }
198
207
  }