@electric-sql/client 1.0.3 → 1.0.4

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
@@ -39,6 +39,7 @@ import {
39
39
  TABLE_QUERY_PARAM,
40
40
  REPLICA_PARAM,
41
41
  FORCE_DISCONNECT_AND_REFRESH,
42
+ PAUSE_STREAM,
42
43
  } from './constants'
43
44
 
44
45
  const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
@@ -334,6 +335,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
334
335
  >()
335
336
 
336
337
  #started = false
338
+ #state = `active` as `active` | `pause-requested` | `paused`
337
339
  #lastOffset: Offset
338
340
  #liveCacheBuster: string // Seconds since our Electric Epoch 😎
339
341
  #lastSyncedAt?: number // unix time
@@ -374,6 +376,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
374
376
  createFetchWithChunkBuffer(fetchWithBackoffClient)
375
377
  )
376
378
  )
379
+
380
+ this.#subscribeToVisibilityChanges()
377
381
  }
378
382
 
379
383
  get shapeHandle() {
@@ -392,189 +396,11 @@ export class ShapeStream<T extends Row<unknown> = Row>
392
396
  return this.#lastOffset
393
397
  }
394
398
 
395
- async #start() {
396
- if (this.#started) throw new Error(`Cannot start stream twice`)
399
+ async #start(): Promise<void> {
397
400
  this.#started = true
398
401
 
399
402
  try {
400
- while (
401
- (!this.options.signal?.aborted && !this.#isUpToDate) ||
402
- this.options.subscribe
403
- ) {
404
- const { url, signal } = this.options
405
-
406
- // Resolve headers and params in parallel
407
- const [requestHeaders, params] = await Promise.all([
408
- resolveHeaders(this.options.headers),
409
- this.options.params
410
- ? toInternalParams(convertWhereParamsToObj(this.options.params))
411
- : undefined,
412
- ])
413
-
414
- // Validate params after resolution
415
- if (params) {
416
- validateParams(params)
417
- }
418
-
419
- const fetchUrl = new URL(url)
420
-
421
- // Add PostgreSQL-specific parameters
422
- if (params) {
423
- if (params.table)
424
- setQueryParam(fetchUrl, TABLE_QUERY_PARAM, params.table)
425
- if (params.where)
426
- setQueryParam(fetchUrl, WHERE_QUERY_PARAM, params.where)
427
- if (params.columns)
428
- setQueryParam(fetchUrl, COLUMNS_QUERY_PARAM, params.columns)
429
- if (params.replica)
430
- setQueryParam(fetchUrl, REPLICA_PARAM, params.replica)
431
- if (params.params)
432
- setQueryParam(fetchUrl, WHERE_PARAMS_PARAM, params.params)
433
-
434
- // Add any remaining custom parameters
435
- const customParams = { ...params }
436
- delete customParams.table
437
- delete customParams.where
438
- delete customParams.columns
439
- delete customParams.replica
440
- delete customParams.params
441
-
442
- for (const [key, value] of Object.entries(customParams)) {
443
- setQueryParam(fetchUrl, key, value)
444
- }
445
- }
446
-
447
- // Add Electric's internal parameters
448
- fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, this.#lastOffset)
449
-
450
- if (this.#isUpToDate) {
451
- if (!this.#isRefreshing) {
452
- fetchUrl.searchParams.set(LIVE_QUERY_PARAM, `true`)
453
- }
454
- fetchUrl.searchParams.set(
455
- LIVE_CACHE_BUSTER_QUERY_PARAM,
456
- this.#liveCacheBuster
457
- )
458
- }
459
-
460
- if (this.#shapeHandle) {
461
- // This should probably be a header for better cache breaking?
462
- fetchUrl.searchParams.set(
463
- SHAPE_HANDLE_QUERY_PARAM,
464
- this.#shapeHandle!
465
- )
466
- }
467
-
468
- // sort query params in-place for stable URLs and improved cache hits
469
- fetchUrl.searchParams.sort()
470
-
471
- // Create a new AbortController for this request
472
- this.#requestAbortController = new AbortController()
473
-
474
- // If user provided a signal, listen to it and pass on the reason for the abort
475
- let abortListener: (() => void) | undefined
476
- if (signal) {
477
- abortListener = () => {
478
- this.#requestAbortController?.abort(signal.reason)
479
- }
480
- signal.addEventListener(`abort`, abortListener, { once: true })
481
- if (signal.aborted) {
482
- // If the signal is already aborted, abort the request immediately
483
- this.#requestAbortController?.abort(signal.reason)
484
- }
485
- }
486
-
487
- let response!: Response
488
- try {
489
- response = await this.#fetchClient(fetchUrl.toString(), {
490
- signal: this.#requestAbortController.signal,
491
- headers: requestHeaders,
492
- })
493
- this.#connected = true
494
- } catch (e) {
495
- // Handle abort error triggered by refresh
496
- if (
497
- (e instanceof FetchError || e instanceof FetchBackoffAbortError) &&
498
- this.#requestAbortController.signal.aborted &&
499
- this.#requestAbortController.signal.reason ===
500
- FORCE_DISCONNECT_AND_REFRESH
501
- ) {
502
- // Loop back to the top of the while loop to start a new request
503
- continue
504
- }
505
-
506
- if (e instanceof FetchBackoffAbortError) break // interrupted
507
- if (!(e instanceof FetchError)) throw e // should never happen
508
-
509
- if (e.status == 409) {
510
- // Upon receiving a 409, we should start from scratch
511
- // with the newly provided shape handle
512
- const newShapeHandle = e.headers[SHAPE_HANDLE_HEADER]
513
- this.#reset(newShapeHandle)
514
- await this.#publish(e.json as Message<T>[])
515
- continue
516
- } else {
517
- // Notify subscribers
518
- this.#sendErrorToSubscribers(e)
519
-
520
- // errors that have reached this point are not actionable without
521
- // additional user input, such as 400s or failures to read the
522
- // body of a response, so we exit the loop
523
- throw e
524
- }
525
- } finally {
526
- if (abortListener && signal) {
527
- signal.removeEventListener(`abort`, abortListener)
528
- }
529
- this.#requestAbortController = undefined
530
- }
531
-
532
- const { headers, status } = response
533
- const shapeHandle = headers.get(SHAPE_HANDLE_HEADER)
534
- if (shapeHandle) {
535
- this.#shapeHandle = shapeHandle
536
- }
537
-
538
- const lastOffset = headers.get(CHUNK_LAST_OFFSET_HEADER)
539
- if (lastOffset) {
540
- this.#lastOffset = lastOffset as Offset
541
- }
542
-
543
- const liveCacheBuster = headers.get(LIVE_CACHE_BUSTER_HEADER)
544
- if (liveCacheBuster) {
545
- this.#liveCacheBuster = liveCacheBuster
546
- }
547
-
548
- const getSchema = (): Schema => {
549
- const schemaHeader = headers.get(SHAPE_SCHEMA_HEADER)
550
- return schemaHeader ? JSON.parse(schemaHeader) : {}
551
- }
552
- this.#schema = this.#schema ?? getSchema()
553
-
554
- // NOTE: 204s are deprecated, the Electric server should not
555
- // send these in latest versions but this is here for backwards
556
- // compatibility
557
- if (status === 204) {
558
- // There's no content so we are live and up to date
559
- this.#lastSyncedAt = Date.now()
560
- }
561
-
562
- const messages = (await response.text()) || `[]`
563
- const batch = this.#messageParser.parse(messages, this.#schema)
564
-
565
- // Update isUpToDate
566
- if (batch.length > 0) {
567
- const lastMessage = batch[batch.length - 1]
568
- if (isUpToDateMessage(lastMessage)) {
569
- this.#lastSyncedAt = Date.now()
570
- this.#isUpToDate = true
571
- }
572
-
573
- await this.#publish(batch)
574
- }
575
-
576
- this.#tickPromiseResolver?.()
577
- }
403
+ await this.#requestShape()
578
404
  } catch (err) {
579
405
  this.#error = err
580
406
  if (this.#onError) {
@@ -605,6 +431,216 @@ export class ShapeStream<T extends Row<unknown> = Row>
605
431
  }
606
432
  }
607
433
 
434
+ async #requestShape(): Promise<void> {
435
+ if (this.#state === `pause-requested`) {
436
+ this.#state = `paused`
437
+ return
438
+ }
439
+
440
+ if (
441
+ !this.options.subscribe &&
442
+ (this.options.signal?.aborted || this.#isUpToDate)
443
+ ) {
444
+ return
445
+ }
446
+
447
+ const resumingFromPause = this.#state === `paused`
448
+ this.#state = `active`
449
+
450
+ const { url, signal } = this.options
451
+
452
+ // Resolve headers and params in parallel
453
+ const [requestHeaders, params] = await Promise.all([
454
+ resolveHeaders(this.options.headers),
455
+ this.options.params
456
+ ? toInternalParams(convertWhereParamsToObj(this.options.params))
457
+ : undefined,
458
+ ])
459
+
460
+ // Validate params after resolution
461
+ if (params) {
462
+ validateParams(params)
463
+ }
464
+
465
+ const fetchUrl = new URL(url)
466
+
467
+ // Add PostgreSQL-specific parameters
468
+ if (params) {
469
+ if (params.table) setQueryParam(fetchUrl, TABLE_QUERY_PARAM, params.table)
470
+ if (params.where) setQueryParam(fetchUrl, WHERE_QUERY_PARAM, params.where)
471
+ if (params.columns)
472
+ setQueryParam(fetchUrl, COLUMNS_QUERY_PARAM, params.columns)
473
+ if (params.replica) setQueryParam(fetchUrl, REPLICA_PARAM, params.replica)
474
+ if (params.params)
475
+ setQueryParam(fetchUrl, WHERE_PARAMS_PARAM, params.params)
476
+
477
+ // Add any remaining custom parameters
478
+ const customParams = { ...params }
479
+ delete customParams.table
480
+ delete customParams.where
481
+ delete customParams.columns
482
+ delete customParams.replica
483
+ delete customParams.params
484
+
485
+ for (const [key, value] of Object.entries(customParams)) {
486
+ setQueryParam(fetchUrl, key, value)
487
+ }
488
+ }
489
+
490
+ // Add Electric's internal parameters
491
+ fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, this.#lastOffset)
492
+
493
+ if (this.#isUpToDate) {
494
+ // If we are resuming from a paused state, we don't want to perform a live request
495
+ // because it could be a long poll that holds for 20sec
496
+ // and during all that time `isConnected` will be false
497
+ if (!this.#isRefreshing && !resumingFromPause) {
498
+ fetchUrl.searchParams.set(LIVE_QUERY_PARAM, `true`)
499
+ }
500
+ fetchUrl.searchParams.set(
501
+ LIVE_CACHE_BUSTER_QUERY_PARAM,
502
+ this.#liveCacheBuster
503
+ )
504
+ }
505
+
506
+ if (this.#shapeHandle) {
507
+ // This should probably be a header for better cache breaking?
508
+ fetchUrl.searchParams.set(SHAPE_HANDLE_QUERY_PARAM, this.#shapeHandle!)
509
+ }
510
+
511
+ // sort query params in-place for stable URLs and improved cache hits
512
+ fetchUrl.searchParams.sort()
513
+
514
+ // Create a new AbortController for this request
515
+ this.#requestAbortController = new AbortController()
516
+
517
+ // If user provided a signal, listen to it and pass on the reason for the abort
518
+ let abortListener: (() => void) | undefined
519
+ if (signal) {
520
+ abortListener = () => {
521
+ this.#requestAbortController?.abort(signal.reason)
522
+ }
523
+ signal.addEventListener(`abort`, abortListener, { once: true })
524
+ if (signal.aborted) {
525
+ // If the signal is already aborted, abort the request immediately
526
+ this.#requestAbortController?.abort(signal.reason)
527
+ }
528
+ }
529
+
530
+ let response!: Response
531
+ try {
532
+ response = await this.#fetchClient(fetchUrl.toString(), {
533
+ signal: this.#requestAbortController.signal,
534
+ headers: requestHeaders,
535
+ })
536
+ this.#connected = true
537
+ } catch (e) {
538
+ // Handle abort error triggered by refresh
539
+ if (
540
+ (e instanceof FetchError || e instanceof FetchBackoffAbortError) &&
541
+ this.#requestAbortController.signal.aborted &&
542
+ this.#requestAbortController.signal.reason ===
543
+ FORCE_DISCONNECT_AND_REFRESH
544
+ ) {
545
+ // Loop back to the top of the while loop to start a new request
546
+ return this.#requestShape()
547
+ }
548
+
549
+ if (e instanceof FetchBackoffAbortError) {
550
+ if (
551
+ this.#requestAbortController.signal.aborted &&
552
+ this.#requestAbortController.signal.reason === PAUSE_STREAM
553
+ ) {
554
+ this.#state = `paused`
555
+ }
556
+ return // interrupted
557
+ }
558
+ if (!(e instanceof FetchError)) throw e // should never happen
559
+
560
+ if (e.status == 409) {
561
+ // Upon receiving a 409, we should start from scratch
562
+ // with the newly provided shape handle
563
+ const newShapeHandle = e.headers[SHAPE_HANDLE_HEADER]
564
+ this.#reset(newShapeHandle)
565
+ await this.#publish(e.json as Message<T>[])
566
+ return this.#requestShape()
567
+ } else {
568
+ // Notify subscribers
569
+ this.#sendErrorToSubscribers(e)
570
+
571
+ // errors that have reached this point are not actionable without
572
+ // additional user input, such as 400s or failures to read the
573
+ // body of a response, so we exit the loop
574
+ throw e
575
+ }
576
+ } finally {
577
+ if (abortListener && signal) {
578
+ signal.removeEventListener(`abort`, abortListener)
579
+ }
580
+ this.#requestAbortController = undefined
581
+ }
582
+
583
+ const { headers, status } = response
584
+ const shapeHandle = headers.get(SHAPE_HANDLE_HEADER)
585
+ if (shapeHandle) {
586
+ this.#shapeHandle = shapeHandle
587
+ }
588
+
589
+ const lastOffset = headers.get(CHUNK_LAST_OFFSET_HEADER)
590
+ if (lastOffset) {
591
+ this.#lastOffset = lastOffset as Offset
592
+ }
593
+
594
+ const liveCacheBuster = headers.get(LIVE_CACHE_BUSTER_HEADER)
595
+ if (liveCacheBuster) {
596
+ this.#liveCacheBuster = liveCacheBuster
597
+ }
598
+
599
+ const getSchema = (): Schema => {
600
+ const schemaHeader = headers.get(SHAPE_SCHEMA_HEADER)
601
+ return schemaHeader ? JSON.parse(schemaHeader) : {}
602
+ }
603
+ this.#schema = this.#schema ?? getSchema()
604
+
605
+ // NOTE: 204s are deprecated, the Electric server should not
606
+ // send these in latest versions but this is here for backwards
607
+ // compatibility
608
+ if (status === 204) {
609
+ // There's no content so we are live and up to date
610
+ this.#lastSyncedAt = Date.now()
611
+ }
612
+
613
+ const messages = (await response.text()) || `[]`
614
+ const batch = this.#messageParser.parse(messages, this.#schema)
615
+
616
+ // Update isUpToDate
617
+ if (batch.length > 0) {
618
+ const lastMessage = batch[batch.length - 1]
619
+ if (isUpToDateMessage(lastMessage)) {
620
+ this.#lastSyncedAt = Date.now()
621
+ this.#isUpToDate = true
622
+ }
623
+
624
+ await this.#publish(batch)
625
+ }
626
+
627
+ this.#tickPromiseResolver?.()
628
+ return this.#requestShape()
629
+ }
630
+
631
+ #pause() {
632
+ if (this.#started && this.#state === `active`) {
633
+ this.#state = `pause-requested`
634
+ this.#requestAbortController?.abort(PAUSE_STREAM)
635
+ }
636
+ }
637
+
638
+ #resume() {
639
+ if (this.#started && this.#state === `paused`) {
640
+ this.#start()
641
+ }
642
+ }
643
+
608
644
  subscribe(
609
645
  callback: (messages: Message<T>[]) => MaybePromise<void>,
610
646
  onError: (error: Error) => void = () => {}
@@ -648,6 +684,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
648
684
  return this.#started
649
685
  }
650
686
 
687
+ isPaused(): boolean {
688
+ return this.#state === `paused`
689
+ }
690
+
651
691
  /** Await the next tick of the request loop */
652
692
  async #nextTick() {
653
693
  if (this.#tickPromise) {
@@ -702,6 +742,24 @@ export class ShapeStream<T extends Row<unknown> = Row>
702
742
  })
703
743
  }
704
744
 
745
+ #subscribeToVisibilityChanges() {
746
+ if (
747
+ typeof document === `object` &&
748
+ typeof document.hidden === `boolean` &&
749
+ typeof document.addEventListener === `function`
750
+ ) {
751
+ const visibilityHandler = () => {
752
+ if (document.hidden) {
753
+ this.#pause()
754
+ } else {
755
+ this.#resume()
756
+ }
757
+ }
758
+
759
+ document.addEventListener(`visibilitychange`, visibilityHandler)
760
+ }
761
+ }
762
+
705
763
  /**
706
764
  * Resets the state of the stream, optionally with a provided
707
765
  * shape handle
package/src/constants.ts CHANGED
@@ -13,3 +13,4 @@ export const WHERE_QUERY_PARAM = `where`
13
13
  export const REPLICA_PARAM = `replica`
14
14
  export const WHERE_PARAMS_PARAM = `params`
15
15
  export const FORCE_DISCONNECT_AND_REFRESH = `force-disconnect-and-refresh`
16
+ export const PAUSE_STREAM = `pause-stream`