@tanstack/electric-db-collection 0.4.3 → 0.4.5

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/electric.ts CHANGED
@@ -47,6 +47,7 @@ import type {
47
47
  DeleteMutationFnParams,
48
48
  InsertMutationFnParams,
49
49
  LoadSubsetOptions,
50
+ SyncAppliedReceipt,
50
51
  SyncConfig,
51
52
  SyncMode,
52
53
  UpdateMutationFnParams,
@@ -526,6 +527,8 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
526
527
  begin,
527
528
  write,
528
529
  commit,
530
+ getCommitCursor,
531
+ waitForCommitsAfter,
529
532
  collectionId,
530
533
  encodeColumnName,
531
534
  signal,
@@ -539,7 +542,9 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
539
542
  value: T
540
543
  metadata: Record<string, unknown>
541
544
  }) => void
542
- commit: () => void
545
+ commit: (signal?: AbortSignal) => SyncAppliedReceipt
546
+ getCommitCursor: () => number
547
+ waitForCommitsAfter: (cursor: number) => Promise<void>
543
548
  collectionId?: string
544
549
  /**
545
550
  * Optional function to encode column names (e.g., camelCase to snake_case).
@@ -573,12 +578,14 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
573
578
  }
574
579
 
575
580
  const loadSubset = async (opts: LoadSubsetOptions) => {
581
+ const commitCursor = getCommitCursor()
582
+ if (opts.signal?.aborted) return
583
+
576
584
  if (isBufferingInitialSync()) {
577
585
  const snapshotParams = compileSQL<T>(opts, compileOptions)
578
586
  try {
579
587
  const { data: rows } = await stream.fetchSnapshot(snapshotParams)
580
-
581
- if (!isBufferingInitialSync()) {
588
+ if (opts.signal?.aborted || !isBufferingInitialSync()) {
582
589
  debug(`${logPrefix}Ignoring snapshot - sync completed while fetching`)
583
590
  return
584
591
  }
@@ -592,10 +599,11 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
592
599
  metadata: { ...row.headers },
593
600
  })
594
601
  }
595
- commit()
602
+ await commit(opts.signal)
596
603
  debug(`${logPrefix}Applied snapshot with ${rows.length} rows`)
597
604
  }
598
605
  } catch (error) {
606
+ if (opts.signal?.aborted) return
599
607
  if (handleSnapshotError(error, `fetchSnapshot`)) {
600
608
  return
601
609
  }
@@ -643,6 +651,14 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
643
651
  }
644
652
  }
645
653
 
654
+ if (opts.signal?.aborted) return
655
+
656
+ // Upstream limitation: ShapeStream.requestSnapshot() publishes its rows
657
+ // through the stream callback before its Promise resolves. It accepts no
658
+ // request signal and exposes no request identity on those messages, so an
659
+ // aborted request can already have installed rows before the check below.
660
+ // Full request-scoped cancellation requires support in the Electric client;
661
+ // matching snapshots by parameters is unsafe for overlapping equal requests.
646
662
  try {
647
663
  if (cursor) {
648
664
  const whereCurrentOpts: LoadSubsetOptions = {
@@ -675,11 +691,13 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
675
691
  await stream.requestSnapshot(snapshotParams)
676
692
  }
677
693
  } catch (error) {
694
+ if (opts.signal?.aborted) return
678
695
  if (handleSnapshotError(error, `requestSnapshot`)) {
679
696
  return
680
697
  }
681
698
  throw error
682
699
  }
700
+ await waitForCommitsAfter(commitCursor)
683
701
  }
684
702
 
685
703
  return new DeduplicatedLoadSubset({ loadSubset })
@@ -1476,13 +1494,33 @@ function createElectricSync<T extends Row<unknown>>(
1476
1494
  const {
1477
1495
  begin,
1478
1496
  write,
1479
- commit,
1497
+ commit: commitSyncTransaction,
1480
1498
  markReady,
1481
1499
  markError,
1482
1500
  truncate,
1483
1501
  collection,
1484
1502
  metadata,
1485
1503
  } = params
1504
+ let commitSequence = 0
1505
+ const pendingAppliedReceipts = new Map<number, Promise<void>>()
1506
+ const commit = (signal?: AbortSignal): SyncAppliedReceipt => {
1507
+ const sequence = ++commitSequence
1508
+ const applied = commitSyncTransaction(signal)
1509
+ if (applied === true) {
1510
+ return true
1511
+ }
1512
+ pendingAppliedReceipts.set(sequence, applied)
1513
+ const removeReceipt = () => pendingAppliedReceipts.delete(sequence)
1514
+ void applied.then(removeReceipt, removeReceipt)
1515
+ return applied
1516
+ }
1517
+ const waitForCommitsAfter = async (cursor: number): Promise<void> => {
1518
+ await Promise.all(
1519
+ Array.from(pendingAppliedReceipts, ([sequence, applied]) =>
1520
+ sequence > cursor ? applied : undefined,
1521
+ ),
1522
+ )
1523
+ }
1486
1524
  const readPersistedResumeState = (): ElectricResumeState | undefined => {
1487
1525
  const persistedResumeState = metadata?.collection.get(`electric:resume`)
1488
1526
  return parseElectricResumeState(persistedResumeState)
@@ -1507,7 +1545,13 @@ function createElectricSync<T extends Row<unknown>>(
1507
1545
 
1508
1546
  // Wrap markReady to wait for test hook in progressive mode
1509
1547
  let progressiveReadyGate: Promise<void> | null = null
1510
- const wrappedMarkReady = (isBuffering: boolean) => {
1548
+ let streamErrorVersion = 0
1549
+ const wrappedMarkReady = (
1550
+ isBuffering: boolean,
1551
+ expectedErrorVersion = streamErrorVersion,
1552
+ ) => {
1553
+ if (streamErrorVersion !== expectedErrorVersion) return
1554
+
1511
1555
  // Only create gate if we're in buffering phase (first up-to-date)
1512
1556
  if (
1513
1557
  isBuffering &&
@@ -1517,7 +1561,9 @@ function createElectricSync<T extends Row<unknown>>(
1517
1561
  // Create a new gate promise for this sync cycle
1518
1562
  progressiveReadyGate = testHooks.beforeMarkingReady()
1519
1563
  progressiveReadyGate.then(() => {
1520
- markReady()
1564
+ if (streamErrorVersion === expectedErrorVersion) {
1565
+ markReady()
1566
+ }
1521
1567
  })
1522
1568
  } else {
1523
1569
  // No hook, not buffering, or already past first up-to-date
@@ -1572,6 +1618,7 @@ function createElectricSync<T extends Row<unknown>>(
1572
1618
  (canUsePersistedResume ? persistedResumeState.handle : undefined),
1573
1619
  signal: abortController.signal,
1574
1620
  onError: (errorParams) => {
1621
+ streamErrorVersion++
1575
1622
  // Note that Electric sends a 409 error on a `must-refetch` message, but the
1576
1623
  // ShapeStream handled this and it will not reach this handler, therefor
1577
1624
  // this handler will not run for a `must-refetch`.
@@ -1719,6 +1766,8 @@ function createElectricSync<T extends Row<unknown>>(
1719
1766
  begin,
1720
1767
  write,
1721
1768
  commit,
1769
+ getCommitCursor: () => commitSequence,
1770
+ waitForCommitsAfter,
1722
1771
  collectionId,
1723
1772
  // Pass the columnMapper's encode function to transform column names
1724
1773
  // (e.g., camelCase to snake_case) when compiling SQL for subset queries
@@ -1881,6 +1930,8 @@ function createElectricSync<T extends Row<unknown>>(
1881
1930
  }
1882
1931
 
1883
1932
  if (commitPoint !== null) {
1933
+ let applied: SyncAppliedReceipt = true
1934
+ const wasBufferingInitialSync = isBufferingInitialSync()
1884
1935
  // PROGRESSIVE MODE: Atomic swap on first up-to-date (not subset-end)
1885
1936
  // EXCEPTION: Skip atomic swap if a transaction is already started (e.g., from must-refetch).
1886
1937
  // In that case, do a normal commit to properly close the existing transaction.
@@ -1935,7 +1986,7 @@ function createElectricSync<T extends Row<unknown>>(
1935
1986
 
1936
1987
  // Commit the atomic swap
1937
1988
  stageResumeMetadata()
1938
- commit()
1989
+ applied = commit()
1939
1990
 
1940
1991
  // Exit buffering phase by marking that we've received up-to-date
1941
1992
  // isBufferingInitialSync() will now return false
@@ -1949,15 +2000,24 @@ function createElectricSync<T extends Row<unknown>>(
1949
2000
  // Both up-to-date and subset-end trigger a commit
1950
2001
  if (transactionStarted) {
1951
2002
  stageResumeMetadata()
1952
- commit()
2003
+ applied = commit()
1953
2004
  transactionStarted = false
1954
2005
  } else if (commitPoint === `up-to-date` && metadata) {
1955
2006
  begin()
1956
2007
  stageResumeMetadata()
1957
- commit()
2008
+ applied = commit()
1958
2009
  }
1959
2010
  }
1960
- wrappedMarkReady(isBufferingInitialSync())
2011
+ const readyErrorVersion = streamErrorVersion
2012
+ if (applied === true) {
2013
+ wrappedMarkReady(wasBufferingInitialSync, readyErrorVersion)
2014
+ } else {
2015
+ void applied.then(
2016
+ () =>
2017
+ wrappedMarkReady(wasBufferingInitialSync, readyErrorVersion),
2018
+ () => undefined,
2019
+ )
2020
+ }
1961
2021
 
1962
2022
  // Track that we've received the first up-to-date for progressive mode
1963
2023
  if (commitPoint === `up-to-date`) {