@electric-sql/client 0.3.3 → 0.4.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
@@ -1,9 +1,11 @@
1
- import { Message, Value, Offset, Schema } from './types'
1
+ import { Message, Offset, Schema, Row } from './types'
2
2
  import { MessageParser, Parser } from './parser'
3
3
  import { isChangeMessage, isControlMessage } from './helpers'
4
4
 
5
- export type ShapeData = Map<string, { [key: string]: Value }>
6
- export type ShapeChangedCallback = (value: ShapeData) => void
5
+ export type ShapeData<T extends Row = Row> = Map<string, T>
6
+ export type ShapeChangedCallback<T extends Row = Row> = (
7
+ value: ShapeData<T>
8
+ ) => void
7
9
 
8
10
  export interface BackoffOptions {
9
11
  initialDelay: number
@@ -62,16 +64,16 @@ export interface ShapeStreamOptions {
62
64
  * @constructor
63
65
  * @param {(messages: Message[]) => void} callback function
64
66
  */
65
- class MessageProcessor {
66
- private messageQueue: Message[][] = []
67
+ class MessageProcessor<T extends Row = Row> {
68
+ private messageQueue: Message<T>[][] = []
67
69
  private isProcessing = false
68
- private callback: (messages: Message[]) => void | Promise<void>
70
+ private callback: (messages: Message<T>[]) => void | Promise<void>
69
71
 
70
- constructor(callback: (messages: Message[]) => void | Promise<void>) {
72
+ constructor(callback: (messages: Message<T>[]) => void | Promise<void>) {
71
73
  this.callback = callback
72
74
  }
73
75
 
74
- process(messages: Message[]) {
76
+ process(messages: Message<T>[]) {
75
77
  this.messageQueue.push(messages)
76
78
 
77
79
  if (!this.isProcessing) {
@@ -144,28 +146,30 @@ export class FetchError extends Error {
144
146
  * to consume the HTTP `GET /v1/shape` api.
145
147
  *
146
148
  * @constructor
147
- * @param {ShapeStreamOptions} options
148
- *
149
+ * @param {ShapeStreamOptions} options - configure the shape stream
150
+ * @example
149
151
  * Register a callback function to subscribe to the messages.
150
- *
151
- * const stream = new ShapeStream(options)
152
- * stream.subscribe(messages => {
153
- * // messages is 1 or more row updates
154
- * })
152
+ * ```
153
+ * const stream = new ShapeStream(options)
154
+ * stream.subscribe(messages => {
155
+ * // messages is 1 or more row updates
156
+ * })
157
+ * ```
155
158
  *
156
159
  * To abort the stream, abort the `signal`
157
160
  * passed in via the `ShapeStreamOptions`.
158
- *
159
- * const aborter = new AbortController()
160
- * const issueStream = new ShapeStream({
161
- * url: `${BASE_URL}/${table}`
162
- * subscribe: true,
163
- * signal: aborter.signal,
164
- * })
165
- * // Later...
166
- * aborter.abort()
161
+ * ```
162
+ * const aborter = new AbortController()
163
+ * const issueStream = new ShapeStream({
164
+ * url: `${BASE_URL}/${table}`
165
+ * subscribe: true,
166
+ * signal: aborter.signal,
167
+ * })
168
+ * // Later...
169
+ * aborter.abort()
170
+ * ```
167
171
  */
168
- export class ShapeStream {
172
+ export class ShapeStream<T extends Row = Row> {
169
173
  private options: ShapeStreamOptions
170
174
  private backoffOptions: BackoffOptions
171
175
  private fetchClient: typeof fetch
@@ -173,7 +177,7 @@ export class ShapeStream {
173
177
 
174
178
  private subscribers = new Map<
175
179
  number,
176
- [MessageProcessor, ((error: Error) => void) | undefined]
180
+ [MessageProcessor<T>, ((error: Error) => void) | undefined]
177
181
  >()
178
182
  private upToDateSubscribers = new Map<
179
183
  number,
@@ -181,8 +185,10 @@ export class ShapeStream {
181
185
  >()
182
186
 
183
187
  private lastOffset: Offset
184
- private messageParser: MessageParser
188
+ private messageParser: MessageParser<T>
189
+ private lastSyncedAt?: number // unix time
185
190
  public isUpToDate: boolean = false
191
+ private connected: boolean = false
186
192
 
187
193
  shapeId?: string
188
194
 
@@ -191,7 +197,7 @@ export class ShapeStream {
191
197
  this.options = { subscribe: true, ...options }
192
198
  this.lastOffset = this.options.offset ?? `-1`
193
199
  this.shapeId = this.options.shapeId
194
- this.messageParser = new MessageParser(options.parser)
200
+ this.messageParser = new MessageParser<T>(options.parser)
195
201
 
196
202
  this.backoffOptions = options.backoffOptions ?? BackoffDefaults
197
203
  this.fetchClient =
@@ -206,85 +212,96 @@ export class ShapeStream {
206
212
 
207
213
  const { url, where, signal } = this.options
208
214
 
209
- while ((!signal?.aborted && !this.isUpToDate) || this.options.subscribe) {
210
- const fetchUrl = new URL(url)
211
- if (where) fetchUrl.searchParams.set(`where`, where)
212
- fetchUrl.searchParams.set(`offset`, this.lastOffset)
215
+ try {
216
+ while ((!signal?.aborted && !this.isUpToDate) || this.options.subscribe) {
217
+ const fetchUrl = new URL(url)
218
+ if (where) fetchUrl.searchParams.set(`where`, where)
219
+ fetchUrl.searchParams.set(`offset`, this.lastOffset)
213
220
 
214
- if (this.isUpToDate) {
215
- fetchUrl.searchParams.set(`live`, `true`)
216
- }
221
+ if (this.isUpToDate) {
222
+ fetchUrl.searchParams.set(`live`, `true`)
223
+ }
217
224
 
218
- if (this.shapeId) {
219
- // This should probably be a header for better cache breaking?
220
- fetchUrl.searchParams.set(`shape_id`, this.shapeId!)
221
- }
225
+ if (this.shapeId) {
226
+ // This should probably be a header for better cache breaking?
227
+ fetchUrl.searchParams.set(`shape_id`, this.shapeId!)
228
+ }
222
229
 
223
- let response!: Response
230
+ let response!: Response
231
+
232
+ try {
233
+ const maybeResponse = await this.fetchWithBackoff(fetchUrl)
234
+ if (maybeResponse) response = maybeResponse
235
+ else break
236
+ } catch (e) {
237
+ if (!(e instanceof FetchError)) throw e // should never happen
238
+ if (e.status == 409) {
239
+ // Upon receiving a 409, we should start from scratch
240
+ // with the newly provided shape ID
241
+ const newShapeId = e.headers[`x-electric-shape-id`]
242
+ this.reset(newShapeId)
243
+ this.publish(e.json as Message<T>[])
244
+ continue
245
+ } else if (e.status >= 400 && e.status < 500) {
246
+ // Notify subscribers
247
+ this.sendErrorToUpToDateSubscribers(e)
248
+ this.sendErrorToSubscribers(e)
249
+
250
+ // 400 errors are not actionable without additional user input, so we're throwing them.
251
+ throw e
252
+ }
253
+ }
224
254
 
225
- try {
226
- const maybeResponse = await this.fetchWithBackoff(fetchUrl)
227
- if (maybeResponse) response = maybeResponse
228
- else break
229
- } catch (e) {
230
- if (!(e instanceof FetchError)) throw e // should never happen
231
- if (e.status == 409) {
232
- // Upon receiving a 409, we should start from scratch
233
- // with the newly provided shape ID
234
- const newShapeId = e.headers[`x-electric-shape-id`]
235
- this.reset(newShapeId)
236
- this.publish(e.json as Message[])
237
- continue
238
- } else if (e.status >= 400 && e.status < 500) {
239
- // Notify subscribers
240
- this.sendErrorToUpToDateSubscribers(e)
241
- this.sendErrorToSubscribers(e)
242
-
243
- // 400 errors are not actionable without additional user input, so we're throwing them.
244
- throw e
255
+ const { headers, status } = response
256
+ const shapeId = headers.get(`X-Electric-Shape-Id`)
257
+ if (shapeId) {
258
+ this.shapeId = shapeId
245
259
  }
246
- }
247
260
 
248
- const { headers, status } = response
249
- const shapeId = headers.get(`X-Electric-Shape-Id`)
250
- if (shapeId) {
251
- this.shapeId = shapeId
252
- }
261
+ const lastOffset = headers.get(`X-Electric-Chunk-Last-Offset`)
262
+ if (lastOffset) {
263
+ this.lastOffset = lastOffset as Offset
264
+ }
253
265
 
254
- const lastOffset = headers.get(`X-Electric-Chunk-Last-Offset`)
255
- if (lastOffset) {
256
- this.lastOffset = lastOffset as Offset
257
- }
266
+ const getSchema = (): Schema => {
267
+ const schemaHeader = headers.get(`X-Electric-Schema`)
268
+ return schemaHeader ? JSON.parse(schemaHeader) : {}
269
+ }
270
+ this.schema = this.schema ?? getSchema()
258
271
 
259
- const getSchema = (): Schema => {
260
- const schemaHeader = headers.get(`X-Electric-Schema`)
261
- return schemaHeader ? JSON.parse(schemaHeader) : {}
262
- }
263
- this.schema = this.schema ?? getSchema()
272
+ const messages = status === 204 ? `[]` : await response.text()
264
273
 
265
- const messages = status === 204 ? `[]` : await response.text()
274
+ if (status === 204) {
275
+ // There's no content so we are live and up to date
276
+ this.lastSyncedAt = Date.now()
277
+ }
266
278
 
267
- const batch = this.messageParser.parse(messages, this.schema)
279
+ const batch = this.messageParser.parse(messages, this.schema)
280
+
281
+ // Update isUpToDate
282
+ if (batch.length > 0) {
283
+ const lastMessage = batch[batch.length - 1]
284
+ if (
285
+ isControlMessage(lastMessage) &&
286
+ lastMessage.headers.control === `up-to-date`
287
+ ) {
288
+ this.lastSyncedAt = Date.now()
289
+ if (!this.isUpToDate) {
290
+ this.isUpToDate = true
291
+ this.notifyUpToDateSubscribers()
292
+ }
293
+ }
268
294
 
269
- // Update isUpToDate
270
- if (batch.length > 0) {
271
- const lastMessage = batch[batch.length - 1]
272
- if (
273
- isControlMessage(lastMessage) &&
274
- lastMessage.headers.control === `up-to-date` &&
275
- !this.isUpToDate
276
- ) {
277
- this.isUpToDate = true
278
- this.notifyUpToDateSubscribers()
295
+ this.publish(batch)
279
296
  }
280
-
281
- this.publish(batch)
282
297
  }
298
+ } finally {
299
+ this.connected = false
283
300
  }
284
301
  }
285
302
 
286
303
  subscribe(
287
- callback: (messages: Message[]) => void | Promise<void>,
304
+ callback: (messages: Message<T>[]) => void | Promise<void>,
288
305
  onError?: (error: FetchError | Error) => void
289
306
  ) {
290
307
  const subscriptionId = Math.random()
@@ -301,7 +318,7 @@ export class ShapeStream {
301
318
  this.subscribers.clear()
302
319
  }
303
320
 
304
- private publish(messages: Message[]) {
321
+ private publish(messages: Message<T>[]) {
305
322
  this.subscribers.forEach(([subscriber, _]) => {
306
323
  subscriber.process(messages)
307
324
  })
@@ -330,6 +347,16 @@ export class ShapeStream {
330
347
  this.upToDateSubscribers.clear()
331
348
  }
332
349
 
350
+ /** Time elapsed since last sync (in ms). Infinity if we did not yet sync. */
351
+ lastSynced(): number {
352
+ if (this.lastSyncedAt === undefined) return Infinity
353
+ return Date.now() - this.lastSyncedAt
354
+ }
355
+
356
+ isConnected(): boolean {
357
+ return this.connected
358
+ }
359
+
333
360
  private notifyUpToDateSubscribers() {
334
361
  this.upToDateSubscribers.forEach(([callback]) => {
335
362
  callback()
@@ -351,6 +378,7 @@ export class ShapeStream {
351
378
  this.lastOffset = `-1`
352
379
  this.shapeId = shapeId
353
380
  this.isUpToDate = false
381
+ this.connected = false
354
382
  this.schema = undefined
355
383
  }
356
384
 
@@ -386,9 +414,14 @@ export class ShapeStream {
386
414
  while (true) {
387
415
  try {
388
416
  const result = await this.fetchClient(url.toString(), { signal })
389
- if (result.ok) return result
390
- else throw await FetchError.fromResponse(result, url.toString())
417
+ if (result.ok) {
418
+ if (this.options.subscribe) {
419
+ this.connected = true
420
+ }
421
+ return result
422
+ } else throw await FetchError.fromResponse(result, url.toString())
391
423
  } catch (e) {
424
+ this.connected = false
392
425
  if (signal?.aborted) {
393
426
  return undefined
394
427
  } else if (
@@ -423,10 +456,12 @@ export class ShapeStream {
423
456
  * to simplify developing framework hooks.
424
457
  *
425
458
  * @constructor
426
- * @param {Shape}
427
- *
428
- * const shapeStream = new ShapeStream(url: 'http://localhost:3000/v1/shape/foo'})
429
- * const shape = new Shape(shapeStream)
459
+ * @param {ShapeStream<T extends Row>} - the underlying shape stream
460
+ * @example
461
+ * ```
462
+ * const shapeStream = new ShapeStream<{ foo: number }>(url: 'http://localhost:3000/v1/shape/foo'})
463
+ * const shape = new Shape(shapeStream)
464
+ * ```
430
465
  *
431
466
  * `value` returns a promise that resolves the Shape data once the Shape has been
432
467
  * fully loaded (and when resuming from being offline):
@@ -443,15 +478,15 @@ export class ShapeStream {
443
478
  * console.log(shapeData)
444
479
  * })
445
480
  */
446
- export class Shape {
447
- private stream: ShapeStream
481
+ export class Shape<T extends Row = Row> {
482
+ private stream: ShapeStream<T>
448
483
 
449
- private data: ShapeData = new Map()
450
- private subscribers = new Map<number, ShapeChangedCallback>()
484
+ private data: ShapeData<T> = new Map()
485
+ private subscribers = new Map<number, ShapeChangedCallback<T>>()
451
486
  public error: FetchError | false = false
452
487
  private hasNotifiedSubscribersUpToDate: boolean = false
453
488
 
454
- constructor(stream: ShapeStream) {
489
+ constructor(stream: ShapeStream<T>) {
455
490
  this.stream = stream
456
491
  this.stream.subscribe(this.process.bind(this), this.handleError.bind(this))
457
492
  const unsubscribe = this.stream.subscribeOnceToUpToDate(
@@ -465,11 +500,15 @@ export class Shape {
465
500
  )
466
501
  }
467
502
 
468
- get isUpToDate(): boolean {
469
- return this.stream.isUpToDate
503
+ lastSynced(): number {
504
+ return this.stream.lastSynced()
505
+ }
506
+
507
+ isConnected(): boolean {
508
+ return this.stream.isConnected()
470
509
  }
471
510
 
472
- get value(): Promise<ShapeData> {
511
+ get value(): Promise<ShapeData<T>> {
473
512
  return new Promise((resolve) => {
474
513
  if (this.stream.isUpToDate) {
475
514
  resolve(this.valueSync)
@@ -491,7 +530,7 @@ export class Shape {
491
530
  return this.data
492
531
  }
493
532
 
494
- subscribe(callback: ShapeChangedCallback): () => void {
533
+ subscribe(callback: ShapeChangedCallback<T>): () => void {
495
534
  const subscriptionId = Math.random()
496
535
 
497
536
  this.subscribers.set(subscriptionId, callback)
@@ -509,7 +548,7 @@ export class Shape {
509
548
  return this.subscribers.size
510
549
  }
511
550
 
512
- private process(messages: Message[]): void {
551
+ private process(messages: Message<T>[]): void {
513
552
  let dataMayHaveChanged = false
514
553
  let isUpToDate = false
515
554
  let newlyUpToDate = false
package/src/helpers.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ChangeMessage, ControlMessage, Message, Value } from './types'
1
+ import { ChangeMessage, ControlMessage, Message, Row } from './types'
2
2
 
3
3
  /**
4
4
  * Type guard for checking {@link Message} is {@link ChangeMessage}.
@@ -17,7 +17,7 @@ import { ChangeMessage, ControlMessage, Message, Value } from './types'
17
17
  * }
18
18
  * ```
19
19
  */
20
- export function isChangeMessage<T extends Value = { [key: string]: Value }>(
20
+ export function isChangeMessage<T extends Row = Row>(
21
21
  message: Message<T>
22
22
  ): message is ChangeMessage<T> {
23
23
  return `key` in message
@@ -40,7 +40,7 @@ export function isChangeMessage<T extends Value = { [key: string]: Value }>(
40
40
  * }
41
41
  * ```
42
42
  */
43
- export function isControlMessage<T extends Value = { [key: string]: Value }>(
43
+ export function isControlMessage<T extends Row = Row>(
44
44
  message: Message<T>
45
45
  ): message is ControlMessage {
46
46
  return !isChangeMessage(message)
package/src/parser.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ColumnInfo, Message, Schema, Value } from './types'
1
+ import { ColumnInfo, Message, Row, Schema, Value } from './types'
2
2
 
3
3
  type NullToken = null | `NULL`
4
4
  type Token = Exclude<string, NullToken>
@@ -79,7 +79,7 @@ export function pgArrayParser(value: Token, parser?: ParseFunction): Value {
79
79
  return loop(value)[0]
80
80
  }
81
81
 
82
- export class MessageParser {
82
+ export class MessageParser<T extends Row> {
83
83
  private parser: Parser
84
84
  constructor(parser?: Parser) {
85
85
  // Merge the provided parser with the default parser
@@ -88,7 +88,7 @@ export class MessageParser {
88
88
  this.parser = { ...defaultParser, ...parser }
89
89
  }
90
90
 
91
- parse(messages: string, schema: Schema): Message[] {
91
+ parse(messages: string, schema: Schema): Message<T>[] {
92
92
  return JSON.parse(messages, (key, value) => {
93
93
  // typeof value === `object` is needed because
94
94
  // there could be a column named `value`
@@ -101,7 +101,7 @@ export class MessageParser {
101
101
  })
102
102
  }
103
103
  return value
104
- }) as Message[]
104
+ }) as Message<T>[]
105
105
  }
106
106
 
107
107
  // Parses the message values using the provided parser based on the schema information
package/src/types.ts CHANGED
@@ -7,6 +7,8 @@ export type Value =
7
7
  | Value[]
8
8
  | { [key: string]: Value }
9
9
 
10
+ export type Row = { [key: string]: Value }
11
+
10
12
  export type Offset = `-1` | `${number}_${number}`
11
13
 
12
14
  interface Header {
@@ -17,7 +19,7 @@ export type ControlMessage = {
17
19
  headers: Header & { control: `up-to-date` | `must-refetch` }
18
20
  }
19
21
 
20
- export type ChangeMessage<T> = {
22
+ export type ChangeMessage<T extends Row = Row> = {
21
23
  key: string
22
24
  value: T
23
25
  headers: Header & { operation: `insert` | `update` | `delete` }
@@ -25,9 +27,7 @@ export type ChangeMessage<T> = {
25
27
  }
26
28
 
27
29
  // Define the type for a record
28
- export type Message<T extends Value = { [key: string]: Value }> =
29
- | ControlMessage
30
- | ChangeMessage<T>
30
+ export type Message<T extends Row = Row> = ControlMessage | ChangeMessage<T>
31
31
 
32
32
  /**
33
33
  * Common properties for all columns.
@@ -104,7 +104,7 @@ export type ColumnInfo =
104
104
 
105
105
  export type Schema = { [key: string]: ColumnInfo }
106
106
 
107
- export type TypedMessages<T extends Value = { [key: string]: Value }> = {
107
+ export type TypedMessages<T extends Row = Row> = {
108
108
  messages: Array<Message<T>>
109
109
  schema: ColumnInfo
110
110
  }