@electric-sql/client 0.5.0 → 0.6.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,23 +1,22 @@
1
- import { Message, Offset, Schema, Row } from './types'
1
+ import { Message, Offset, Schema, Row, MaybePromise } from './types'
2
2
  import { MessageParser, Parser } from './parser'
3
- import { isChangeMessage, isControlMessage } from './helpers'
4
-
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
9
-
10
- export interface BackoffOptions {
11
- initialDelay: number
12
- maxDelay: number
13
- multiplier: number
14
- }
15
-
16
- export const BackoffDefaults = {
17
- initialDelay: 100,
18
- maxDelay: 10_000,
19
- multiplier: 1.3,
20
- }
3
+ import { isUpToDateMessage } from './helpers'
4
+ import { FetchError, FetchBackoffAbortError } from './error'
5
+ import {
6
+ BackoffDefaults,
7
+ BackoffOptions,
8
+ createFetchWithBackoff,
9
+ createFetchWithChunkBuffer,
10
+ } from './fetch'
11
+ import {
12
+ CHUNK_LAST_OFFSET_HEADER,
13
+ LIVE_QUERY_PARAM,
14
+ OFFSET_QUERY_PARAM,
15
+ SHAPE_ID_HEADER,
16
+ SHAPE_ID_QUERY_PARAM,
17
+ SHAPE_SCHEMA_HEADER,
18
+ WHERE_QUERY_PARAM,
19
+ } from './constants'
21
20
 
22
21
  /**
23
22
  * Options for constructing a ShapeStream.
@@ -57,86 +56,25 @@ export interface ShapeStreamOptions {
57
56
  parser?: Parser
58
57
  }
59
58
 
60
- /**
61
- * Receives batches of `messages`, puts them on a queue and processes
62
- * them asynchronously by passing to a registered callback function.
63
- *
64
- * @constructor
65
- * @param {(messages: Message[]) => void} callback function
66
- */
67
- class MessageProcessor<T extends Row = Row> {
68
- private messageQueue: Message<T>[][] = []
69
- private isProcessing = false
70
- private callback: (messages: Message<T>[]) => void | Promise<void>
71
-
72
- constructor(callback: (messages: Message<T>[]) => void | Promise<void>) {
73
- this.callback = callback
74
- }
75
-
76
- process(messages: Message<T>[]) {
77
- this.messageQueue.push(messages)
78
-
79
- if (!this.isProcessing) {
80
- this.processQueue()
81
- }
82
- }
83
-
84
- private async processQueue() {
85
- this.isProcessing = true
86
-
87
- while (this.messageQueue.length > 0) {
88
- const messages = this.messageQueue.shift()!
89
-
90
- await this.callback(messages)
91
- }
92
-
93
- this.isProcessing = false
94
- }
95
- }
96
-
97
- export class FetchError extends Error {
98
- status: number
99
- text?: string
100
- json?: object
101
- headers: Record<string, string>
102
-
103
- constructor(
104
- status: number,
105
- text: string | undefined,
106
- json: object | undefined,
107
- headers: Record<string, string>,
108
- public url: string,
109
- message?: string
110
- ) {
111
- super(
112
- message ||
113
- `HTTP Error ${status} at ${url}: ${text ?? JSON.stringify(json)}`
114
- )
115
- this.name = `FetchError`
116
- this.status = status
117
- this.text = text
118
- this.json = json
119
- this.headers = headers
120
- }
59
+ export interface ShapeStreamInterface<T extends Row = Row> {
60
+ subscribe(
61
+ callback: (messages: Message<T>[]) => MaybePromise<void>,
62
+ onError?: (error: FetchError | Error) => void
63
+ ): void
64
+ unsubscribeAllUpToDateSubscribers(): void
65
+ unsubscribeAll(): void
66
+ subscribeOnceToUpToDate(
67
+ callback: () => MaybePromise<void>,
68
+ error: (err: FetchError | Error) => void
69
+ ): () => void
121
70
 
122
- static async fromResponse(
123
- response: Response,
124
- url: string
125
- ): Promise<FetchError> {
126
- const status = response.status
127
- const headers = Object.fromEntries([...response.headers.entries()])
128
- let text: string | undefined = undefined
129
- let json: object | undefined = undefined
130
-
131
- const contentType = response.headers.get(`content-type`)
132
- if (contentType && contentType.includes(`application/json`)) {
133
- json = (await response.json()) as object
134
- } else {
135
- text = await response.text()
136
- }
71
+ isLoading(): boolean
72
+ lastSyncedAt(): number | undefined
73
+ lastSynced(): number
74
+ isConnected(): boolean
137
75
 
138
- return new FetchError(status, text, json, headers, url)
139
- }
76
+ isUpToDate: boolean
77
+ shapeId?: string
140
78
  }
141
79
 
142
80
  /**
@@ -169,89 +107,113 @@ export class FetchError extends Error {
169
107
  * aborter.abort()
170
108
  * ```
171
109
  */
172
- export class ShapeStream<T extends Row = Row> {
173
- private options: ShapeStreamOptions
174
- private backoffOptions: BackoffOptions
175
- private fetchClient: typeof fetch
176
- private schema?: Schema
177
110
 
178
- private subscribers = new Map<
111
+ export class ShapeStream<T extends Row = Row>
112
+ implements ShapeStreamInterface<T>
113
+ {
114
+ readonly options: ShapeStreamOptions
115
+
116
+ readonly #fetchClient: typeof fetch
117
+ readonly #messageParser: MessageParser<T>
118
+
119
+ readonly #subscribers = new Map<
179
120
  number,
180
- [MessageProcessor<T>, ((error: Error) => void) | undefined]
121
+ [
122
+ (messages: Message<T>[]) => MaybePromise<void>,
123
+ ((error: Error) => void) | undefined,
124
+ ]
181
125
  >()
182
- private upToDateSubscribers = new Map<
126
+ readonly #upToDateSubscribers = new Map<
183
127
  number,
184
128
  [() => void, (error: FetchError | Error) => void]
185
129
  >()
186
130
 
187
- private lastOffset: Offset
188
- private messageParser: MessageParser<T>
189
- private lastSyncedAt?: number // unix time
190
- public isUpToDate: boolean = false
191
- private connected: boolean = false
192
-
193
- shapeId?: string
131
+ #lastOffset: Offset
132
+ #lastSyncedAt?: number // unix time
133
+ #isUpToDate: boolean = false
134
+ #connected: boolean = false
135
+ #shapeId?: string
136
+ #schema?: Schema
194
137
 
195
138
  constructor(options: ShapeStreamOptions) {
196
- this.validateOptions(options)
139
+ validateOptions(options)
197
140
  this.options = { subscribe: true, ...options }
198
- this.lastOffset = this.options.offset ?? `-1`
199
- this.shapeId = this.options.shapeId
200
- this.messageParser = new MessageParser<T>(options.parser)
141
+ this.#lastOffset = this.options.offset ?? `-1`
142
+ this.#shapeId = this.options.shapeId
143
+ this.#messageParser = new MessageParser<T>(options.parser)
201
144
 
202
- this.backoffOptions = options.backoffOptions ?? BackoffDefaults
203
- this.fetchClient =
145
+ const baseFetchClient =
204
146
  options.fetchClient ??
205
147
  ((...args: Parameters<typeof fetch>) => fetch(...args))
206
148
 
149
+ const fetchWithBackoffClient = createFetchWithBackoff(baseFetchClient, {
150
+ ...(options.backoffOptions ?? BackoffDefaults),
151
+ onFailedAttempt: () => {
152
+ this.#connected = false
153
+ options.backoffOptions?.onFailedAttempt?.()
154
+ },
155
+ })
156
+
157
+ this.#fetchClient = createFetchWithChunkBuffer(fetchWithBackoffClient)
158
+
207
159
  this.start()
208
160
  }
209
161
 
162
+ get shapeId() {
163
+ return this.#shapeId
164
+ }
165
+
166
+ get isUpToDate() {
167
+ return this.#isUpToDate
168
+ }
169
+
210
170
  async start() {
211
- this.isUpToDate = false
171
+ this.#isUpToDate = false
212
172
 
213
173
  const { url, where, signal } = this.options
214
174
 
215
175
  try {
216
- while ((!signal?.aborted && !this.isUpToDate) || this.options.subscribe) {
176
+ while (
177
+ (!signal?.aborted && !this.#isUpToDate) ||
178
+ this.options.subscribe
179
+ ) {
217
180
  const fetchUrl = new URL(url)
218
- if (where) fetchUrl.searchParams.set(`where`, where)
219
- fetchUrl.searchParams.set(`offset`, this.lastOffset)
181
+ if (where) fetchUrl.searchParams.set(WHERE_QUERY_PARAM, where)
182
+ fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, this.#lastOffset)
220
183
 
221
- if (this.isUpToDate) {
222
- fetchUrl.searchParams.set(`live`, `true`)
184
+ if (this.#isUpToDate) {
185
+ fetchUrl.searchParams.set(LIVE_QUERY_PARAM, `true`)
223
186
  }
224
187
 
225
- if (this.shapeId) {
188
+ if (this.#shapeId) {
226
189
  // This should probably be a header for better cache breaking?
227
- fetchUrl.searchParams.set(`shape_id`, this.shapeId!)
190
+ fetchUrl.searchParams.set(SHAPE_ID_QUERY_PARAM, this.#shapeId!)
228
191
  }
229
192
 
230
193
  let response!: Response
231
-
232
194
  try {
233
- const maybeResponse = await this.fetchWithBackoff(fetchUrl)
234
- if (maybeResponse) response = maybeResponse
235
- else break
195
+ response = await this.#fetchClient(fetchUrl.toString(), { signal })
196
+ this.#connected = true
236
197
  } catch (e) {
198
+ if (e instanceof FetchBackoffAbortError) break // interrupted
237
199
  if (!(e instanceof FetchError)) throw e // should never happen
238
200
  if (e.status == 400) {
239
201
  // The request is invalid, most likely because the shape has been deleted.
240
202
  // We should start from scratch, this will force the shape to be recreated.
241
- this.reset()
242
- this.publish(e.json as Message<T>[])
203
+ this.#reset()
204
+ await this.#publish(e.json as Message<T>[])
243
205
  continue
244
206
  } else if (e.status == 409) {
245
207
  // Upon receiving a 409, we should start from scratch
246
208
  // with the newly provided shape ID
247
- const newShapeId = e.headers[`x-electric-shape-id`]
248
- this.reset(newShapeId)
249
- this.publish(e.json as Message<T>[])
209
+ const newShapeId = e.headers[SHAPE_ID_HEADER]
210
+ this.#reset(newShapeId)
211
+ await this.#publish(e.json as Message<T>[])
250
212
  continue
251
213
  } else if (e.status >= 400 && e.status < 500) {
252
214
  // Notify subscribers
253
- this.sendErrorToUpToDateSubscribers(e)
254
- this.sendErrorToSubscribers(e)
215
+ this.#sendErrorToUpToDateSubscribers(e)
216
+ this.#sendErrorToSubscribers(e)
255
217
 
256
218
  // 400 errors are not actionable without additional user input, so we're throwing them.
257
219
  throw e
@@ -259,108 +221,99 @@ export class ShapeStream<T extends Row = Row> {
259
221
  }
260
222
 
261
223
  const { headers, status } = response
262
- const shapeId = headers.get(`X-Electric-Shape-Id`)
224
+ const shapeId = headers.get(SHAPE_ID_HEADER)
263
225
  if (shapeId) {
264
- this.shapeId = shapeId
226
+ this.#shapeId = shapeId
265
227
  }
266
228
 
267
- const lastOffset = headers.get(`X-Electric-Chunk-Last-Offset`)
229
+ const lastOffset = headers.get(CHUNK_LAST_OFFSET_HEADER)
268
230
  if (lastOffset) {
269
- this.lastOffset = lastOffset as Offset
231
+ this.#lastOffset = lastOffset as Offset
270
232
  }
271
233
 
272
234
  const getSchema = (): Schema => {
273
- const schemaHeader = headers.get(`X-Electric-Schema`)
235
+ const schemaHeader = headers.get(SHAPE_SCHEMA_HEADER)
274
236
  return schemaHeader ? JSON.parse(schemaHeader) : {}
275
237
  }
276
- this.schema = this.schema ?? getSchema()
238
+ this.#schema = this.#schema ?? getSchema()
277
239
 
278
240
  const messages = status === 204 ? `[]` : await response.text()
279
241
 
280
242
  if (status === 204) {
281
243
  // There's no content so we are live and up to date
282
- this.lastSyncedAt = Date.now()
244
+ this.#lastSyncedAt = Date.now()
283
245
  }
284
246
 
285
- const batch = this.messageParser.parse(messages, this.schema)
247
+ const batch = this.#messageParser.parse(messages, this.#schema)
286
248
 
287
249
  // Update isUpToDate
288
250
  if (batch.length > 0) {
251
+ const prevUpToDate = this.#isUpToDate
289
252
  const lastMessage = batch[batch.length - 1]
290
- if (
291
- isControlMessage(lastMessage) &&
292
- lastMessage.headers.control === `up-to-date`
293
- ) {
294
- this.lastSyncedAt = Date.now()
295
- if (!this.isUpToDate) {
296
- this.isUpToDate = true
297
- this.notifyUpToDateSubscribers()
298
- }
253
+ if (isUpToDateMessage(lastMessage)) {
254
+ this.#lastSyncedAt = Date.now()
255
+ this.#isUpToDate = true
299
256
  }
300
257
 
301
- this.publish(batch)
258
+ await this.#publish(batch)
259
+ if (!prevUpToDate && this.#isUpToDate) {
260
+ this.#notifyUpToDateSubscribers()
261
+ }
302
262
  }
303
263
  }
304
264
  } finally {
305
- this.connected = false
265
+ this.#connected = false
306
266
  }
307
267
  }
308
268
 
309
269
  subscribe(
310
- callback: (messages: Message<T>[]) => void | Promise<void>,
270
+ callback: (messages: Message<T>[]) => MaybePromise<void>,
311
271
  onError?: (error: FetchError | Error) => void
312
272
  ) {
313
273
  const subscriptionId = Math.random()
314
- const subscriber = new MessageProcessor(callback)
315
274
 
316
- this.subscribers.set(subscriptionId, [subscriber, onError])
275
+ this.#subscribers.set(subscriptionId, [callback, onError])
317
276
 
318
277
  return () => {
319
- this.subscribers.delete(subscriptionId)
278
+ this.#subscribers.delete(subscriptionId)
320
279
  }
321
280
  }
322
281
 
323
282
  unsubscribeAll(): void {
324
- this.subscribers.clear()
325
- }
326
-
327
- private publish(messages: Message<T>[]) {
328
- this.subscribers.forEach(([subscriber, _]) => {
329
- subscriber.process(messages)
330
- })
331
- }
332
-
333
- private sendErrorToSubscribers(error: Error) {
334
- this.subscribers.forEach(([_, errorFn]) => {
335
- errorFn?.(error)
336
- })
283
+ this.#subscribers.clear()
337
284
  }
338
285
 
339
286
  subscribeOnceToUpToDate(
340
- callback: () => void | Promise<void>,
287
+ callback: () => MaybePromise<void>,
341
288
  error: (err: FetchError | Error) => void
342
289
  ) {
343
290
  const subscriptionId = Math.random()
344
291
 
345
- this.upToDateSubscribers.set(subscriptionId, [callback, error])
292
+ this.#upToDateSubscribers.set(subscriptionId, [callback, error])
346
293
 
347
294
  return () => {
348
- this.upToDateSubscribers.delete(subscriptionId)
295
+ this.#upToDateSubscribers.delete(subscriptionId)
349
296
  }
350
297
  }
351
298
 
352
299
  unsubscribeAllUpToDateSubscribers(): void {
353
- this.upToDateSubscribers.clear()
300
+ this.#upToDateSubscribers.clear()
301
+ }
302
+
303
+ /** Unix time at which we last synced. Undefined when `isLoading` is true. */
304
+ lastSyncedAt(): number | undefined {
305
+ return this.#lastSyncedAt
354
306
  }
355
307
 
356
308
  /** Time elapsed since last sync (in ms). Infinity if we did not yet sync. */
357
309
  lastSynced(): number {
358
- if (this.lastSyncedAt === undefined) return Infinity
359
- return Date.now() - this.lastSyncedAt
310
+ if (this.#lastSyncedAt === undefined) return Infinity
311
+ return Date.now() - this.#lastSyncedAt
360
312
  }
361
313
 
314
+ /** Indicates if we are connected to the Electric sync service. */
362
315
  isConnected(): boolean {
363
- return this.connected
316
+ return this.#connected
364
317
  }
365
318
 
366
319
  /** True during initial fetch. False afterwise. */
@@ -368,15 +321,34 @@ export class ShapeStream<T extends Row = Row> {
368
321
  return !this.isUpToDate
369
322
  }
370
323
 
371
- private notifyUpToDateSubscribers() {
372
- this.upToDateSubscribers.forEach(([callback]) => {
324
+ async #publish(messages: Message<T>[]): Promise<void> {
325
+ await Promise.all(
326
+ Array.from(this.#subscribers.values()).map(async ([callback, __]) => {
327
+ try {
328
+ await callback(messages)
329
+ } catch (err) {
330
+ queueMicrotask(() => {
331
+ throw err
332
+ })
333
+ }
334
+ })
335
+ )
336
+ }
337
+
338
+ #sendErrorToSubscribers(error: Error) {
339
+ this.#subscribers.forEach(([_, errorFn]) => {
340
+ errorFn?.(error)
341
+ })
342
+ }
343
+
344
+ #notifyUpToDateSubscribers() {
345
+ this.#upToDateSubscribers.forEach(([callback]) => {
373
346
  callback()
374
347
  })
375
348
  }
376
349
 
377
- private sendErrorToUpToDateSubscribers(error: FetchError | Error) {
378
- // eslint-disable-next-line
379
- this.upToDateSubscribers.forEach(([_, errorCallback]) =>
350
+ #sendErrorToUpToDateSubscribers(error: FetchError | Error) {
351
+ this.#upToDateSubscribers.forEach(([_, errorCallback]) =>
380
352
  errorCallback(error)
381
353
  )
382
354
  }
@@ -385,248 +357,33 @@ export class ShapeStream<T extends Row = Row> {
385
357
  * Resets the state of the stream, optionally with a provided
386
358
  * shape ID
387
359
  */
388
- private reset(shapeId?: string) {
389
- this.lastOffset = `-1`
390
- this.shapeId = shapeId
391
- this.isUpToDate = false
392
- this.connected = false
393
- this.schema = undefined
394
- }
395
-
396
- private validateOptions(options: ShapeStreamOptions): void {
397
- if (!options.url) {
398
- throw new Error(`Invalid shape option. It must provide the url`)
399
- }
400
- if (options.signal && !(options.signal instanceof AbortSignal)) {
401
- throw new Error(
402
- `Invalid signal option. It must be an instance of AbortSignal.`
403
- )
404
- }
405
-
406
- if (
407
- options.offset !== undefined &&
408
- options.offset !== `-1` &&
409
- !options.shapeId
410
- ) {
411
- throw new Error(
412
- `shapeId is required if this isn't an initial fetch (i.e. offset > -1)`
413
- )
414
- }
415
- }
416
-
417
- private async fetchWithBackoff(url: URL) {
418
- const { initialDelay, maxDelay, multiplier } = this.backoffOptions
419
- const signal = this.options.signal
420
-
421
- let delay = initialDelay
422
- let attempt = 0
423
-
424
- // eslint-disable-next-line no-constant-condition -- we're retrying with a lag until we get a non-500 response or the abort signal is triggered
425
- while (true) {
426
- try {
427
- const result = await this.fetchClient(url.toString(), { signal })
428
- if (result.ok) {
429
- if (this.options.subscribe) {
430
- this.connected = true
431
- }
432
- return result
433
- } else throw await FetchError.fromResponse(result, url.toString())
434
- } catch (e) {
435
- this.connected = false
436
- if (signal?.aborted) {
437
- return undefined
438
- } else if (
439
- e instanceof FetchError &&
440
- e.status >= 400 &&
441
- e.status < 500
442
- ) {
443
- // Any client errors cannot be backed off on, leave it to the caller to handle.
444
- throw e
445
- } else {
446
- // Exponentially backoff on errors.
447
- // Wait for the current delay duration
448
- await new Promise((resolve) => setTimeout(resolve, delay))
449
-
450
- // Increase the delay for the next attempt
451
- delay = Math.min(delay * multiplier, maxDelay)
452
-
453
- attempt++
454
- console.log(`Retry attempt #${attempt} after ${delay}ms`)
455
- }
456
- }
457
- }
360
+ #reset(shapeId?: string) {
361
+ this.#lastOffset = `-1`
362
+ this.#shapeId = shapeId
363
+ this.#isUpToDate = false
364
+ this.#connected = false
365
+ this.#schema = undefined
458
366
  }
459
367
  }
460
368
 
461
- /**
462
- * A Shape is an object that subscribes to a shape log,
463
- * keeps a materialised shape `.value` in memory and
464
- * notifies subscribers when the value has changed.
465
- *
466
- * It can be used without a framework and as a primitive
467
- * to simplify developing framework hooks.
468
- *
469
- * @constructor
470
- * @param {ShapeStream<T extends Row>} - the underlying shape stream
471
- * @example
472
- * ```
473
- * const shapeStream = new ShapeStream<{ foo: number }>(url: 'http://localhost:3000/v1/shape/foo'})
474
- * const shape = new Shape(shapeStream)
475
- * ```
476
- *
477
- * `value` returns a promise that resolves the Shape data once the Shape has been
478
- * fully loaded (and when resuming from being offline):
479
- *
480
- * const value = await shape.value
481
- *
482
- * `valueSync` returns the current data synchronously:
483
- *
484
- * const value = shape.valueSync
485
- *
486
- * Subscribe to updates. Called whenever the shape updates in Postgres.
487
- *
488
- * shape.subscribe(shapeData => {
489
- * console.log(shapeData)
490
- * })
491
- */
492
- export class Shape<T extends Row = Row> {
493
- private stream: ShapeStream<T>
494
-
495
- private data: ShapeData<T> = new Map()
496
- private subscribers = new Map<number, ShapeChangedCallback<T>>()
497
- public error: FetchError | false = false
498
- private hasNotifiedSubscribersUpToDate: boolean = false
499
-
500
- constructor(stream: ShapeStream<T>) {
501
- this.stream = stream
502
- this.stream.subscribe(this.process.bind(this), this.handleError.bind(this))
503
- const unsubscribe = this.stream.subscribeOnceToUpToDate(
504
- () => {
505
- unsubscribe()
506
- },
507
- (e) => {
508
- this.handleError(e)
509
- throw e
510
- }
511
- )
512
- }
513
-
514
- lastSynced(): number {
515
- return this.stream.lastSynced()
516
- }
517
-
518
- isConnected(): boolean {
519
- return this.stream.isConnected()
520
- }
521
-
522
- /** True during initial fetch. False afterwise. */
523
- isLoading(): boolean {
524
- return this.stream.isLoading()
525
- }
526
-
527
- get value(): Promise<ShapeData<T>> {
528
- return new Promise((resolve) => {
529
- if (this.stream.isUpToDate) {
530
- resolve(this.valueSync)
531
- } else {
532
- const unsubscribe = this.stream.subscribeOnceToUpToDate(
533
- () => {
534
- unsubscribe()
535
- resolve(this.valueSync)
536
- },
537
- (e) => {
538
- throw e
539
- }
540
- )
541
- }
542
- })
543
- }
544
-
545
- get valueSync() {
546
- return this.data
547
- }
548
-
549
- subscribe(callback: ShapeChangedCallback<T>): () => void {
550
- const subscriptionId = Math.random()
551
-
552
- this.subscribers.set(subscriptionId, callback)
553
-
554
- return () => {
555
- this.subscribers.delete(subscriptionId)
556
- }
369
+ function validateOptions(options: Partial<ShapeStreamOptions>): void {
370
+ if (!options.url) {
371
+ throw new Error(`Invalid shape option. It must provide the url`)
557
372
  }
558
-
559
- unsubscribeAll(): void {
560
- this.subscribers.clear()
561
- }
562
-
563
- get numSubscribers() {
564
- return this.subscribers.size
565
- }
566
-
567
- private process(messages: Message<T>[]): void {
568
- let dataMayHaveChanged = false
569
- let isUpToDate = false
570
- let newlyUpToDate = false
571
-
572
- messages.forEach((message) => {
573
- if (isChangeMessage(message)) {
574
- dataMayHaveChanged = [`insert`, `update`, `delete`].includes(
575
- message.headers.operation
576
- )
577
-
578
- switch (message.headers.operation) {
579
- case `insert`:
580
- this.data.set(message.key, message.value)
581
- break
582
- case `update`:
583
- this.data.set(message.key, {
584
- ...this.data.get(message.key)!,
585
- ...message.value,
586
- })
587
- break
588
- case `delete`:
589
- this.data.delete(message.key)
590
- break
591
- }
592
- }
593
-
594
- if (isControlMessage(message)) {
595
- switch (message.headers.control) {
596
- case `up-to-date`:
597
- isUpToDate = true
598
- if (!this.hasNotifiedSubscribersUpToDate) {
599
- newlyUpToDate = true
600
- }
601
- break
602
- case `must-refetch`:
603
- this.data.clear()
604
- this.error = false
605
- isUpToDate = false
606
- newlyUpToDate = false
607
- break
608
- }
609
- }
610
- })
611
-
612
- // Always notify subscribers when the Shape first is up to date.
613
- // FIXME this would be cleaner with a simple state machine.
614
- if (newlyUpToDate || (isUpToDate && dataMayHaveChanged)) {
615
- this.hasNotifiedSubscribersUpToDate = true
616
- this.notify()
617
- }
618
- }
619
-
620
- private handleError(e: Error): void {
621
- if (e instanceof FetchError) {
622
- this.error = e
623
- this.notify()
624
- }
373
+ if (options.signal && !(options.signal instanceof AbortSignal)) {
374
+ throw new Error(
375
+ `Invalid signal option. It must be an instance of AbortSignal.`
376
+ )
625
377
  }
626
378
 
627
- private notify(): void {
628
- this.subscribers.forEach((callback) => {
629
- callback(this.valueSync)
630
- })
379
+ if (
380
+ options.offset !== undefined &&
381
+ options.offset !== `-1` &&
382
+ !options.shapeId
383
+ ) {
384
+ throw new Error(
385
+ `shapeId is required if this isn't an initial fetch (i.e. offset > -1)`
386
+ )
631
387
  }
388
+ return
632
389
  }