@electric-sql/client 0.4.1 → 0.5.1

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