@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/dist/cjs/electric.cjs +819 -573
- package/dist/cjs/electric.cjs.map +1 -1
- package/dist/cjs/electric.d.cts +2 -0
- package/dist/esm/electric.d.ts +2 -0
- package/dist/esm/electric.js +820 -574
- package/dist/esm/electric.js.map +1 -1
- package/package.json +2 -2
- package/src/electric.ts +1155 -805
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
|
-
|
|
694
|
-
|
|
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
|
-
|
|
757
|
-
|
|
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
|
|
794
|
-
|
|
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
|
|
871
|
+
await requestSnapshotInOrder(snapshotParams)
|
|
800
872
|
}
|
|
801
873
|
} catch (error) {
|
|
802
|
-
if (opts.signal?.aborted)
|
|
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
|
-
|
|
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
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
const
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
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
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
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
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
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
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
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 (
|
|
1808
|
-
|
|
1809
|
-
|
|
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
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1919
|
+
} else {
|
|
1920
|
+
// No hook, not buffering, or already past first up-to-date
|
|
1921
|
+
markReady()
|
|
1814
1922
|
}
|
|
1923
|
+
}
|
|
1815
1924
|
|
|
1816
|
-
|
|
1817
|
-
|
|
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
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
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
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
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
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
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
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
2074
|
+
const recordPendingTruncate = () => {
|
|
2075
|
+
pendingPresenceRevision++
|
|
2076
|
+
pendingPresence.clear()
|
|
2077
|
+
progressiveSnapshotRows.clear()
|
|
2078
|
+
usesBaseline = false
|
|
2079
|
+
}
|
|
1915
2080
|
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
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
|
-
|
|
2091
|
+
if (applied === true) retireAppliedPresence()
|
|
2092
|
+
else void applied.then(retireAppliedPresence, () => undefined)
|
|
1930
2093
|
}
|
|
2094
|
+
return applied
|
|
2095
|
+
}
|
|
1931
2096
|
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
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
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
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
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
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
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
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
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
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
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
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
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
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
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
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
|
-
|
|
2014
|
-
|
|
2208
|
+
// Extract active_conditions from headers (DNF support)
|
|
2209
|
+
const activeConditions = changeMessage.headers.active_conditions as
|
|
2210
|
+
ActiveConditions | undefined
|
|
2015
2211
|
|
|
2016
|
-
|
|
2017
|
-
|
|
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
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
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
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
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
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
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
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
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
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
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
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2329
|
+
const processMessages = (messages: Array<Message<T>>): void => {
|
|
2330
|
+
if (!isActiveLifecycle() || resumeInvalid) {
|
|
2331
|
+
return
|
|
2332
|
+
}
|
|
2132
2333
|
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
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
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
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
|
|
2163
|
-
|
|
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
|
-
|
|
2173
|
-
|
|
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
|
-
|
|
2376
|
+
lifecycle.beginMatchGeneration(messages)
|
|
2176
2377
|
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
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
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
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
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
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
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
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
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
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
|
-
|
|
2247
|
-
|
|
2248
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
2305
|
-
clearTagTrackingState()
|
|
2496
|
+
commitResetResumeMetadataImmediately()
|
|
2306
2497
|
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2498
|
+
// Start a transaction and truncate the collection
|
|
2499
|
+
if (!transactionStarted) {
|
|
2500
|
+
beginSourceTransaction()
|
|
2501
|
+
transactionStarted = true
|
|
2502
|
+
}
|
|
2312
2503
|
|
|
2313
|
-
|
|
2314
|
-
// This ensures that previously loaded predicates don't prevent refetching after truncate
|
|
2315
|
-
loadSubsetDedupe?.reset()
|
|
2504
|
+
truncate()
|
|
2316
2505
|
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
)
|
|
2331
|
-
|
|
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
|
-
|
|
2334
|
-
|
|
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
|
-
|
|
2352
|
-
|
|
2557
|
+
// Truncate to clear all snapshot data
|
|
2558
|
+
truncate()
|
|
2353
2559
|
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
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
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2598
|
+
// Commit the atomic swap
|
|
2599
|
+
stageResumeMetadata()
|
|
2600
|
+
applied = commitSourceTransaction()
|
|
2400
2601
|
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
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
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
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
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
()
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
)
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
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
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
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
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2667
|
+
if (applied === true) {
|
|
2668
|
+
finishReset()
|
|
2669
|
+
} else {
|
|
2670
|
+
void applied.then(finishReset, () => undefined)
|
|
2467
2671
|
}
|
|
2672
|
+
}
|
|
2468
2673
|
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
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:
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
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
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
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
|
},
|