@electric-sql/client 0.6.5 → 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,17 +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
162
196
  #error?: unknown
197
+ #replica?: Replica
163
198
 
164
199
  constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
165
200
  validateOptions(options)
166
201
  this.options = { subscribe: true, ...options }
167
202
  this.#lastOffset = this.options.offset ?? `-1`
168
203
  this.#liveCacheBuster = ``
169
- this.#shapeId = this.options.shapeId
204
+ this.#shapeHandle = this.options.shapeHandle
205
+ this.#databaseId = this.options.databaseId
170
206
  this.#messageParser = new MessageParser<T>(options.parser)
207
+ this.#replica = this.options.replica
171
208
 
172
209
  const baseFetchClient =
173
210
  options.fetchClient ??
@@ -186,8 +223,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
186
223
  this.start()
187
224
  }
188
225
 
189
- get shapeId() {
190
- return this.#shapeId
226
+ get shapeHandle() {
227
+ return this.#shapeHandle
191
228
  }
192
229
 
193
230
  get isUpToDate() {
@@ -201,7 +238,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
201
238
  async start() {
202
239
  this.#isUpToDate = false
203
240
 
204
- const { url, where, columns, signal } = this.options
241
+ const { url, table, where, columns, signal } = this.options
205
242
 
206
243
  try {
207
244
  while (
@@ -209,6 +246,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
209
246
  this.options.subscribe
210
247
  ) {
211
248
  const fetchUrl = new URL(url)
249
+ fetchUrl.searchParams.set(TABLE_QUERY_PARAM, table)
212
250
  if (where) fetchUrl.searchParams.set(WHERE_QUERY_PARAM, where)
213
251
  if (columns && columns.length > 0)
214
252
  fetchUrl.searchParams.set(COLUMNS_QUERY_PARAM, columns.join(`,`))
@@ -222,9 +260,23 @@ export class ShapeStream<T extends Row<unknown> = Row>
222
260
  )
223
261
  }
224
262
 
225
- if (this.#shapeId) {
263
+ if (this.#shapeHandle) {
226
264
  // This should probably be a header for better cache breaking?
227
- 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)
228
280
  }
229
281
 
230
282
  let response!: Response
@@ -239,9 +291,9 @@ export class ShapeStream<T extends Row<unknown> = Row>
239
291
  if (!(e instanceof FetchError)) throw e // should never happen
240
292
  if (e.status == 409) {
241
293
  // Upon receiving a 409, we should start from scratch
242
- // with the newly provided shape ID
243
- const newShapeId = e.headers[SHAPE_ID_HEADER]
244
- this.#reset(newShapeId)
294
+ // with the newly provided shape handle
295
+ const newShapeHandle = e.headers[SHAPE_HANDLE_HEADER]
296
+ this.#reset(newShapeHandle)
245
297
  await this.#publish(e.json as Message<T>[])
246
298
  continue
247
299
  } else if (e.status >= 400 && e.status < 500) {
@@ -256,9 +308,9 @@ export class ShapeStream<T extends Row<unknown> = Row>
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)
@@ -397,12 +449,12 @@ export class ShapeStream<T extends Row<unknown> = Row>
397
449
 
398
450
  /**
399
451
  * Resets the state of the stream, optionally with a provided
400
- * shape ID
452
+ * shape handle
401
453
  */
402
- #reset(shapeId?: string) {
454
+ #reset(shapeHandle?: string) {
403
455
  this.#lastOffset = `-1`
404
456
  this.#liveCacheBuster = ``
405
- this.#shapeId = shapeId
457
+ this.#shapeHandle = shapeHandle
406
458
  this.#isUpToDate = false
407
459
  this.#connected = false
408
460
  this.#schema = undefined
@@ -411,7 +463,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
411
463
 
412
464
  function validateOptions<T>(options: Partial<ShapeStreamOptions<T>>): void {
413
465
  if (!options.url) {
414
- 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`)
415
470
  }
416
471
  if (options.signal && !(options.signal instanceof AbortSignal)) {
417
472
  throw new Error(
@@ -422,10 +477,10 @@ function validateOptions<T>(options: Partial<ShapeStreamOptions<T>>): void {
422
477
  if (
423
478
  options.offset !== undefined &&
424
479
  options.offset !== `-1` &&
425
- !options.shapeId
480
+ !options.shapeHandle
426
481
  ) {
427
482
  throw new Error(
428
- `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)`
429
484
  )
430
485
  }
431
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
  }