@tanstack/electric-db-collection 0.5.9 → 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/electric.ts CHANGED
@@ -8,6 +8,7 @@ import DebugModule from 'debug'
8
8
  import {
9
9
  DeduplicatedLoadSubset,
10
10
  LoadSubsetOperationAbortedError,
11
+ SyncTransactionAbortedError,
11
12
  and,
12
13
  validateSyncPersistenceCapability,
13
14
  warnOnce,
@@ -331,6 +332,8 @@ type InferSchemaOutput<T> = T extends StandardSchemaV1
331
332
  * - syncs all data for the collection in the background
332
333
  * - uses incremental snapshots during the initial sync to provide a fast path to the data required for queries
333
334
  * - collection will be marked as ready once the full sync is complete
335
+ * - a live query, Suspense, or `skipInitial` effect is ready once its own snapshot loads, before the full sync
336
+ * completes; changes to those rows arrive when the full sync completes
334
337
  */
335
338
  export type ElectricSyncMode = SyncMode | `progressive`
336
339
 
@@ -633,6 +636,25 @@ function hasTxids<T extends Row<unknown>>(
633
636
  return `txids` in message.headers && Array.isArray(message.headers.txids)
634
637
  }
635
638
 
639
+ const abortReason = (abortedSignal: AbortSignal): unknown =>
640
+ abortedSignal.reason ?? new LoadSubsetOperationAbortedError()
641
+
642
+ const waitForDemandOrAbort = <TResult>(
643
+ work: Promise<TResult>,
644
+ demandSignal?: AbortSignal,
645
+ ): Promise<TResult> => {
646
+ if (!demandSignal) return work
647
+ let removeAbortListener = () => {}
648
+ const aborted = new Promise<never>((_, reject) => {
649
+ const onAbort = () => reject(abortReason(demandSignal))
650
+ demandSignal.addEventListener(`abort`, onAbort, { once: true })
651
+ removeAbortListener = () =>
652
+ demandSignal.removeEventListener(`abort`, onAbort)
653
+ if (demandSignal.aborted) onAbort()
654
+ })
655
+ return Promise.race([work, aborted]).finally(removeAbortListener)
656
+ }
657
+
636
658
  /**
637
659
  * Creates a deduplicated loadSubset handler for progressive/on-demand modes
638
660
  * Returns null for eager mode, or a DeduplicatedLoadSubset instance for other modes.
@@ -648,6 +670,8 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
648
670
  stream,
649
671
  syncMode,
650
672
  isBufferingInitialSync,
673
+ waitForFullSnapshot,
674
+ waitForEstablishedSnapshot,
651
675
  begin,
652
676
  write,
653
677
  commit,
@@ -657,10 +681,14 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
657
681
  collectionId,
658
682
  encodeColumnName,
659
683
  signal,
684
+ isCleanupAbort,
685
+ beforeSnapshot,
660
686
  }: {
661
687
  stream: ShapeStream<T>
662
688
  syncMode: ElectricSyncMode
663
689
  isBufferingInitialSync: () => boolean
690
+ waitForFullSnapshot?: () => Promise<void>
691
+ waitForEstablishedSnapshot: () => Promise<void>
664
692
  begin: () => void
665
693
  write: (mutation: {
666
694
  type: `insert` | `update` | `delete`
@@ -682,6 +710,8 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
682
710
  * When aborted, errors from requestSnapshot are silently ignored.
683
711
  */
684
712
  signal: AbortSignal
713
+ isCleanupAbort: () => boolean
714
+ beforeSnapshot?: () => Promise<void>
685
715
  }): DeduplicatedLoadSubset | null {
686
716
  if (syncMode === `eager`) {
687
717
  return null
@@ -689,16 +719,16 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
689
719
 
690
720
  const compileOptions = encodeColumnName ? { encodeColumnName } : undefined
691
721
  const logPrefix = collectionId ? `[${collectionId}] ` : ``
692
-
693
- const abortReason = (abortedSignal: AbortSignal): unknown =>
694
- abortedSignal.reason ?? new LoadSubsetOperationAbortedError()
722
+ // ShapeStream advances one stream cursor for every snapshot response.
723
+ // Overlapping requests must not apply a later offset before an older one.
724
+ let snapshotTail: Promise<void> | undefined
695
725
 
696
726
  /**
697
727
  * Handles errors from snapshot operations. Returns true if the error was
698
728
  * handled (signal aborted during cleanup), false if it should be re-thrown.
699
729
  */
700
730
  function handleSnapshotError(error: unknown, operation: string): boolean {
701
- if (signal.aborted) {
731
+ if (signal.aborted && isCleanupAbort()) {
702
732
  debug(`${logPrefix}Ignoring ${operation} error during cleanup: %o`, error)
703
733
  return true
704
734
  }
@@ -712,7 +742,49 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
712
742
  if (signal.aborted) throw abortReason(signal)
713
743
  if (opts.signal?.aborted) throw abortReason(opts.signal)
714
744
  }
745
+ const stopAfterStreamAbort = () => {
746
+ if (!signal.aborted) return false
747
+ if (!isCleanupAbort()) throw abortReason(signal)
748
+ return true
749
+ }
750
+ const requestSnapshotInOrder = (
751
+ params: Parameters<typeof stream.requestSnapshot>[0],
752
+ ) => {
753
+ const invoke = () => {
754
+ throwIfAborted()
755
+ return stream.requestSnapshot(params)
756
+ }
757
+ const request = snapshotTail ? snapshotTail.then(invoke) : invoke()
758
+ snapshotTail = request.then(
759
+ () => undefined,
760
+ () => undefined,
761
+ )
762
+ // Caller cancellation does not advance the shared SDK cursor. The tail
763
+ // still waits for the physical request or its queued turn to finish.
764
+ return waitForDemandOrAbort(request, opts.signal)
765
+ }
715
766
  throwIfAborted()
767
+ if (beforeSnapshot) {
768
+ await beforeSnapshot()
769
+ throwIfAborted()
770
+ }
771
+
772
+ // A load that installs no rows itself settles when the sync that
773
+ // establishes them does, so success never precedes its rows.
774
+ const settleWith = async (establishing: Promise<void>) => {
775
+ const demandSignal = opts.signal
776
+ try {
777
+ await waitForDemandOrAbort(establishing, demandSignal)
778
+ } catch (error) {
779
+ if (signal.aborted && isCleanupAbort()) return
780
+ if (demandSignal?.aborted) throw abortReason(demandSignal)
781
+ throw error
782
+ }
783
+ if (signal.aborted && isCleanupAbort()) return
784
+ if (demandSignal?.aborted) throw abortReason(demandSignal)
785
+ }
786
+
787
+ if (waitForFullSnapshot) return settleWith(waitForFullSnapshot())
716
788
 
717
789
  if (isBufferingInitialSync()) {
718
790
  const snapshotParams = compileSQL<T>(opts, compileOptions)
@@ -720,7 +792,7 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
720
792
  const { data: rows } = await stream.fetchSnapshot(snapshotParams)
721
793
  if (!isBufferingInitialSync()) {
722
794
  debug(`${logPrefix}Ignoring snapshot - sync completed while fetching`)
723
- return
795
+ return settleWith(waitForEstablishedSnapshot())
724
796
  }
725
797
  // Check the signal before commit to discard a stale snapshot.
726
798
  throwIfAborted()
@@ -753,9 +825,11 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
753
825
  return
754
826
  }
755
827
 
756
- if (syncMode === `progressive`) {
757
- return
758
- }
828
+ // Outside its buffered initial sync, a progressive load installs no rows.
829
+ // A resumed stream or a completed full sync already holds them; a reset
830
+ // supplies them only when its snapshot completes.
831
+ if (syncMode === `progressive`)
832
+ return settleWith(waitForEstablishedSnapshot())
759
833
 
760
834
  const { cursor, where, orderBy, limit } = opts
761
835
 
@@ -790,22 +864,24 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
790
864
  `${logPrefix}Requesting cursor.whereFrom snapshot (with limit ${limit})`,
791
865
  )
792
866
 
793
- await Promise.all([
794
- stream.requestSnapshot(whereCurrentParams),
795
- stream.requestSnapshot(whereFromParams),
796
- ])
867
+ await requestSnapshotInOrder(whereCurrentParams)
868
+ await requestSnapshotInOrder(whereFromParams)
797
869
  } else {
798
870
  const snapshotParams = compileSQL<T>(opts, compileOptions)
799
- await stream.requestSnapshot(snapshotParams)
871
+ await requestSnapshotInOrder(snapshotParams)
800
872
  }
801
873
  } catch (error) {
802
- if (opts.signal?.aborted) return
874
+ if (opts.signal?.aborted) throw abortReason(opts.signal)
803
875
  if (handleSnapshotError(error, `requestSnapshot`)) {
804
876
  return
805
877
  }
806
878
  throw error
807
879
  }
808
- await waitForCommitsAfter(commitCursor)
880
+ if (opts.signal?.aborted) throw abortReason(opts.signal)
881
+ if (stopAfterStreamAbort()) return
882
+ await waitForDemandOrAbort(waitForCommitsAfter(commitCursor), opts.signal)
883
+ if (opts.signal?.aborted) throw abortReason(opts.signal)
884
+ if (stopAfterStreamAbort()) return
809
885
  }
810
886
 
811
887
  return new DeduplicatedLoadSubset({ loadSubset })
@@ -900,6 +976,12 @@ class ElectricLifecycle<T extends Row<unknown>> {
900
976
  this.evidence.hydratedResumeState = undefined
901
977
  }
902
978
 
979
+ forgetEvidenceAfterScopedRecovery(): void {
980
+ this.evidence.seenTxids.clear()
981
+ this.evidence.seenSnapshots.length = 0
982
+ this.evidence.hydratedResumeState = undefined
983
+ }
984
+
903
985
  exportMeta(): ElectricSyncMeta {
904
986
  return exportElectricSyncMeta(this.evidence)
905
987
  }
@@ -1647,896 +1729,1164 @@ function createElectricSync<T extends Row<unknown>>(
1647
1729
  ReturnType<typeof createTagState>
1648
1730
  >()
1649
1731
 
1650
- return {
1651
- getSyncMetadata: () => ({
1652
- relation: shapeOptions.params?.table
1653
- ? [relationSchema || `public`, shapeOptions.params.table]
1654
- : undefined,
1655
- }),
1656
- sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => {
1657
- const retainsTagState = collectionTags.has(params.collection)
1658
- let tagState = collectionTags.get(params.collection)
1659
- if (!tagState) {
1660
- tagState = createTagState()
1661
- collectionTags.set(params.collection, tagState)
1662
- }
1663
- const {
1664
- processTagsForChangeMessage,
1665
- clearTagTrackingState,
1666
- clearTagsForRow,
1667
- processMoveOutEvent,
1668
- processMoveInEvent,
1669
- } = tagState
1670
- const lifecycle = getLifecycle(params.collection)
1671
- const lifecycleEpoch = lifecycle.start()
1672
- const isActiveLifecycle = () => lifecycle.isActive(lifecycleEpoch)
1673
- Object.assign(params.collection.utils, lifecycle.utils)
1674
-
1675
- const {
1676
- begin,
1677
- write,
1678
- commit: commitSyncTransaction,
1679
- markReady,
1680
- markError,
1681
- truncate,
1682
- collection,
1683
- metadata,
1684
- } = params
1685
- let commitSequence = 0
1686
- const pendingAppliedReceipts = new Map<number, Promise<void>>()
1687
- const commit = (signal?: AbortSignal): SyncAppliedReceipt => {
1688
- const sequence = ++commitSequence
1689
- const applied = commitSyncTransaction(signal)
1690
- if (applied === true) {
1691
- return true
1692
- }
1693
- pendingAppliedReceipts.set(sequence, applied)
1694
- const removeReceipt = () => pendingAppliedReceipts.delete(sequence)
1695
- void applied.then(removeReceipt, removeReceipt)
1696
- return applied
1697
- }
1698
- const waitForCommitsAfter = async (cursor: number): Promise<void> => {
1699
- await Promise.all(
1700
- Array.from(pendingAppliedReceipts, ([sequence, applied]) =>
1701
- sequence > cursor ? applied : undefined,
1702
- ),
1703
- )
1704
- }
1705
- const readPersistedResumeState = (): ElectricResumeState | undefined => {
1706
- const persistedResumeState = metadata?.collection.get(`electric:resume`)
1707
- return parseElectricResumeState(persistedResumeState)
1732
+ const startSession = (
1733
+ params: Parameters<SyncConfig<T>[`sync`]>[0],
1734
+ freshScopedGeneration = false,
1735
+ ) => {
1736
+ const retainsTagState = collectionTags.has(params.collection)
1737
+ let tagState = collectionTags.get(params.collection)
1738
+ if (!tagState) {
1739
+ tagState = createTagState()
1740
+ collectionTags.set(params.collection, tagState)
1741
+ }
1742
+ const {
1743
+ processTagsForChangeMessage,
1744
+ clearTagTrackingState,
1745
+ clearTagsForRow,
1746
+ processMoveOutEvent,
1747
+ processMoveInEvent,
1748
+ } = tagState
1749
+ if (freshScopedGeneration) clearTagTrackingState()
1750
+ const lifecycle = getLifecycle(params.collection)
1751
+ if (freshScopedGeneration) lifecycle.forgetEvidenceAfterScopedRecovery()
1752
+ const lifecycleEpoch = lifecycle.start()
1753
+ const isActiveLifecycle = () => lifecycle.isActive(lifecycleEpoch)
1754
+ Object.assign(params.collection.utils, lifecycle.utils)
1755
+
1756
+ const {
1757
+ begin,
1758
+ write,
1759
+ commit: commitSyncTransaction,
1760
+ markReady,
1761
+ markError,
1762
+ truncate,
1763
+ collection,
1764
+ metadata,
1765
+ } = params
1766
+ let commitSequence = 0
1767
+ const pendingAppliedReceipts = new Map<number, Promise<void>>()
1768
+ const commit = (signal?: AbortSignal): SyncAppliedReceipt => {
1769
+ const sequence = ++commitSequence
1770
+ const applied = commitSyncTransaction(signal)
1771
+ if (applied === true) {
1772
+ return true
1708
1773
  }
1709
-
1710
- const persistence =
1711
- metadata === undefined
1712
- ? null
1713
- : validateSyncPersistenceCapability(metadata.persistence)
1714
- const hydrateBaseline = persistence?.hydrateBaseline
1715
- const resumeSnapshot = persistence?.resumeSnapshot
1716
- const certifyResumeSnapshot = resumeSnapshot?.certify
1717
- const getKeySetEvidence = resumeSnapshot?.getKeySetEvidence
1718
- const expectCurrentCommit = resumeSnapshot?.expectCurrentCommit
1719
- const persistedKeySetEvidence = getKeySetEvidence?.()
1720
-
1721
- const persistedResumeState = getNewestElectricResumeState(
1722
- readPersistedResumeState(),
1723
- lifecycle.resumeState,
1774
+ pendingAppliedReceipts.set(sequence, applied)
1775
+ const removeReceipt = () => pendingAppliedReceipts.delete(sequence)
1776
+ void applied.then(removeReceipt, removeReceipt)
1777
+ return applied
1778
+ }
1779
+ const waitForCommitsAfter = async (cursor: number): Promise<void> => {
1780
+ await Promise.all(
1781
+ Array.from(pendingAppliedReceipts, ([sequence, applied]) =>
1782
+ sequence > cursor ? applied : undefined,
1783
+ ),
1724
1784
  )
1725
- const shapeIdentity = getStableShapeIdentity({
1726
- url: shapeOptions.url,
1727
- params: shapeOptions.params as Record<string, unknown> | undefined,
1785
+ }
1786
+ const persistence =
1787
+ metadata === undefined
1788
+ ? null
1789
+ : validateSyncPersistenceCapability(metadata.persistence)
1790
+ const hydrateBaseline = persistence?.hydrateBaseline
1791
+ const resumeSnapshot = persistence?.resumeSnapshot
1792
+ const certifyResumeSnapshot = resumeSnapshot?.certify
1793
+ const getKeySetEvidence = resumeSnapshot?.getKeySetEvidence
1794
+ const expectCurrentCommit = resumeSnapshot?.expectCurrentCommit
1795
+ const persistedKeySetEvidence = getKeySetEvidence?.()
1796
+
1797
+ const storedResumeRecord = freshScopedGeneration
1798
+ ? undefined
1799
+ : metadata?.collection.get(`electric:resume`)
1800
+ const storedResumeState = parseElectricResumeState(storedResumeRecord)
1801
+ const malformedPersistedResume =
1802
+ storedResumeRecord !== undefined && storedResumeState === undefined
1803
+ const persistedResumeState = getNewestElectricResumeState(
1804
+ storedResumeState,
1805
+ lifecycle.resumeState,
1806
+ )
1807
+ const shapeIdentity = getStableShapeIdentity({
1808
+ url: shapeOptions.url,
1809
+ params: shapeOptions.params as Record<string, unknown> | undefined,
1810
+ })
1811
+ const hasIncompatiblePersistedResume =
1812
+ persistedResumeState?.kind === `resume` &&
1813
+ persistedResumeState.shapeId !== shapeIdentity
1814
+ // A pre-ledger `unknown` baseline cannot justify a non-initial cursor.
1815
+ // One fresh replacement establishes consistent evidence for later resumes.
1816
+ const lacksCompletePersistedKeySet =
1817
+ persistedResumeState?.kind === `resume` &&
1818
+ getKeySetEvidence !== undefined &&
1819
+ persistedKeySetEvidence?.status !== `consistent`
1820
+ // Claim expiry can leave a partial managed cache without a resume record.
1821
+ // Its incompatible key set alone requires a fresh source snapshot.
1822
+ const hasIncompatibleManagedCache =
1823
+ persistence?.managedCacheGeneration === true &&
1824
+ persistedKeySetEvidence?.status === `incompatible`
1825
+ const requiresFreshSourceEvidence =
1826
+ !freshScopedGeneration &&
1827
+ (hasIncompatibleManagedCache ||
1828
+ (shapeOptions.offset === undefined &&
1829
+ shapeOptions.handle === undefined &&
1830
+ (persistedResumeState !== undefined || malformedPersistedResume) &&
1831
+ (malformedPersistedResume ||
1832
+ hasIncompatiblePersistedResume ||
1833
+ persistedResumeState?.kind === `reset` ||
1834
+ lacksCompletePersistedKeySet ||
1835
+ (!retainsTagState &&
1836
+ persistedResumeState?.kind === `resume` &&
1837
+ persistedResumeState.requiresTagState !== false))))
1838
+ const scopedRecovery =
1839
+ syncMode === `on-demand` &&
1840
+ requiresFreshSourceEvidence &&
1841
+ persistence?.managedCacheGeneration === true &&
1842
+ persistence.startScopedRecovery !== undefined
1843
+ if (scopedRecovery) lifecycle.forgetEvidenceAfterScopedRecovery()
1844
+ const usesChangesOnlyLog =
1845
+ syncMode === `on-demand` &&
1846
+ (!requiresFreshSourceEvidence || scopedRecovery)
1847
+ const usesFullLog = syncMode === `on-demand` && !usesChangesOnlyLog
1848
+ // A full-log on-demand stream waits for this snapshot before any load. A
1849
+ // progressive stream waits for it only outside its buffered initial sync,
1850
+ // where a load installs no rows itself.
1851
+ const tracksFullSnapshot = usesFullLog || syncMode === `progressive`
1852
+ let resolveFullSnapshot: (receipt: SyncAppliedReceipt) => void = () => {}
1853
+ let rejectFullSnapshot: (error: unknown) => void = () => {}
1854
+ let fullSnapshotReady = Promise.resolve()
1855
+ const resetFullSnapshot = () => {
1856
+ if (!tracksFullSnapshot) return
1857
+ fullSnapshotReady = new Promise<void>((resolve, reject) => {
1858
+ resolveFullSnapshot = (receipt) =>
1859
+ resolve(receipt === true ? undefined : receipt)
1860
+ rejectFullSnapshot = reject
1728
1861
  })
1729
- const hasIncompatiblePersistedResume =
1730
- persistedResumeState?.kind === `resume` &&
1731
- persistedResumeState.shapeId !== shapeIdentity
1732
- // A pre-ledger `unknown` baseline cannot justify a non-initial cursor.
1733
- // One fresh replacement establishes consistent evidence for later resumes.
1734
- const lacksCompletePersistedKeySet =
1735
- persistedResumeState?.kind === `resume` &&
1736
- getKeySetEvidence !== undefined &&
1737
- persistedKeySetEvidence?.status !== `consistent`
1738
- const needsFullSnapshot =
1739
- shapeOptions.offset === undefined &&
1740
- shapeOptions.handle === undefined &&
1741
- persistedResumeState !== undefined &&
1742
- (persistedResumeState.kind === `reset` ||
1743
- lacksCompletePersistedKeySet ||
1744
- (!retainsTagState && persistedResumeState.requiresTagState !== false))
1745
- const canUsePersistedResume =
1746
- shapeOptions.offset === undefined &&
1747
- shapeOptions.handle === undefined &&
1748
- persistedResumeState?.kind === `resume` &&
1749
- !hasIncompatiblePersistedResume &&
1750
- // Cached rows do not contain authoritative tag/active-condition state.
1751
- // Only a complete adapter ledger can justify a persisted cursor.
1752
- !needsFullSnapshot
1753
- const hasExplicitResumeOffset =
1754
- shapeOptions.offset !== undefined && shapeOptions.offset !== `-1`
1755
- if (!canUsePersistedResume && !hasExplicitResumeOffset) {
1756
- clearTagTrackingState()
1757
- }
1758
- const receivesCompleteRows = shapeOptions.params?.replica === `full`
1759
- const requiresKeySetCertification =
1760
- canUsePersistedResume && certifyResumeSnapshot !== undefined
1761
- // Eager and progressive streams that start after the initial offset can
1762
- // only apply partial updates when the local materialization is complete.
1763
- const requiresCompleteResume =
1764
- syncMode !== `on-demand` &&
1765
- (canUsePersistedResume ||
1766
- (hasExplicitResumeOffset && !receivesCompleteRows))
1767
- // A fresh snapshot replaces its hydrated cache; omitting the old offset
1768
- // alone would merge rows that no longer exist on the server.
1769
- let freshSnapshotPending =
1770
- (syncMode === `eager` || needsFullSnapshot) &&
1771
- !canUsePersistedResume &&
1772
- !hasExplicitResumeOffset &&
1773
- hydrateBaseline !== undefined
1774
-
1775
- // Wrap markReady to wait for test hook in progressive mode
1776
- let progressiveReadyGate: Promise<void> | null = null
1777
- let streamErrorVersion = 0
1778
- const wrappedMarkReady = (
1779
- isBuffering: boolean,
1780
- expectedErrorVersion = streamErrorVersion,
1781
- ) => {
1782
- if (streamErrorVersion !== expectedErrorVersion) return
1783
-
1784
- // Only create gate if we're in buffering phase (first up-to-date)
1785
- if (
1786
- isBuffering &&
1787
- syncMode === `progressive` &&
1788
- testHooks?.beforeMarkingReady
1789
- ) {
1790
- // Create a new gate promise for this sync cycle
1791
- progressiveReadyGate = testHooks.beforeMarkingReady()
1792
- progressiveReadyGate.then(() => {
1793
- if (streamErrorVersion === expectedErrorVersion) {
1794
- markReady()
1795
- }
1796
- })
1797
- } else {
1798
- // No hook, not buffering, or already past first up-to-date
1799
- markReady()
1800
- }
1801
- }
1802
-
1803
- // Abort controller for the stream - wraps the signal if provided
1804
- const abortController = new AbortController()
1805
- const forwardExternalAbort = () => abortController.abort()
1862
+ void fullSnapshotReady.catch(() => undefined)
1863
+ }
1864
+ resetFullSnapshot()
1865
+ const canUsePersistedResume =
1866
+ shapeOptions.offset === undefined &&
1867
+ shapeOptions.handle === undefined &&
1868
+ persistedResumeState?.kind === `resume` &&
1869
+ !hasIncompatiblePersistedResume &&
1870
+ // Cached rows do not contain authoritative tag/active-condition state.
1871
+ // Only a complete adapter ledger can justify a persisted cursor.
1872
+ !requiresFreshSourceEvidence
1873
+ const hasExplicitResumeOffset =
1874
+ shapeOptions.offset !== undefined && shapeOptions.offset !== `-1`
1875
+ if (!canUsePersistedResume && !hasExplicitResumeOffset) {
1876
+ clearTagTrackingState()
1877
+ }
1878
+ const receivesCompleteRows = shapeOptions.params?.replica === `full`
1879
+ const requiresKeySetCertification =
1880
+ canUsePersistedResume && certifyResumeSnapshot !== undefined
1881
+ // Eager and progressive streams that start after the initial offset can
1882
+ // only apply partial updates when the local materialization is complete.
1883
+ const requiresCompleteResume =
1884
+ syncMode !== `on-demand` &&
1885
+ (canUsePersistedResume ||
1886
+ (hasExplicitResumeOffset && !receivesCompleteRows))
1887
+ // A fresh snapshot replaces its hydrated cache; omitting the old offset
1888
+ // alone would merge rows that no longer exist on the server.
1889
+ let freshSnapshotPending =
1890
+ (syncMode === `eager` ||
1891
+ (requiresFreshSourceEvidence && !scopedRecovery)) &&
1892
+ !canUsePersistedResume &&
1893
+ !hasExplicitResumeOffset &&
1894
+ hydrateBaseline !== undefined
1895
+
1896
+ // Wrap markReady to wait for test hook in progressive mode
1897
+ let progressiveReadyGate: Promise<void> | null = null
1898
+ let streamErrorVersion = 0
1899
+ const wrappedMarkReady = (
1900
+ isBuffering: boolean,
1901
+ expectedErrorVersion = streamErrorVersion,
1902
+ ) => {
1903
+ if (!isActiveLifecycle() || streamErrorVersion !== expectedErrorVersion)
1904
+ return
1806
1905
 
1807
- if (shapeOptions.signal) {
1808
- shapeOptions.signal.addEventListener(`abort`, forwardExternalAbort, {
1809
- once: true,
1906
+ // Only create gate if we're in buffering phase (first up-to-date)
1907
+ if (
1908
+ isBuffering &&
1909
+ syncMode === `progressive` &&
1910
+ testHooks?.beforeMarkingReady
1911
+ ) {
1912
+ // Create a new gate promise for this sync cycle
1913
+ progressiveReadyGate = testHooks.beforeMarkingReady()
1914
+ progressiveReadyGate.then(() => {
1915
+ if (streamErrorVersion === expectedErrorVersion) {
1916
+ markReady()
1917
+ }
1810
1918
  })
1811
- if (shapeOptions.signal.aborted) {
1812
- abortController.abort()
1813
- }
1919
+ } else {
1920
+ // No hook, not buffering, or already past first up-to-date
1921
+ markReady()
1814
1922
  }
1923
+ }
1815
1924
 
1816
- abortController.signal.addEventListener(`abort`, () => {
1817
- lifecycle.retire(lifecycleEpoch)
1925
+ // Abort controller for the stream - wraps the signal if provided
1926
+ const abortController = new AbortController()
1927
+ let cleanupAbortedStream = false
1928
+ // A final initial failure decides its waiting loads. A cleanup that a
1929
+ // status listener runs inside that failure must not relabel it.
1930
+ let initialSyncFailedFinally = false
1931
+ // Once the first up-to-date is committed, its acceptance alone decides
1932
+ // the initial sync: the Collection becomes ready when the snapshot is
1933
+ // accepted, whatever errors, retries, or give-ups arrive meanwhile, and
1934
+ // fails if the acceptance fails.
1935
+ let firstSnapshotCommitted = false
1936
+ // Cleanup, including a cache rotation's retirement, ends this session's
1937
+ // claim on readiness; a replacement session decides it.
1938
+ let sessionCleanedUp = false
1939
+ const failInitialSync = (cause: unknown) => {
1940
+ if (firstSnapshotCommitted) return
1941
+ initialSyncFailedFinally = true
1942
+ markError(cause)
1943
+ }
1944
+ const settleFirstSnapshot = (isBuffering: boolean, failure?: unknown) => {
1945
+ if (sessionCleanedUp || collection.status !== `loading`) return
1946
+ if (failure !== undefined) {
1947
+ initialSyncFailedFinally = true
1948
+ markError(failure)
1949
+ } else if (isActiveLifecycle()) {
1950
+ wrappedMarkReady(isBuffering, streamErrorVersion)
1951
+ } else markReady()
1952
+ }
1953
+ // An external abort stops the source for good. Before the first ready,
1954
+ // that is the final initial failure; cleanup aborts internally instead.
1955
+ const forwardExternalAbort = () => {
1956
+ const fails = isActiveLifecycle() && collection.status === `loading`
1957
+ abortController.abort()
1958
+ if (fails) failInitialSync(new StreamAbortedError(collectionId))
1959
+ }
1960
+
1961
+ if (shapeOptions.signal) {
1962
+ shapeOptions.signal.addEventListener(`abort`, forwardExternalAbort, {
1963
+ once: true,
1818
1964
  })
1965
+ if (shapeOptions.signal.aborted) forwardExternalAbort()
1966
+ }
1819
1967
 
1820
- const stream = new ShapeStream({
1821
- ...shapeOptions,
1822
- // Recovery needs a complete snapshot even for on-demand shapes.
1823
- // Normal on-demand startup still subscribes to changes only.
1824
- log:
1825
- syncMode === `on-demand` && !needsFullSnapshot
1826
- ? `changes_only`
1827
- : undefined,
1828
- // In on-demand mode, we only need the changes from the point of time the collection was created
1829
- // so we default to `now` when there is no saved offset.
1830
- offset:
1831
- shapeOptions.offset ??
1832
- (canUsePersistedResume
1833
- ? (persistedResumeState.offset as Offset)
1834
- : syncMode === `on-demand` && !needsFullSnapshot
1835
- ? `now`
1836
- : undefined),
1837
- handle:
1838
- shapeOptions.handle ??
1839
- (canUsePersistedResume ? persistedResumeState.handle : undefined),
1840
- signal: abortController.signal,
1841
- onError: (errorParams) => {
1842
- streamErrorVersion++
1843
- // Note that Electric sends a 409 error on a `must-refetch` message, but the
1844
- // ShapeStream handled this and it will not reach this handler, therefor
1845
- // this handler will not run for a `must-refetch`.
1846
- const initialSyncFailed = collection.status === `loading`
1847
- if (initialSyncFailed) {
1848
- markError(errorParams)
1849
- }
1968
+ abortController.signal.addEventListener(`abort`, () => {
1969
+ rejectFullSnapshot(new StreamAbortedError(collectionId))
1970
+ lifecycle.retire(lifecycleEpoch)
1971
+ })
1850
1972
 
1851
- if (shapeOptions.onError) {
1852
- return shapeOptions.onError(errorParams)
1853
- } else {
1854
- console.error(
1855
- `An error occurred while syncing collection: ${collection.id}, \n` +
1856
- (initialSyncFailed
1857
- ? `the initial sync has been marked as failed. \n`
1858
- : `the last ready snapshot has been preserved. \n`) +
1859
- `You can provide an 'onError' handler on the shapeOptions to handle this error, and this message will not be logged.`,
1860
- errorParams,
1861
- )
1973
+ const stream = new ShapeStream({
1974
+ ...shapeOptions,
1975
+ // Uncertified on-demand cache rows are quarantined and loaded only
1976
+ // through fresh subset snapshots.
1977
+ log: usesChangesOnlyLog ? `changes_only` : undefined,
1978
+ // In on-demand mode, we only need the changes from the point of time the collection was created
1979
+ // so we default to `now` when there is no saved offset.
1980
+ offset:
1981
+ shapeOptions.offset ??
1982
+ (canUsePersistedResume
1983
+ ? (persistedResumeState.offset as Offset)
1984
+ : usesChangesOnlyLog
1985
+ ? `now`
1986
+ : undefined),
1987
+ handle:
1988
+ shapeOptions.handle ??
1989
+ (canUsePersistedResume ? persistedResumeState.handle : undefined),
1990
+ signal: abortController.signal,
1991
+ onError: async (errorParams) => {
1992
+ if (!isActiveLifecycle() || abortController.signal.aborted) return
1993
+ streamErrorVersion++
1994
+ // Note that Electric sends a 409 error on a `must-refetch` message, but the
1995
+ // ShapeStream handled this and it will not reach this handler, therefor
1996
+ // this handler will not run for a `must-refetch`.
1997
+ // A stream error fails waiting loads and the initial sync only when no
1998
+ // retry follows. A retry that succeeds is a success: waiting loads
1999
+ // settle on the snapshot it establishes.
2000
+ // Waiting loads and readiness waits reject with the same cause.
2001
+ const fail = (cause: unknown) => {
2002
+ rejectFullSnapshot(cause)
2003
+ if (collection.status === `loading`) failInitialSync(cause)
2004
+ }
2005
+
2006
+ if (shapeOptions.onError) {
2007
+ let retry: Awaited<ReturnType<typeof shapeOptions.onError>>
2008
+ try {
2009
+ retry = await shapeOptions.onError(errorParams)
2010
+ } catch (error) {
2011
+ fail(error)
2012
+ throw error
1862
2013
  }
2014
+ if (!retry || typeof retry !== `object`) fail(errorParams)
2015
+ return retry
2016
+ } else {
2017
+ const initialSyncFailed = collection.status === `loading`
2018
+ fail(errorParams)
2019
+ console.error(
2020
+ `An error occurred while syncing collection: ${collection.id}, \n` +
2021
+ (initialSyncFailed
2022
+ ? `the initial sync has been marked as failed. \n`
2023
+ : `the last ready snapshot has been preserved. \n`) +
2024
+ `You can provide an 'onError' handler on the shapeOptions to handle this error, and this message will not be logged.`,
2025
+ errorParams,
2026
+ )
2027
+ }
1863
2028
 
1864
- return
1865
- },
1866
- })
1867
- let transactionStarted = false
1868
- const newTxids = new Set<Txid>()
1869
- const newSnapshots: Array<PostgresSnapshot> = []
1870
- // Track if we've completed initial sync in progressive mode. A persisted
1871
- // resume starts from an already-committed stream offset, so the next
1872
- // up-to-date message must not run the initial atomic swap again.
1873
- let hasReceivedUpToDate =
1874
- syncMode === `progressive` && requiresCompleteResume
1875
- // A must-refetch starts a new snapshot generation. Until its up-to-date
1876
- // commit is applied, old Collection keys cannot make an update valid and
1877
- // the durable resume marker must remain reset.
1878
- let isResettingSnapshot = false
1879
- let resetGeneration = 0
1880
-
1881
- // Progressive mode state
1882
- // Helper to determine if we're buffering the initial sync
1883
- const isBufferingInitialSync = () =>
1884
- syncMode === `progressive` &&
1885
- !hasReceivedUpToDate &&
1886
- !isResettingSnapshot
1887
- const bufferedMessages: Array<Message<T>> = [] // Buffer change messages during initial sync
1888
- // Progressive subset rows are provisional: the initial atomic swap
1889
- // normally discards them. Retain one only when a later stream update
1890
- // needs that full row as its baseline.
1891
- const progressiveSnapshotRows = new Map<string | number, Message<T>>()
1892
-
1893
- // Presence spans the open source transaction and persisted callbacks
1894
- // whose FIFO receipts have not applied yet. Later callbacks must see
1895
- // those staged writes even though persistence has not replayed them into
1896
- // core. Once the newest receipt applies, syncedData is authoritative.
1897
- const pendingPresence = new Map<string | number, boolean>()
1898
- let usesBaseline = true
1899
- let pendingPresenceRevision = 0
1900
-
1901
- const recordPendingPresence = (
1902
- rowId: string | number,
1903
- present: boolean,
1904
- ) => {
1905
- pendingPresenceRevision++
1906
- pendingPresence.set(rowId, present)
1907
- }
2029
+ return
2030
+ },
2031
+ })
2032
+ let transactionStarted = false
2033
+ const newTxids = new Set<Txid>()
2034
+ const newSnapshots: Array<PostgresSnapshot> = []
2035
+ // Track if we've completed initial sync in progressive mode. A persisted
2036
+ // resume starts from an already-committed stream offset, so the next
2037
+ // up-to-date message must not run the initial atomic swap again.
2038
+ let hasReceivedUpToDate =
2039
+ syncMode === `progressive` && requiresCompleteResume
2040
+ // A complete resume already holds the established rows.
2041
+ if (hasReceivedUpToDate) resolveFullSnapshot(true)
2042
+ // A must-refetch starts a new snapshot generation. Until its up-to-date
2043
+ // commit is applied, old Collection keys cannot make an update valid and
2044
+ // the durable resume marker must remain reset.
2045
+ let isResettingSnapshot = false
2046
+ let resetGeneration = 0
2047
+
2048
+ // Progressive mode state
2049
+ // Helper to determine if we're buffering the initial sync
2050
+ const isBufferingInitialSync = () =>
2051
+ syncMode === `progressive` && !hasReceivedUpToDate && !isResettingSnapshot
2052
+ const bufferedMessages: Array<Message<T>> = [] // Buffer change messages during initial sync
2053
+ // Progressive subset rows are provisional: the initial atomic swap
2054
+ // normally discards them. Retain one only when a later stream update
2055
+ // needs that full row as its baseline.
2056
+ const progressiveSnapshotRows = new Map<string | number, Message<T>>()
2057
+
2058
+ // Presence spans the open source transaction and persisted callbacks
2059
+ // whose FIFO receipts have not applied yet. Later callbacks must see
2060
+ // those staged writes even though persistence has not replayed them into
2061
+ // core. Once the newest receipt applies, syncedData is authoritative.
2062
+ const pendingPresence = new Map<string | number, boolean>()
2063
+ let usesBaseline = true
2064
+ let pendingPresenceRevision = 0
2065
+
2066
+ const recordPendingPresence = (
2067
+ rowId: string | number,
2068
+ present: boolean,
2069
+ ) => {
2070
+ pendingPresenceRevision++
2071
+ pendingPresence.set(rowId, present)
2072
+ }
1908
2073
 
1909
- const recordPendingTruncate = () => {
1910
- pendingPresenceRevision++
1911
- pendingPresence.clear()
1912
- progressiveSnapshotRows.clear()
1913
- usesBaseline = false
1914
- }
2074
+ const recordPendingTruncate = () => {
2075
+ pendingPresenceRevision++
2076
+ pendingPresence.clear()
2077
+ progressiveSnapshotRows.clear()
2078
+ usesBaseline = false
2079
+ }
1915
2080
 
1916
- const commitSourceTransaction = (): SyncAppliedReceipt => {
1917
- const committedPresenceRevision = pendingPresenceRevision
1918
- const applied = commit()
1919
- if (metadata?.persistence) {
1920
- const retireAppliedPresence = () => {
1921
- if (pendingPresenceRevision === committedPresenceRevision) {
1922
- pendingPresence.clear()
1923
- usesBaseline = true
1924
- }
2081
+ const commitSourceTransaction = (): SyncAppliedReceipt => {
2082
+ const committedPresenceRevision = pendingPresenceRevision
2083
+ const applied = commit()
2084
+ if (metadata?.persistence) {
2085
+ const retireAppliedPresence = () => {
2086
+ if (pendingPresenceRevision === committedPresenceRevision) {
2087
+ pendingPresence.clear()
2088
+ usesBaseline = true
1925
2089
  }
1926
- if (applied === true) retireAppliedPresence()
1927
- else void applied.then(retireAppliedPresence, () => undefined)
1928
2090
  }
1929
- return applied
2091
+ if (applied === true) retireAppliedPresence()
2092
+ else void applied.then(retireAppliedPresence, () => undefined)
1930
2093
  }
2094
+ return applied
2095
+ }
1931
2096
 
1932
- // Subset hydration must not split a stream transaction it overlaps.
1933
- const beginSourceTransaction = () => {
1934
- begin()
1935
- metadata?.persistence?.reserveCommitTurn()
1936
- }
2097
+ // Subset hydration must not split a stream transaction it overlaps.
2098
+ const beginSourceTransaction = () => {
2099
+ begin()
2100
+ metadata?.persistence?.reserveCommitTurn()
2101
+ }
1937
2102
 
1938
- // Track keys that have been synced to handle overlapping subset queries.
1939
- // When multiple subset queries return the same row, the server sends `insert`
1940
- // for each response. We convert subsequent inserts to updates to avoid
1941
- // duplicate key errors when the row's data has changed between requests.
1942
- const syncedKeys = new Set<string | number>()
1943
- let resumeInvalid = false
2103
+ // Track keys that have been synced to handle overlapping subset queries.
2104
+ // When multiple subset queries return the same row, the server sends `insert`
2105
+ // for each response. We convert subsequent inserts to updates to avoid
2106
+ // duplicate key errors when the row's data has changed between requests.
2107
+ const syncedKeys = new Set<string | number>()
2108
+ let resumeInvalid = false
1944
2109
 
1945
- const stageResumeMetadata = () => {
1946
- if (!isActiveLifecycle() || resumeInvalid) {
1947
- return
1948
- }
1949
- const shapeHandle = stream.shapeHandle
1950
- const lastOffset = stream.lastOffset
1951
- if (!shapeHandle || lastOffset === `-1`) {
1952
- return
1953
- }
2110
+ const stageResumeMetadata = () => {
2111
+ if (
2112
+ !isActiveLifecycle() ||
2113
+ resumeInvalid ||
2114
+ scopedRecovery ||
2115
+ freshScopedGeneration
2116
+ ) {
2117
+ return
2118
+ }
2119
+ const shapeHandle = stream.shapeHandle
2120
+ const lastOffset = stream.lastOffset
2121
+ if (!shapeHandle || lastOffset === `-1`) {
2122
+ return
2123
+ }
1954
2124
 
1955
- const resumeState: ElectricResumeState = {
1956
- kind: `resume`,
1957
- offset: lastOffset,
1958
- handle: shapeHandle,
1959
- shapeId: shapeIdentity,
1960
- updatedAt: Date.now(),
1961
- requiresTagState: tagState.hasTags(),
1962
- }
1963
- lifecycle.resumeState = resumeState
1964
- metadata?.collection.set(`electric:resume`, resumeState)
2125
+ const resumeState: ElectricResumeState = {
2126
+ kind: `resume`,
2127
+ offset: lastOffset,
2128
+ handle: shapeHandle,
2129
+ shapeId: shapeIdentity,
2130
+ updatedAt: Date.now(),
2131
+ requiresTagState: tagState.hasTags(),
1965
2132
  }
2133
+ lifecycle.resumeState = resumeState
2134
+ metadata?.collection.set(`electric:resume`, resumeState)
2135
+ }
1966
2136
 
1967
- const commitResetResumeMetadataImmediately = (
1968
- expectInResumeSnapshot = false,
1969
- ) => {
1970
- const resetState: ElectricResumeState = {
1971
- kind: `reset`,
1972
- updatedAt: Date.now(),
1973
- }
1974
- lifecycle.resumeState = resetState
2137
+ const commitResetResumeMetadataImmediately = (
2138
+ expectInResumeSnapshot = false,
2139
+ ) => {
2140
+ const resetState: ElectricResumeState = {
2141
+ kind: `reset`,
2142
+ updatedAt:
2143
+ Math.max(
2144
+ Date.now(),
2145
+ persistedResumeState?.updatedAt ?? 0,
2146
+ lifecycle.resumeState?.updatedAt ?? 0,
2147
+ ) + 1,
2148
+ }
2149
+ lifecycle.resumeState = resetState
1975
2150
 
1976
- if (metadata) {
1977
- begin()
1978
- metadata.collection.set(`electric:resume`, resetState)
1979
- if (expectInResumeSnapshot) {
1980
- expectCurrentCommit?.()
1981
- }
1982
- commit()
2151
+ if (metadata) {
2152
+ begin()
2153
+ metadata.collection.set(`electric:resume`, resetState)
2154
+ if (expectInResumeSnapshot) {
2155
+ expectCurrentCommit?.()
1983
2156
  }
2157
+ commit()
1984
2158
  }
2159
+ }
1985
2160
 
1986
- if (
2161
+ // Scoped recovery writes its reset marker into the rotated generation.
2162
+ // Writing it before rotation could alter a warm peer's current cache if
2163
+ // this run's claim expires before SQLite decides rotation ownership.
2164
+ if (
2165
+ requiresFreshSourceEvidence &&
2166
+ !scopedRecovery &&
2167
+ (malformedPersistedResume ||
1987
2168
  hasIncompatiblePersistedResume ||
1988
- (needsFullSnapshot && persistedResumeState.kind === `resume`)
1989
- ) {
1990
- // This reset is part of the current runtime's startup decision. The
1991
- // persisted wrapper may commit it before loading the atomic baseline,
1992
- // so carry ownership of exactly this generation into certification.
1993
- commitResetResumeMetadataImmediately(true)
1994
- }
1995
-
1996
- /**
1997
- * Process a change message: handle tags and write the mutation
1998
- */
1999
- const processChangeMessage = (changeMessage: Message<T>) => {
2000
- if (!isChangeMessage(changeMessage)) {
2001
- return
2002
- }
2169
+ persistedResumeState?.kind === `resume`)
2170
+ ) {
2171
+ // This reset is part of the current runtime's startup decision. The
2172
+ // persisted wrapper may commit it before loading the atomic baseline,
2173
+ // so carry ownership of exactly this generation into certification.
2174
+ commitResetResumeMetadataImmediately(true)
2175
+ }
2176
+ const scopedResetState: ElectricResumeState | undefined = scopedRecovery
2177
+ ? persistedResumeState?.kind === `reset`
2178
+ ? persistedResumeState
2179
+ : lifecycle.resumeState?.kind === `reset`
2180
+ ? lifecycle.resumeState
2181
+ : {
2182
+ kind: `reset`,
2183
+ updatedAt: Date.now(),
2184
+ }
2185
+ : undefined
2186
+ if (scopedResetState) lifecycle.resumeState = scopedResetState
2187
+ const scopedRecoveryPromise =
2188
+ scopedResetState && persistence?.startScopedRecovery
2189
+ ? persistence.startScopedRecovery({
2190
+ key: `electric:resume`,
2191
+ value: scopedResetState,
2192
+ })
2193
+ : undefined
2003
2194
 
2004
- // Process tags if present
2005
- const tags = changeMessage.headers.tags
2006
- const removedTags = changeMessage.headers.removed_tags
2007
- const hasTags = tags || removedTags
2195
+ /**
2196
+ * Process a change message: handle tags and write the mutation
2197
+ */
2198
+ const processChangeMessage = (changeMessage: Message<T>) => {
2199
+ if (!isChangeMessage(changeMessage)) {
2200
+ return
2201
+ }
2008
2202
 
2009
- // Extract active_conditions from headers (DNF support)
2010
- const activeConditions = changeMessage.headers.active_conditions as
2011
- ActiveConditions | undefined
2203
+ // Process tags if present
2204
+ const tags = changeMessage.headers.tags
2205
+ const removedTags = changeMessage.headers.removed_tags
2206
+ const hasTags = tags || removedTags
2012
2207
 
2013
- const rowId = collection.getKeyFromItem(changeMessage.value)
2014
- const operation = changeMessage.headers.operation
2208
+ // Extract active_conditions from headers (DNF support)
2209
+ const activeConditions = changeMessage.headers.active_conditions as
2210
+ ActiveConditions | undefined
2015
2211
 
2016
- // Track synced keys and handle overlapping subset queries.
2017
- // When multiple subset queries return the same row, the server sends
2018
- // `insert` for each response. We convert subsequent inserts to updates
2019
- // to avoid duplicate key errors when the row's data has changed.
2020
- const isDelete = operation === `delete`
2021
- const isDuplicateInsert =
2022
- operation === `insert` && syncedKeys.has(rowId)
2212
+ const rowId = collection.getKeyFromItem(changeMessage.value)
2213
+ const operation = changeMessage.headers.operation
2023
2214
 
2024
- if (isDelete) {
2025
- syncedKeys.delete(rowId)
2026
- } else {
2027
- syncedKeys.add(rowId)
2028
- }
2215
+ // Track synced keys and handle overlapping subset queries.
2216
+ // When multiple subset queries return the same row, the server sends
2217
+ // `insert` for each response. We convert subsequent inserts to updates
2218
+ // to avoid duplicate key errors when the row's data has changed.
2219
+ const isDelete = operation === `delete`
2220
+ const isDuplicateInsert = operation === `insert` && syncedKeys.has(rowId)
2029
2221
 
2030
- if (isDelete) {
2031
- clearTagsForRow(rowId)
2032
- } else if (hasTags) {
2033
- processTagsForChangeMessage(
2034
- tags,
2035
- removedTags,
2036
- rowId,
2037
- activeConditions,
2038
- )
2039
- }
2222
+ if (isDelete) {
2223
+ syncedKeys.delete(rowId)
2224
+ } else {
2225
+ syncedKeys.add(rowId)
2226
+ }
2040
2227
 
2041
- write({
2042
- type: isDuplicateInsert ? `update` : operation,
2043
- value: changeMessage.value,
2044
- // Include the primary key and relation info in the metadata
2045
- metadata: {
2046
- ...changeMessage.headers,
2047
- },
2048
- })
2228
+ if (isDelete) {
2229
+ clearTagsForRow(rowId)
2230
+ } else if (hasTags) {
2231
+ processTagsForChangeMessage(tags, removedTags, rowId, activeConditions)
2049
2232
  }
2050
2233
 
2051
- // Create deduplicated loadSubset wrapper for non-eager modes
2052
- // This prevents redundant snapshot requests when multiple concurrent
2053
- // live queries request overlapping or subset predicates
2054
- const loadSubsetDedupe = createLoadSubsetDedupe({
2055
- stream,
2056
- syncMode,
2057
- isBufferingInitialSync,
2058
- begin,
2059
- write,
2060
- commit,
2061
- getCommitCursor: () => commitSequence,
2062
- waitForCommitsAfter,
2063
- recordSnapshotRow: (row, message) => {
2064
- recordPendingPresence(collection.getKeyFromItem(row), true)
2065
- progressiveSnapshotRows.set(collection.getKeyFromItem(row), message)
2234
+ write({
2235
+ type: isDuplicateInsert ? `update` : operation,
2236
+ value: changeMessage.value,
2237
+ // Include the primary key and relation info in the metadata
2238
+ metadata: {
2239
+ ...changeMessage.headers,
2066
2240
  },
2067
- collectionId,
2068
- // Pass the columnMapper's encode function to transform column names
2069
- // (e.g., camelCase to snake_case) when compiling SQL for subset queries
2070
- encodeColumnName: shapeOptions.columnMapper?.encode,
2071
- // Pass abort signal so requestSnapshot errors can be ignored during cleanup
2072
- signal: abortController.signal,
2073
2241
  })
2242
+ }
2074
2243
 
2075
- const resumeKeysPromise =
2076
- requiresCompleteResume || freshSnapshotPending
2077
- ? hydrateBaseline
2078
- ? (async () => {
2079
- await hydrateBaseline()
2080
- const currentKeySetEvidence = getKeySetEvidence?.()
2081
- if (
2082
- canUsePersistedResume &&
2083
- currentKeySetEvidence?.status !== `consistent`
2084
- ) {
2085
- throw new Error(
2086
- `Electric persisted resume baseline could not be certified during hydration`,
2087
- )
2088
- }
2089
- })()
2090
- : undefined
2091
- : requiresKeySetCertification
2092
- ? (async () => {
2093
- await certifyResumeSnapshot()
2094
- const currentKeySetEvidence = getKeySetEvidence?.()
2095
- if (currentKeySetEvidence?.status !== `consistent`) {
2096
- throw new Error(
2097
- `Electric persisted resume baseline could not be certified`,
2098
- )
2099
- }
2100
- })()
2101
- : undefined
2102
- let areResumeKeysReady = !resumeKeysPromise
2103
- const pendingResumeBatches: Array<Array<Message<T>>> = []
2104
- let unsubscribeStream: () => void = () => {}
2105
-
2106
- const invalidateResume = () => {
2107
- resumeInvalid = true
2108
- if (transactionStarted) {
2109
- const cancellation = new AbortController()
2110
- cancellation.abort()
2111
- commit(cancellation.signal)
2112
- transactionStarted = false
2113
- }
2114
- syncedKeys.clear()
2115
- newTxids.clear()
2116
- newSnapshots.length = 0
2117
- commitResetResumeMetadataImmediately()
2118
- streamErrorVersion++
2119
- unsubscribeStream()
2120
- abortController.abort()
2121
- markError(
2122
- new Error(
2123
- `Electric resume state referenced an unseen row; a full snapshot is required`,
2124
- ),
2125
- )
2244
+ // Create deduplicated loadSubset wrapper for non-eager modes
2245
+ // This prevents redundant snapshot requests when multiple concurrent
2246
+ // live queries request overlapping or subset predicates
2247
+ const loadSubsetDedupe = createLoadSubsetDedupe({
2248
+ stream,
2249
+ syncMode,
2250
+ isBufferingInitialSync,
2251
+ waitForFullSnapshot: usesFullLog ? () => fullSnapshotReady : undefined,
2252
+ waitForEstablishedSnapshot: () => fullSnapshotReady,
2253
+ begin,
2254
+ write,
2255
+ commit,
2256
+ getCommitCursor: () => commitSequence,
2257
+ waitForCommitsAfter,
2258
+ recordSnapshotRow: (row, message) => {
2259
+ recordPendingPresence(collection.getKeyFromItem(row), true)
2260
+ progressiveSnapshotRows.set(collection.getKeyFromItem(row), message)
2261
+ },
2262
+ collectionId,
2263
+ // Pass the columnMapper's encode function to transform column names
2264
+ // (e.g., camelCase to snake_case) when compiling SQL for subset queries
2265
+ encodeColumnName: shapeOptions.columnMapper?.encode,
2266
+ // Pass abort ownership so cleanup stays quiet without certifying an
2267
+ // externally aborted acquisition as successful.
2268
+ signal: abortController.signal,
2269
+ isCleanupAbort: () => cleanupAbortedStream,
2270
+ beforeSnapshot: scopedRecoveryPromise
2271
+ ? () => scopedRecoveryPromise
2272
+ : undefined,
2273
+ })
2274
+
2275
+ const resumeKeysPromise =
2276
+ scopedRecoveryPromise ??
2277
+ (requiresCompleteResume || freshSnapshotPending
2278
+ ? hydrateBaseline
2279
+ ? (async () => {
2280
+ await hydrateBaseline()
2281
+ const currentKeySetEvidence = getKeySetEvidence?.()
2282
+ if (
2283
+ canUsePersistedResume &&
2284
+ currentKeySetEvidence?.status !== `consistent`
2285
+ ) {
2286
+ throw new Error(
2287
+ `Electric persisted resume baseline could not be certified during hydration`,
2288
+ )
2289
+ }
2290
+ })()
2291
+ : undefined
2292
+ : requiresKeySetCertification
2293
+ ? (async () => {
2294
+ await certifyResumeSnapshot()
2295
+ const currentKeySetEvidence = getKeySetEvidence?.()
2296
+ if (currentKeySetEvidence?.status !== `consistent`) {
2297
+ throw new Error(
2298
+ `Electric persisted resume baseline could not be certified`,
2299
+ )
2300
+ }
2301
+ })()
2302
+ : undefined)
2303
+ let areResumeKeysReady = !resumeKeysPromise
2304
+ const pendingResumeBatches: Array<Array<Message<T>>> = []
2305
+ let unsubscribeStream: () => void = () => {}
2306
+
2307
+ const invalidateResume = () => {
2308
+ resumeInvalid = true
2309
+ if (transactionStarted) {
2310
+ const cancellation = new AbortController()
2311
+ cancellation.abort()
2312
+ commit(cancellation.signal)
2313
+ transactionStarted = false
2126
2314
  }
2315
+ syncedKeys.clear()
2316
+ newTxids.clear()
2317
+ newSnapshots.length = 0
2318
+ commitResetResumeMetadataImmediately()
2319
+ streamErrorVersion++
2320
+ unsubscribeStream()
2321
+ abortController.abort()
2322
+ markError(
2323
+ new Error(
2324
+ `Electric resume state referenced an unseen row; a full snapshot is required`,
2325
+ ),
2326
+ )
2327
+ }
2127
2328
 
2128
- const processMessages = (messages: Array<Message<T>>): void => {
2129
- if (!isActiveLifecycle() || resumeInvalid) {
2130
- return
2131
- }
2329
+ const processMessages = (messages: Array<Message<T>>): void => {
2330
+ if (!isActiveLifecycle() || resumeInvalid) {
2331
+ return
2332
+ }
2132
2333
 
2133
- if (freshSnapshotPending) {
2134
- freshSnapshotPending = false
2135
- beginSourceTransaction()
2136
- transactionStarted = true
2137
- truncate()
2138
- recordPendingTruncate()
2139
- syncedKeys.clear()
2140
- clearTagTrackingState()
2141
- isResettingSnapshot = true
2142
- resetGeneration++
2143
- }
2334
+ if (freshSnapshotPending) {
2335
+ freshSnapshotPending = false
2336
+ beginSourceTransaction()
2337
+ transactionStarted = true
2338
+ truncate()
2339
+ recordPendingTruncate()
2340
+ syncedKeys.clear()
2341
+ clearTagTrackingState()
2342
+ isResettingSnapshot = true
2343
+ resetGeneration++
2344
+ }
2144
2345
 
2145
- // Without persistence, core owns pending source transactions and can
2146
- // rebuild this callback's presence overlay from them. Persistence owns
2147
- // queued source transactions until their FIFO turn, so retain the
2148
- // overlay across callbacks instead. A queued truncate still fences off
2149
- // the previous snapshot below.
2150
- if (!metadata?.persistence && !transactionStarted) {
2151
- pendingPresence.clear()
2152
- usesBaseline = true
2153
- for (const pending of collection._state.pendingSyncedTransactions) {
2154
- if (pending.truncate) {
2155
- pendingPresence.clear()
2156
- usesBaseline = false
2157
- }
2158
- for (const operation of pending.operations) {
2159
- pendingPresence.set(operation.key, operation.type !== `delete`)
2160
- }
2346
+ // Without persistence, core owns pending source transactions and can
2347
+ // rebuild this callback's presence overlay from them. Persistence owns
2348
+ // queued source transactions until their FIFO turn, so retain the
2349
+ // overlay across callbacks instead. A queued truncate still fences off
2350
+ // the previous snapshot below.
2351
+ if (!metadata?.persistence && !transactionStarted) {
2352
+ pendingPresence.clear()
2353
+ usesBaseline = true
2354
+ for (const pending of collection._state.pendingSyncedTransactions) {
2355
+ if (pending.truncate) {
2356
+ pendingPresence.clear()
2357
+ usesBaseline = false
2161
2358
  }
2162
- for (const message of bufferedMessages) {
2163
- if (isChangeMessage(message)) {
2164
- pendingPresence.set(
2165
- collection.getKeyFromItem(message.value),
2166
- message.headers.operation !== `delete`,
2167
- )
2168
- }
2359
+ for (const operation of pending.operations) {
2360
+ pendingPresence.set(operation.key, operation.type !== `delete`)
2169
2361
  }
2170
2362
  }
2363
+ for (const message of bufferedMessages) {
2364
+ if (isChangeMessage(message)) {
2365
+ pendingPresence.set(
2366
+ collection.getKeyFromItem(message.value),
2367
+ message.headers.operation !== `delete`,
2368
+ )
2369
+ }
2370
+ }
2371
+ }
2171
2372
 
2172
- // Track commit point type - up-to-date takes precedence as it also triggers progressive mode atomic swap
2173
- let commitPoint: `up-to-date` | `subset-end` | null = null
2373
+ // Track commit point type - up-to-date takes precedence as it also triggers progressive mode atomic swap
2374
+ let commitPoint: `up-to-date` | `subset-end` | null = null
2174
2375
 
2175
- lifecycle.beginMatchGeneration(messages)
2376
+ lifecycle.beginMatchGeneration(messages)
2176
2377
 
2177
- for (const message of messages) {
2178
- lifecycle.observeMatchMessage(message)
2179
- // A match predicate can synchronously clean up and restart sync.
2180
- // Nothing after that boundary belongs to the replacement session.
2181
- if (!isActiveLifecycle()) return
2378
+ for (const message of messages) {
2379
+ lifecycle.observeMatchMessage(message)
2380
+ // A match predicate can synchronously clean up and restart sync.
2381
+ // Nothing after that boundary belongs to the replacement session.
2382
+ if (!isActiveLifecycle()) return
2182
2383
 
2183
- // Check for txids in the message and add them to our store
2184
- // Skip during buffered initial sync in progressive mode (txids will be extracted during atomic swap)
2185
- // EXCEPTION: If a transaction is already started (e.g., from must-refetch), track txids
2186
- // to avoid losing them when messages are written to the existing transaction.
2187
- if (
2188
- hasTxids(message) &&
2189
- (!isBufferingInitialSync() || transactionStarted)
2190
- ) {
2191
- message.headers.txids?.forEach((txid) => newTxids.add(txid))
2192
- }
2384
+ // Check for txids in the message and add them to our store
2385
+ // Skip during buffered initial sync in progressive mode (txids will be extracted during atomic swap)
2386
+ // EXCEPTION: If a transaction is already started (e.g., from must-refetch), track txids
2387
+ // to avoid losing them when messages are written to the existing transaction.
2388
+ if (
2389
+ hasTxids(message) &&
2390
+ (!isBufferingInitialSync() || transactionStarted)
2391
+ ) {
2392
+ message.headers.txids?.forEach((txid) => newTxids.add(txid))
2393
+ }
2193
2394
 
2194
- if (isChangeMessage(message)) {
2195
- const rowId = collection.getKeyFromItem(message.value)
2196
- const operation = message.headers.operation
2197
- const hasKnownRow =
2198
- pendingPresence.get(rowId) ??
2199
- (usesBaseline && collection._state.syncedData.has(rowId))
2200
- if (operation === `update` && !hasKnownRow) {
2201
- // Validate after all earlier events, including tag move-outs.
2202
- // Cancel staged writes before publishing any part of an invalid
2203
- // resumed callback; the next lifecycle must take a full snapshot.
2204
- if (requiresCompleteResume && !isResettingSnapshot) {
2205
- invalidateResume()
2206
- return
2207
- }
2208
- if (!receivesCompleteRows) continue
2209
- }
2210
- recordPendingPresence(rowId, operation !== `delete`)
2211
- if (operation !== `update`) {
2212
- progressiveSnapshotRows.delete(rowId)
2395
+ if (isChangeMessage(message)) {
2396
+ const rowId = collection.getKeyFromItem(message.value)
2397
+ const operation = message.headers.operation
2398
+ const hasKnownRow =
2399
+ pendingPresence.get(rowId) ??
2400
+ (usesBaseline && collection._state.syncedData.has(rowId))
2401
+ if (operation === `update` && !hasKnownRow) {
2402
+ // Validate after all earlier events, including tag move-outs.
2403
+ // Cancel staged writes before publishing any part of an invalid
2404
+ // resumed callback; the next lifecycle must take a full snapshot.
2405
+ if (requiresCompleteResume && !isResettingSnapshot) {
2406
+ invalidateResume()
2407
+ return
2213
2408
  }
2409
+ if (!receivesCompleteRows) continue
2410
+ }
2411
+ recordPendingPresence(rowId, operation !== `delete`)
2412
+ if (operation !== `update`) {
2413
+ progressiveSnapshotRows.delete(rowId)
2214
2414
  }
2415
+ }
2215
2416
 
2216
- if (isChangeMessage(message)) {
2217
- // Check if the message contains schema information
2218
- const schema = message.headers.schema
2219
- if (schema && typeof schema === `string`) {
2220
- // Store the schema for future use if it's a valid string
2221
- relationSchema = schema
2222
- }
2417
+ if (isChangeMessage(message)) {
2418
+ // Check if the message contains schema information
2419
+ const schema = message.headers.schema
2420
+ if (schema && typeof schema === `string`) {
2421
+ // Store the schema for future use if it's a valid string
2422
+ relationSchema = schema
2423
+ }
2223
2424
 
2224
- // In buffered initial sync of progressive mode, buffer messages instead of writing
2225
- // EXCEPTION: If a transaction is already started (e.g., from must-refetch), write
2226
- // directly to it instead of buffering. This prevents orphan transactions.
2227
- if (isBufferingInitialSync() && !transactionStarted) {
2228
- if (message.headers.operation === `update`) {
2229
- const rowId = collection.getKeyFromItem(message.value)
2230
- const snapshotRow = progressiveSnapshotRows.get(rowId)
2231
- if (snapshotRow) {
2232
- bufferedMessages.push(snapshotRow)
2233
- progressiveSnapshotRows.delete(rowId)
2234
- }
2235
- }
2236
- bufferedMessages.push(message)
2237
- } else {
2238
- // Normal processing: write changes immediately
2239
- if (!transactionStarted) {
2240
- beginSourceTransaction()
2241
- transactionStarted = true
2425
+ // In buffered initial sync of progressive mode, buffer messages instead of writing
2426
+ // EXCEPTION: If a transaction is already started (e.g., from must-refetch), write
2427
+ // directly to it instead of buffering. This prevents orphan transactions.
2428
+ if (isBufferingInitialSync() && !transactionStarted) {
2429
+ if (message.headers.operation === `update`) {
2430
+ const rowId = collection.getKeyFromItem(message.value)
2431
+ const snapshotRow = progressiveSnapshotRows.get(rowId)
2432
+ if (snapshotRow) {
2433
+ bufferedMessages.push(snapshotRow)
2434
+ progressiveSnapshotRows.delete(rowId)
2242
2435
  }
2243
-
2244
- processChangeMessage(message)
2245
2436
  }
2246
- } else if (isSnapshotEndMessage(message)) {
2247
- // Track postgres snapshot metadata for resolving awaiting mutations
2248
- // Skip during buffered initial sync (will be extracted during atomic swap)
2249
- // EXCEPTION: If a transaction is already started (e.g., from must-refetch), track snapshots
2250
- // to avoid losing them when messages are written to the existing transaction.
2251
- if (!isBufferingInitialSync() || transactionStarted) {
2252
- newSnapshots.push(parseSnapshotMessage(message))
2253
- }
2254
- } else if (isUpToDateMessage(message)) {
2255
- // up-to-date takes precedence - also triggers progressive mode atomic swap
2256
- commitPoint = `up-to-date`
2257
- } else if (isSubsetEndMessage(message)) {
2258
- // subset-end triggers commit but not progressive mode atomic swap
2259
- if (commitPoint !== `up-to-date`) {
2260
- commitPoint = `subset-end`
2261
- }
2262
- } else if (isMoveOutMessage(message)) {
2263
- // Handle move-out event: buffer if buffering, otherwise process immediately
2264
- // EXCEPTION: If a transaction is already started (e.g., from must-refetch), process
2265
- // immediately to avoid orphan transactions.
2266
- if (isBufferingInitialSync() && !transactionStarted) {
2267
- bufferedMessages.push(message)
2268
- } else {
2269
- // Normal processing: process move-out immediately
2270
- transactionStarted = processMoveOutEvent(
2271
- message.headers.patterns,
2272
- beginSourceTransaction,
2273
- write,
2274
- transactionStarted,
2275
- (rowId) => {
2276
- recordPendingPresence(rowId, false)
2277
- syncedKeys.delete(rowId)
2278
- },
2279
- )
2280
- }
2281
- } else if (isMoveInMessage(message)) {
2282
- // Handle move-in event: re-activate conditions for matching rows.
2283
- // Buffer if buffering, otherwise process immediately.
2284
- if (isBufferingInitialSync() && !transactionStarted) {
2285
- bufferedMessages.push(message)
2286
- } else {
2287
- processMoveInEvent(message.headers.patterns)
2288
- }
2289
- } else if (isMustRefetchMessage(message)) {
2290
- debug(
2291
- `${collectionId ? `[${collectionId}] ` : ``}Received must-refetch message, starting transaction with truncate`,
2292
- )
2293
-
2294
- commitResetResumeMetadataImmediately()
2295
-
2296
- // Start a transaction and truncate the collection
2437
+ bufferedMessages.push(message)
2438
+ } else {
2439
+ // Normal processing: write changes immediately
2297
2440
  if (!transactionStarted) {
2298
2441
  beginSourceTransaction()
2299
2442
  transactionStarted = true
2300
2443
  }
2301
2444
 
2302
- truncate()
2445
+ processChangeMessage(message)
2446
+ }
2447
+ } else if (isSnapshotEndMessage(message)) {
2448
+ // Track postgres snapshot metadata for resolving awaiting mutations
2449
+ // Skip during buffered initial sync (will be extracted during atomic swap)
2450
+ // EXCEPTION: If a transaction is already started (e.g., from must-refetch), track snapshots
2451
+ // to avoid losing them when messages are written to the existing transaction.
2452
+ if (!isBufferingInitialSync() || transactionStarted) {
2453
+ newSnapshots.push(parseSnapshotMessage(message))
2454
+ }
2455
+ } else if (isUpToDateMessage(message)) {
2456
+ // up-to-date takes precedence - also triggers progressive mode atomic swap
2457
+ commitPoint = `up-to-date`
2458
+ } else if (isSubsetEndMessage(message)) {
2459
+ // subset-end triggers commit but not progressive mode atomic swap
2460
+ if (commitPoint !== `up-to-date`) {
2461
+ commitPoint = `subset-end`
2462
+ }
2463
+ } else if (isMoveOutMessage(message)) {
2464
+ // Handle move-out event: buffer if buffering, otherwise process immediately
2465
+ // EXCEPTION: If a transaction is already started (e.g., from must-refetch), process
2466
+ // immediately to avoid orphan transactions.
2467
+ if (isBufferingInitialSync() && !transactionStarted) {
2468
+ bufferedMessages.push(message)
2469
+ } else {
2470
+ // Normal processing: process move-out immediately
2471
+ transactionStarted = processMoveOutEvent(
2472
+ message.headers.patterns,
2473
+ beginSourceTransaction,
2474
+ write,
2475
+ transactionStarted,
2476
+ (rowId) => {
2477
+ recordPendingPresence(rowId, false)
2478
+ syncedKeys.delete(rowId)
2479
+ },
2480
+ )
2481
+ }
2482
+ } else if (isMoveInMessage(message)) {
2483
+ // Handle move-in event: re-activate conditions for matching rows.
2484
+ // Buffer if buffering, otherwise process immediately.
2485
+ if (isBufferingInitialSync() && !transactionStarted) {
2486
+ bufferedMessages.push(message)
2487
+ } else {
2488
+ processMoveInEvent(message.headers.patterns)
2489
+ }
2490
+ } else if (isMustRefetchMessage(message)) {
2491
+ if (hasReceivedUpToDate) resetFullSnapshot()
2492
+ debug(
2493
+ `${collectionId ? `[${collectionId}] ` : ``}Received must-refetch message, starting transaction with truncate`,
2494
+ )
2303
2495
 
2304
- // Clear tag tracking state
2305
- clearTagTrackingState()
2496
+ commitResetResumeMetadataImmediately()
2306
2497
 
2307
- // Clear synced keys tracking since we're starting fresh
2308
- syncedKeys.clear()
2309
- recordPendingTruncate()
2310
- isResettingSnapshot = true
2311
- resetGeneration++
2498
+ // Start a transaction and truncate the collection
2499
+ if (!transactionStarted) {
2500
+ beginSourceTransaction()
2501
+ transactionStarted = true
2502
+ }
2312
2503
 
2313
- // Reset the loadSubset deduplication state since we're starting fresh
2314
- // This ensures that previously loaded predicates don't prevent refetching after truncate
2315
- loadSubsetDedupe?.reset()
2504
+ truncate()
2316
2505
 
2317
- // Reset flags so we continue accumulating changes until next up-to-date
2318
- commitPoint = null
2319
- hasReceivedUpToDate = false // Reset for progressive mode (isBufferingInitialSync will reflect this)
2320
- bufferedMessages.length = 0 // Clear buffered messages
2321
- progressiveSnapshotRows.clear()
2322
- }
2506
+ // Clear tag tracking state
2507
+ clearTagTrackingState()
2508
+
2509
+ // Clear synced keys tracking since we're starting fresh
2510
+ syncedKeys.clear()
2511
+ recordPendingTruncate()
2512
+ isResettingSnapshot = true
2513
+ resetGeneration++
2514
+
2515
+ // Reset the loadSubset deduplication state since we're starting fresh
2516
+ // This ensures that previously loaded predicates don't prevent refetching after truncate
2517
+ loadSubsetDedupe?.reset()
2518
+
2519
+ // Reset flags so we continue accumulating changes until next up-to-date
2520
+ commitPoint = null
2521
+ hasReceivedUpToDate = false // Reset for progressive mode (isBufferingInitialSync will reflect this)
2522
+ bufferedMessages.length = 0 // Clear buffered messages
2523
+ progressiveSnapshotRows.clear()
2323
2524
  }
2525
+ }
2526
+
2527
+ // A subset completion cannot publish a partial cold-recovery snapshot.
2528
+ if (
2529
+ requiresFreshSourceEvidence &&
2530
+ !scopedRecovery &&
2531
+ isResettingSnapshot &&
2532
+ commitPoint === `subset-end`
2533
+ )
2534
+ return
2324
2535
 
2325
- // A subset completion cannot publish a partial cold-recovery snapshot.
2536
+ if (commitPoint !== null) {
2537
+ let applied: SyncAppliedReceipt = true
2538
+ const wasBufferingInitialSync = isBufferingInitialSync()
2539
+ const finishesReset =
2540
+ isResettingSnapshot && commitPoint === `up-to-date`
2541
+ const finishingResetGeneration = resetGeneration
2542
+ // PROGRESSIVE MODE: Atomic swap on first up-to-date (not subset-end)
2543
+ // EXCEPTION: Skip atomic swap if a transaction is already started (e.g., from must-refetch).
2544
+ // In that case, do a normal commit to properly close the existing transaction.
2326
2545
  if (
2327
- needsFullSnapshot &&
2328
- isResettingSnapshot &&
2329
- commitPoint === `subset-end`
2330
- )
2331
- return
2546
+ isBufferingInitialSync() &&
2547
+ commitPoint === `up-to-date` &&
2548
+ !transactionStarted
2549
+ ) {
2550
+ debug(
2551
+ `${collectionId ? `[${collectionId}] ` : ``}Progressive mode: Performing atomic swap with ${bufferedMessages.length} buffered messages`,
2552
+ )
2332
2553
 
2333
- if (commitPoint !== null) {
2334
- let applied: SyncAppliedReceipt = true
2335
- const wasBufferingInitialSync = isBufferingInitialSync()
2336
- const finishesReset =
2337
- isResettingSnapshot && commitPoint === `up-to-date`
2338
- const finishingResetGeneration = resetGeneration
2339
- // PROGRESSIVE MODE: Atomic swap on first up-to-date (not subset-end)
2340
- // EXCEPTION: Skip atomic swap if a transaction is already started (e.g., from must-refetch).
2341
- // In that case, do a normal commit to properly close the existing transaction.
2342
- if (
2343
- isBufferingInitialSync() &&
2344
- commitPoint === `up-to-date` &&
2345
- !transactionStarted
2346
- ) {
2347
- debug(
2348
- `${collectionId ? `[${collectionId}] ` : ``}Progressive mode: Performing atomic swap with ${bufferedMessages.length} buffered messages`,
2349
- )
2554
+ // Start atomic swap transaction
2555
+ beginSourceTransaction()
2350
2556
 
2351
- // Start atomic swap transaction
2352
- beginSourceTransaction()
2557
+ // Truncate to clear all snapshot data
2558
+ truncate()
2353
2559
 
2354
- // Truncate to clear all snapshot data
2355
- truncate()
2356
-
2357
- // Clear tag tracking state for atomic swap
2358
- clearTagTrackingState()
2359
-
2360
- // Clear synced keys tracking for atomic swap
2361
- syncedKeys.clear()
2362
-
2363
- // Apply all buffered change messages and extract txids/snapshots
2364
- for (const bufferedMsg of bufferedMessages) {
2365
- if (isChangeMessage(bufferedMsg)) {
2366
- processChangeMessage(bufferedMsg)
2367
-
2368
- // Extract txids from buffered messages (will be committed to store after transaction)
2369
- if (hasTxids(bufferedMsg)) {
2370
- bufferedMsg.headers.txids?.forEach((txid) =>
2371
- newTxids.add(txid),
2372
- )
2373
- }
2374
- } else if (isSnapshotEndMessage(bufferedMsg)) {
2375
- // Extract snapshots from buffered messages (will be committed to store after transaction)
2376
- newSnapshots.push(parseSnapshotMessage(bufferedMsg))
2377
- } else if (isMoveOutMessage(bufferedMsg)) {
2378
- // Process buffered move-out messages during atomic swap
2379
- processMoveOutEvent(
2380
- bufferedMsg.headers.patterns,
2381
- begin,
2382
- write,
2383
- // The swap already opened a transaction, even though the
2384
- // normal-stream transactionStarted flag is still false.
2385
- true,
2386
- (rowId) => {
2387
- recordPendingPresence(rowId, false)
2388
- syncedKeys.delete(rowId)
2389
- },
2390
- )
2391
- } else if (isMoveInMessage(bufferedMsg)) {
2392
- // Process buffered move-in messages during atomic swap
2393
- processMoveInEvent(bufferedMsg.headers.patterns)
2560
+ // Clear tag tracking state for atomic swap
2561
+ clearTagTrackingState()
2562
+
2563
+ // Clear synced keys tracking for atomic swap
2564
+ syncedKeys.clear()
2565
+
2566
+ // Apply all buffered change messages and extract txids/snapshots
2567
+ for (const bufferedMsg of bufferedMessages) {
2568
+ if (isChangeMessage(bufferedMsg)) {
2569
+ processChangeMessage(bufferedMsg)
2570
+
2571
+ // Extract txids from buffered messages (will be committed to store after transaction)
2572
+ if (hasTxids(bufferedMsg)) {
2573
+ bufferedMsg.headers.txids?.forEach((txid) => newTxids.add(txid))
2394
2574
  }
2575
+ } else if (isSnapshotEndMessage(bufferedMsg)) {
2576
+ // Extract snapshots from buffered messages (will be committed to store after transaction)
2577
+ newSnapshots.push(parseSnapshotMessage(bufferedMsg))
2578
+ } else if (isMoveOutMessage(bufferedMsg)) {
2579
+ // Process buffered move-out messages during atomic swap
2580
+ processMoveOutEvent(
2581
+ bufferedMsg.headers.patterns,
2582
+ begin,
2583
+ write,
2584
+ // The swap already opened a transaction, even though the
2585
+ // normal-stream transactionStarted flag is still false.
2586
+ true,
2587
+ (rowId) => {
2588
+ recordPendingPresence(rowId, false)
2589
+ syncedKeys.delete(rowId)
2590
+ },
2591
+ )
2592
+ } else if (isMoveInMessage(bufferedMsg)) {
2593
+ // Process buffered move-in messages during atomic swap
2594
+ processMoveInEvent(bufferedMsg.headers.patterns)
2395
2595
  }
2596
+ }
2396
2597
 
2397
- // Commit the atomic swap
2398
- stageResumeMetadata()
2399
- applied = commitSourceTransaction()
2598
+ // Commit the atomic swap
2599
+ stageResumeMetadata()
2600
+ applied = commitSourceTransaction()
2400
2601
 
2401
- // Exit buffering phase by marking that we've received up-to-date
2402
- // isBufferingInitialSync() will now return false
2403
- bufferedMessages.length = 0
2404
- progressiveSnapshotRows.clear()
2602
+ // Exit buffering phase by marking that we've received up-to-date
2603
+ // isBufferingInitialSync() will now return false
2604
+ bufferedMessages.length = 0
2605
+ progressiveSnapshotRows.clear()
2405
2606
 
2406
- debug(
2407
- `${collectionId ? `[${collectionId}] ` : ``}Progressive mode: Atomic swap complete, now in normal sync mode`,
2408
- )
2409
- } else {
2410
- // Normal mode or on-demand: commit transaction if one was started
2411
- // Both up-to-date and subset-end trigger a commit
2412
- if (transactionStarted) {
2413
- if (!isResettingSnapshot || finishesReset) {
2414
- stageResumeMetadata()
2415
- }
2416
- applied = commitSourceTransaction()
2417
- transactionStarted = false
2418
- } else if (commitPoint === `up-to-date` && metadata) {
2419
- beginSourceTransaction()
2607
+ debug(
2608
+ `${collectionId ? `[${collectionId}] ` : ``}Progressive mode: Atomic swap complete, now in normal sync mode`,
2609
+ )
2610
+ } else {
2611
+ // Normal mode or on-demand: commit transaction if one was started
2612
+ // Both up-to-date and subset-end trigger a commit
2613
+ if (transactionStarted) {
2614
+ if (!isResettingSnapshot || finishesReset) {
2420
2615
  stageResumeMetadata()
2421
- applied = commitSourceTransaction()
2422
2616
  }
2617
+ applied = commitSourceTransaction()
2618
+ transactionStarted = false
2619
+ } else if (commitPoint === `up-to-date` && metadata) {
2620
+ beginSourceTransaction()
2621
+ stageResumeMetadata()
2622
+ applied = commitSourceTransaction()
2423
2623
  }
2424
- const readyErrorVersion = streamErrorVersion
2425
- // Readiness counts accepted rows.
2426
- const accepted = whenSyncAccepted(applied)
2427
- if (accepted === true) {
2428
- wrappedMarkReady(wasBufferingInitialSync, readyErrorVersion)
2429
- } else {
2430
- void accepted.then(
2431
- () =>
2432
- wrappedMarkReady(wasBufferingInitialSync, readyErrorVersion),
2433
- () => undefined,
2434
- )
2435
- }
2436
- if (applied !== true) {
2437
- void applied.then(
2438
- () => undefined,
2439
- (error: unknown) => {
2440
- if (!isActiveLifecycle() || abortController.signal.aborted) {
2441
- return
2442
- }
2443
- streamErrorVersion++
2444
- unsubscribeStream()
2445
- abortController.abort()
2446
- if (collection.status !== `error`) markError(error)
2447
- },
2448
- )
2449
- }
2450
-
2451
- if (finishesReset) {
2452
- const finishReset = () => {
2453
- if (resetGeneration === finishingResetGeneration) {
2454
- isResettingSnapshot = false
2624
+ }
2625
+ const readyErrorVersion = streamErrorVersion
2626
+ // Readiness counts accepted rows.
2627
+ const accepted = whenSyncAccepted(applied)
2628
+ const firstSnapshot =
2629
+ commitPoint === `up-to-date` && collection.status === `loading`
2630
+ if (commitPoint === `up-to-date`) resolveFullSnapshot(applied)
2631
+ if (accepted === true) {
2632
+ wrappedMarkReady(wasBufferingInitialSync, readyErrorVersion)
2633
+ } else if (firstSnapshot) {
2634
+ firstSnapshotCommitted = true
2635
+ void accepted.then(
2636
+ () => settleFirstSnapshot(wasBufferingInitialSync),
2637
+ (error: unknown) =>
2638
+ settleFirstSnapshot(wasBufferingInitialSync, error),
2639
+ )
2640
+ } else {
2641
+ void accepted.then(
2642
+ () => wrappedMarkReady(wasBufferingInitialSync, readyErrorVersion),
2643
+ () => undefined,
2644
+ )
2645
+ }
2646
+ if (applied !== true) {
2647
+ void applied.then(
2648
+ () => undefined,
2649
+ (error: unknown) => {
2650
+ if (!isActiveLifecycle() || abortController.signal.aborted) {
2651
+ return
2455
2652
  }
2456
- }
2457
- if (applied === true) {
2458
- finishReset()
2459
- } else {
2460
- void applied.then(finishReset, () => undefined)
2653
+ streamErrorVersion++
2654
+ unsubscribeStream()
2655
+ abortController.abort()
2656
+ if (collection.status !== `error`) markError(error)
2657
+ },
2658
+ )
2659
+ }
2660
+
2661
+ if (finishesReset) {
2662
+ const finishReset = () => {
2663
+ if (resetGeneration === finishingResetGeneration) {
2664
+ isResettingSnapshot = false
2461
2665
  }
2462
2666
  }
2463
-
2464
- // Track that we've received the first up-to-date for progressive mode
2465
- if (commitPoint === `up-to-date`) {
2466
- hasReceivedUpToDate = true
2667
+ if (applied === true) {
2668
+ finishReset()
2669
+ } else {
2670
+ void applied.then(finishReset, () => undefined)
2467
2671
  }
2672
+ }
2468
2673
 
2469
- // Stream evidence is the acknowledgement boundary used by mutation
2470
- // handlers. It must publish before a parked applied receipt or the
2471
- // optimistic transaction and its acknowledgement can deadlock.
2472
- if (newTxids.size > 0) {
2473
- debug(
2474
- `${collectionId ? `[${collectionId}] ` : ``}new txids synced from pg %O`,
2475
- Array.from(newTxids),
2476
- )
2477
- }
2478
- newSnapshots.forEach((snapshot) =>
2479
- debug(
2480
- `${collectionId ? `[${collectionId}] ` : ``}new snapshot synced from pg %o`,
2481
- snapshot,
2482
- ),
2674
+ // Track that we've received the first up-to-date for progressive mode
2675
+ if (commitPoint === `up-to-date`) {
2676
+ hasReceivedUpToDate = true
2677
+ }
2678
+
2679
+ // Stream evidence is the acknowledgement boundary used by mutation
2680
+ // handlers. It must publish before a parked applied receipt or the
2681
+ // optimistic transaction and its acknowledgement can deadlock.
2682
+ if (newTxids.size > 0) {
2683
+ debug(
2684
+ `${collectionId ? `[${collectionId}] ` : ``}new txids synced from pg %O`,
2685
+ Array.from(newTxids),
2483
2686
  )
2484
- lifecycle.publishEvidence(newTxids, newSnapshots)
2485
- newTxids.clear()
2486
- newSnapshots.length = 0
2487
- lifecycle.commitMatches()
2488
2687
  }
2688
+ newSnapshots.forEach((snapshot) =>
2689
+ debug(
2690
+ `${collectionId ? `[${collectionId}] ` : ``}new snapshot synced from pg %o`,
2691
+ snapshot,
2692
+ ),
2693
+ )
2694
+ lifecycle.publishEvidence(newTxids, newSnapshots)
2695
+ newTxids.clear()
2696
+ newSnapshots.length = 0
2697
+ lifecycle.commitMatches()
2489
2698
  }
2699
+ }
2490
2700
 
2491
- unsubscribeStream = stream.subscribe((messages: Array<Message<T>>) => {
2701
+ unsubscribeStream = stream.subscribe(
2702
+ (messages: Array<Message<T>>) => {
2492
2703
  if (!areResumeKeysReady) {
2493
2704
  pendingResumeBatches.push([...messages])
2494
2705
  return
2495
2706
  }
2496
2707
  processMessages(messages)
2497
- })
2498
-
2499
- if (!areResumeKeysReady && resumeKeysPromise) {
2500
- void resumeKeysPromise.then(
2501
- () => {
2502
- if (abortController.signal.aborted) return
2503
-
2504
- areResumeKeysReady = true
2505
-
2506
- const queuedBatches = pendingResumeBatches.splice(0)
2507
- queuedBatches.forEach(processMessages)
2508
- },
2509
- (error: unknown) => {
2510
- if (abortController.signal.aborted) return
2511
-
2512
- pendingResumeBatches.length = 0
2513
- resumeInvalid = true
2514
- commitResetResumeMetadataImmediately()
2515
- streamErrorVersion++
2516
- unsubscribeStream()
2517
- abortController.abort()
2518
- markError(error)
2519
- },
2708
+ },
2709
+ (error) => {
2710
+ // The SDK gave up: it rejected the retry decision or exhausted its
2711
+ // retry loop. No provider batch will settle the gate, and before the
2712
+ // first ready this is the source's final initial failure.
2713
+ rejectFullSnapshot(error)
2714
+ if (
2715
+ isActiveLifecycle() &&
2716
+ !abortController.signal.aborted &&
2717
+ collection.status === `loading`
2520
2718
  )
2719
+ failInitialSync(error)
2720
+ },
2721
+ )
2722
+
2723
+ if (!areResumeKeysReady && resumeKeysPromise) {
2724
+ void resumeKeysPromise.then(
2725
+ () => {
2726
+ if (abortController.signal.aborted) return
2727
+
2728
+ areResumeKeysReady = true
2729
+
2730
+ const queuedBatches = pendingResumeBatches.splice(0)
2731
+ queuedBatches.forEach(processMessages)
2732
+ },
2733
+ (error: unknown) => {
2734
+ if (abortController.signal.aborted) return
2735
+
2736
+ pendingResumeBatches.length = 0
2737
+ resumeInvalid = true
2738
+ rejectFullSnapshot(error)
2739
+ commitResetResumeMetadataImmediately()
2740
+ streamErrorVersion++
2741
+ unsubscribeStream()
2742
+ abortController.abort()
2743
+ markError(error)
2744
+ },
2745
+ )
2746
+ }
2747
+
2748
+ // Return the deduplicated loadSubset if available (on-demand or progressive mode)
2749
+ // The loadSubset method is auto-bound, so it can be safely returned directly
2750
+ return {
2751
+ loadSubset: loadSubsetDedupe?.loadSubset,
2752
+ cleanup: () => {
2753
+ sessionCleanedUp = true
2754
+ if (!abortController.signal.aborted && !initialSyncFailedFinally)
2755
+ cleanupAbortedStream = true
2756
+ shapeOptions.signal?.removeEventListener(`abort`, forwardExternalAbort)
2757
+ // Unsubscribe from the stream
2758
+ unsubscribeStream()
2759
+ // Abort the abort controller to stop the stream
2760
+ abortController.abort()
2761
+ if (transactionStarted) {
2762
+ transactionStarted = false
2763
+ const cancellation = new AbortController()
2764
+ cancellation.abort()
2765
+ const abandoned = commit(cancellation.signal)
2766
+ if (abandoned !== true) void abandoned.catch(() => undefined)
2767
+ }
2768
+ pendingResumeBatches.length = 0
2769
+ // Reset deduplication tracking so collection can load fresh data if restarted
2770
+ loadSubsetDedupe?.reset()
2771
+ lifecycle.retire(lifecycleEpoch)
2772
+ },
2773
+ }
2774
+ }
2775
+
2776
+ return {
2777
+ getSyncMetadata: () => ({
2778
+ relation: shapeOptions.params?.table
2779
+ ? [relationSchema || `public`, shapeOptions.params.table]
2780
+ : undefined,
2781
+ }),
2782
+ sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => {
2783
+ if (
2784
+ syncMode !== `on-demand` ||
2785
+ !params.metadata?.persistence?.managedCacheGeneration
2786
+ ) {
2787
+ return startSession(params)
2788
+ }
2789
+ let session = startSession(params)
2790
+ let retireSession!: () => void
2791
+ let retired = new Promise<void>((resolve) => {
2792
+ retireSession = resolve
2793
+ })
2794
+ let restartInFlight: Promise<void> | undefined
2795
+ let restartFailure: { error: unknown } | undefined
2796
+ let restartSequence = 0
2797
+ let sessionCleaned = false
2798
+ let cleanedUp = false
2799
+ const isCleanedUp = () => cleanedUp
2800
+ const cleanupCurrentSession = (): Promise<void> => {
2801
+ if (sessionCleaned) return Promise.resolve()
2802
+ sessionCleaned = true
2803
+ try {
2804
+ return Promise.resolve(session.cleanup())
2805
+ } catch (error) {
2806
+ return Promise.reject(error)
2807
+ }
2808
+ }
2809
+ const trackRestart = (work: Promise<void>): Promise<void> => {
2810
+ const tracked = work
2811
+ .catch((error: unknown) => {
2812
+ restartFailure = { error }
2813
+ throw error
2814
+ })
2815
+ .finally(() => {
2816
+ if (restartInFlight === tracked) restartInFlight = undefined
2817
+ })
2818
+ restartInFlight = tracked
2819
+ return tracked
2820
+ }
2821
+ const startReplacement = (sequence: number): void => {
2822
+ if (isCleanedUp() || sequence !== restartSequence) return
2823
+ session = startSession(params, true)
2824
+ sessionCleaned = false
2825
+ retired = new Promise<void>((resolve) => {
2826
+ retireSession = resolve
2827
+ })
2521
2828
  }
2522
2829
 
2523
- // Return the deduplicated loadSubset if available (on-demand or progressive mode)
2524
- // The loadSubset method is auto-bound, so it can be safely returned directly
2525
2830
  return {
2526
- loadSubset: loadSubsetDedupe?.loadSubset,
2527
- cleanup: () => {
2528
- shapeOptions.signal?.removeEventListener(
2529
- `abort`,
2530
- forwardExternalAbort,
2831
+ loadSubset: async (demand: LoadSubsetOptions) => {
2832
+ while (!isCleanedUp()) {
2833
+ if (restartFailure) throw restartFailure.error
2834
+ if (demand.signal?.aborted) {
2835
+ throw (
2836
+ demand.signal.reason ?? new LoadSubsetOperationAbortedError()
2837
+ )
2838
+ }
2839
+ const current = session
2840
+ const currentRetired = retired
2841
+ const result = current.loadSubset?.(demand) ?? true
2842
+ const outcome = await Promise.race([
2843
+ Promise.resolve(result).then(() => `settled` as const),
2844
+ currentRetired.then(() => `retired` as const),
2845
+ ])
2846
+ if (outcome === `settled`) return
2847
+ if (restartInFlight) {
2848
+ await waitForDemandOrAbort(restartInFlight, demand.signal)
2849
+ }
2850
+ }
2851
+ if (restartFailure) throw restartFailure.error
2852
+ },
2853
+ restartAfterScopedRecovery: (cacheRotated) => {
2854
+ if (isCleanedUp()) return Promise.resolve()
2855
+ if (restartFailure) return Promise.reject(restartFailure.error)
2856
+ const sequence = ++restartSequence
2857
+ retireSession()
2858
+ // Every rotation retires the session currently subscribed to the
2859
+ // SDK, including a replacement started by an earlier rotation.
2860
+ const oldSessionCleanup = cleanupCurrentSession()
2861
+ if (restartInFlight) {
2862
+ const previousRestart = restartInFlight
2863
+ return trackRestart(
2864
+ (async () => {
2865
+ await Promise.all([previousRestart, oldSessionCleanup])
2866
+ await cacheRotated
2867
+ startReplacement(sequence)
2868
+ })(),
2869
+ )
2870
+ }
2871
+ return trackRestart(
2872
+ (async () => {
2873
+ await oldSessionCleanup
2874
+ await cacheRotated
2875
+ startReplacement(sequence)
2876
+ })(),
2531
2877
  )
2532
- // Unsubscribe from the stream
2533
- unsubscribeStream()
2534
- // Abort the abort controller to stop the stream
2535
- abortController.abort()
2536
- pendingResumeBatches.length = 0
2537
- // Reset deduplication tracking so collection can load fresh data if restarted
2538
- loadSubsetDedupe?.reset()
2539
- lifecycle.retire(lifecycleEpoch)
2878
+ },
2879
+ cleanup: async () => {
2880
+ cleanedUp = true
2881
+ retireSession()
2882
+ try {
2883
+ await restartInFlight
2884
+ } catch (error) {
2885
+ // Persistence rejects a held rotation when this cleanup replaces
2886
+ // its lifecycle. That cancellation belongs to teardown.
2887
+ if (!(error instanceof SyncTransactionAbortedError)) throw error
2888
+ }
2889
+ await cleanupCurrentSession()
2540
2890
  },
2541
2891
  }
2542
2892
  },