@tanstack/electric-db-collection 0.4.7 → 0.4.10

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
@@ -4,12 +4,14 @@ import {
4
4
  isControlMessage,
5
5
  isVisibleInSnapshot,
6
6
  } from '@electric-sql/client'
7
- import { Store } from '@tanstack/store'
8
7
  import DebugModule from 'debug'
9
8
  import {
10
9
  DeduplicatedLoadSubset,
10
+ LoadSubsetOperationAbortedError,
11
11
  and,
12
12
  withCollectionConfigFactory,
13
+ withCollectionSyncConfigCleanup,
14
+ withCollectionSyncConfigFactory,
13
15
  } from '@tanstack/db'
14
16
  import {
15
17
  ExpectedNumberInAwaitTxIdError,
@@ -49,6 +51,7 @@ import type {
49
51
  LoadSubsetOptions,
50
52
  SyncAppliedReceipt,
51
53
  SyncConfig,
54
+ SyncMetadataApi,
52
55
  SyncMode,
53
56
  UpdateMutationFnParams,
54
57
  UtilsRecord,
@@ -64,6 +67,14 @@ import type {
64
67
  ShapeStreamOptions,
65
68
  } from '@electric-sql/client'
66
69
 
70
+ type ElectricSyncMetadataWithHydration = SyncMetadataApi<string | number> & {
71
+ row: SyncMetadataApi<string | number>[`row`] & {
72
+ whenHydrated?: () => Promise<void>
73
+ // Capability marker for wrappers predating the hydration barrier.
74
+ scanPersisted?: unknown
75
+ }
76
+ }
77
+
67
78
  // Re-export for user convenience in custom match functions
68
79
  export { isChangeMessage, isControlMessage } from '@electric-sql/client'
69
80
 
@@ -99,6 +110,7 @@ type ElectricResumeState =
99
110
  handle: string
100
111
  shapeId: string
101
112
  updatedAt: number
113
+ requiresTagState?: boolean
102
114
  }
103
115
  | {
104
116
  kind: `reset`
@@ -111,6 +123,47 @@ type ElectricSyncMeta = {
111
123
  seenTxids: Array<Txid>
112
124
  }
113
125
 
126
+ type ElectricLifecycleEvidence = {
127
+ seenTxids: Set<Txid>
128
+ seenSnapshots: Array<PostgresSnapshot>
129
+ hydratedResumeState?: ElectricResumeState
130
+ }
131
+
132
+ type ElectricPendingMatch<T extends Row<unknown>> = {
133
+ matchFn: (message: Message<T>) => boolean
134
+ resolve: (value: boolean) => void
135
+ reject: (error: Error) => void
136
+ timeoutId: ReturnType<typeof setTimeout>
137
+ matched: boolean
138
+ }
139
+
140
+ type ElectricMatchBuffer<T extends Row<unknown>> = {
141
+ committedMessages: Array<Message<T>>
142
+ pendingMessages: Array<Message<T>>
143
+ }
144
+
145
+ function exportElectricSyncMeta(
146
+ evidence: ElectricLifecycleEvidence,
147
+ ): ElectricSyncMeta {
148
+ const resume = evidence.hydratedResumeState
149
+ return {
150
+ version: 1,
151
+ ...(resume ? { resume } : {}),
152
+ seenTxids: Array.from(evidence.seenTxids).sort((a, b) => a - b),
153
+ }
154
+ }
155
+
156
+ function importElectricSyncMeta(
157
+ evidence: ElectricLifecycleEvidence,
158
+ meta: unknown,
159
+ ): void {
160
+ const parsed = parseElectricSyncMeta(meta)
161
+ if (!parsed) return
162
+
163
+ evidence.hydratedResumeState = parsed.resume
164
+ evidence.seenTxids = new Set(parsed.seenTxids)
165
+ }
166
+
114
167
  function parseElectricResumeState(
115
168
  value: unknown,
116
169
  ): ElectricResumeState | undefined {
@@ -133,6 +186,9 @@ function parseElectricResumeState(
133
186
  handle: record.handle,
134
187
  shapeId: record.shapeId,
135
188
  updatedAt: record.updatedAt,
189
+ ...(typeof record.requiresTagState === `boolean` && {
190
+ requiresTagState: record.requiresTagState,
191
+ }),
136
192
  }
137
193
  }
138
194
 
@@ -215,7 +271,21 @@ function getNewestElectricResumeState(
215
271
  ): ElectricResumeState | undefined {
216
272
  if (!current) return incoming
217
273
  if (!incoming) return current
218
- return incoming.updatedAt >= current.updatedAt ? incoming : current
274
+ if (incoming.updatedAt > current.updatedAt) return incoming
275
+ if (incoming.updatedAt < current.updatedAt) return current
276
+
277
+ const isSameState =
278
+ current.kind === incoming.kind &&
279
+ (current.kind === `reset` ||
280
+ (incoming.kind === `resume` &&
281
+ current.offset === incoming.offset &&
282
+ current.handle === incoming.handle &&
283
+ current.shapeId === incoming.shapeId))
284
+
285
+ // Equal timestamps have no causal ordering. Preserve an identical state,
286
+ // but collapse every conflict to reset so merge order cannot resurrect an
287
+ // offset that another source has already declared unsafe.
288
+ return isSameState ? current : { kind: `reset`, updatedAt: current.updatedAt }
219
289
  }
220
290
 
221
291
  /**
@@ -564,6 +634,9 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
564
634
  const compileOptions = encodeColumnName ? { encodeColumnName } : undefined
565
635
  const logPrefix = collectionId ? `[${collectionId}] ` : ``
566
636
 
637
+ const abortReason = (abortedSignal: AbortSignal): unknown =>
638
+ abortedSignal.reason ?? new LoadSubsetOperationAbortedError()
639
+
567
640
  /**
568
641
  * Handles errors from snapshot operations. Returns true if the error was
569
642
  * handled (signal aborted during cleanup), false if it should be re-thrown.
@@ -579,7 +652,11 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
579
652
 
580
653
  const loadSubset = async (opts: LoadSubsetOptions) => {
581
654
  const commitCursor = getCommitCursor()
582
- if (opts.signal?.aborted) return
655
+ const throwIfAborted = () => {
656
+ if (signal.aborted) throw abortReason(signal)
657
+ if (opts.signal?.aborted) throw abortReason(opts.signal)
658
+ }
659
+ throwIfAborted()
583
660
 
584
661
  if (isBufferingInitialSync()) {
585
662
  const snapshotParams = compileSQL<T>(opts, compileOptions)
@@ -628,6 +705,18 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
628
705
  // still works.
629
706
  if (stream.isUpToDate) {
630
707
  let timeoutId: ReturnType<typeof setTimeout> | undefined
708
+ const abortSignals = [signal, opts.signal].filter(
709
+ (candidate): candidate is AbortSignal => candidate !== undefined,
710
+ )
711
+ let rejectAbort: (reason: unknown) => void = () => {}
712
+ const aborted = new Promise<never>((_resolve, reject) => {
713
+ rejectAbort = reject
714
+ })
715
+ const abort = (event: Event) =>
716
+ rejectAbort(abortReason(event.currentTarget as AbortSignal))
717
+ for (const abortSignal of abortSignals) {
718
+ abortSignal.addEventListener(`abort`, abort, { once: true })
719
+ }
631
720
  try {
632
721
  await Promise.race([
633
722
  stream.forceDisconnectAndRefresh(),
@@ -637,8 +726,10 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
637
726
  FORCE_DISCONNECT_AND_REFRESH_TIMEOUT_MS,
638
727
  )
639
728
  }),
729
+ aborted,
640
730
  ])
641
731
  } catch (error) {
732
+ if (signal.aborted || opts.signal?.aborted) throw error
642
733
  if (handleSnapshotError(error, `forceDisconnectAndRefresh`)) {
643
734
  return
644
735
  }
@@ -648,10 +739,13 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
648
739
  )
649
740
  } finally {
650
741
  clearTimeout(timeoutId)
742
+ for (const abortSignal of abortSignals) {
743
+ abortSignal.removeEventListener(`abort`, abort)
744
+ }
651
745
  }
652
746
  }
653
747
 
654
- if (opts.signal?.aborted) return
748
+ throwIfAborted()
655
749
 
656
750
  // Upstream limitation: ShapeStream.requestSnapshot() publishes its rows
657
751
  // through the stream callback before its Promise resolves. It accepts no
@@ -726,6 +820,244 @@ export interface ElectricCollectionUtils<
726
820
  awaitMatch: AwaitMatchFn<T>
727
821
  }
728
822
 
823
+ /** Owns evidence and pending work for one materialized Collection. */
824
+ class ElectricLifecycle<T extends Row<unknown>> {
825
+ private readonly evidence: ElectricLifecycleEvidence = {
826
+ seenTxids: new Set(),
827
+ seenSnapshots: [],
828
+ }
829
+
830
+ private readonly pendingMatches = new Map<number, ElectricPendingMatch<T>>()
831
+ private readonly pendingTxidWaits = new Map<
832
+ number,
833
+ {
834
+ txId: Txid
835
+ resolve: (value: boolean) => void
836
+ reject: (error: Error) => void
837
+ timeoutId: ReturnType<typeof setTimeout>
838
+ }
839
+ >()
840
+ private matchBuffer: ElectricMatchBuffer<T> = {
841
+ committedMessages: [],
842
+ pendingMessages: [],
843
+ }
844
+ private nextWaiterId = 0
845
+ private epoch = 0
846
+ private active = false
847
+
848
+ constructor(private readonly collectionId?: string) {}
849
+
850
+ readonly utils: ElectricCollectionUtils<T> = {
851
+ awaitTxId: (txId, timeout) => this.awaitTxId(txId, timeout),
852
+ awaitMatch: (matchFn, timeout) => this.awaitMatch(matchFn, timeout),
853
+ }
854
+
855
+ start(): number {
856
+ if (this.active) this.retire()
857
+ this.active = true
858
+ this.epoch++
859
+ this.matchBuffer = { committedMessages: [], pendingMessages: [] }
860
+ return this.epoch
861
+ }
862
+
863
+ isActive(epoch: number): boolean {
864
+ return this.active && this.epoch === epoch
865
+ }
866
+
867
+ retire(epoch?: number): void {
868
+ if (epoch !== undefined && !this.isActive(epoch)) return
869
+ this.active = false
870
+ this.epoch++
871
+
872
+ for (const match of this.pendingMatches.values()) {
873
+ clearTimeout(match.timeoutId)
874
+ match.reject(new StreamAbortedError(this.collectionId))
875
+ }
876
+ this.pendingMatches.clear()
877
+
878
+ for (const waiter of this.pendingTxidWaits.values()) {
879
+ clearTimeout(waiter.timeoutId)
880
+ waiter.reject(new StreamAbortedError(this.collectionId))
881
+ }
882
+ this.pendingTxidWaits.clear()
883
+ this.matchBuffer = { committedMessages: [], pendingMessages: [] }
884
+ this.evidence.hydratedResumeState = undefined
885
+ }
886
+
887
+ exportMeta(): ElectricSyncMeta {
888
+ return exportElectricSyncMeta(this.evidence)
889
+ }
890
+
891
+ importMeta(meta: unknown): void {
892
+ importElectricSyncMeta(this.evidence, meta)
893
+ this.resolveTxidWaiters()
894
+ }
895
+
896
+ get resumeState(): ElectricResumeState | undefined {
897
+ return this.evidence.hydratedResumeState
898
+ }
899
+
900
+ set resumeState(value: ElectricResumeState | undefined) {
901
+ this.evidence.hydratedResumeState = value
902
+ }
903
+
904
+ publishEvidence(
905
+ txids: ReadonlySet<Txid>,
906
+ snapshots: ReadonlyArray<PostgresSnapshot>,
907
+ ): void {
908
+ txids.forEach((txid) => this.evidence.seenTxids.add(txid))
909
+ this.evidence.seenSnapshots.push(...snapshots)
910
+ this.resolveTxidWaiters()
911
+ }
912
+
913
+ private hasTxid(txId: Txid): boolean {
914
+ return (
915
+ this.evidence.seenTxids.has(txId) ||
916
+ this.evidence.seenSnapshots.some((snapshot) =>
917
+ isVisibleInSnapshot(txId, snapshot),
918
+ )
919
+ )
920
+ }
921
+
922
+ private resolveTxidWaiters(): void {
923
+ for (const [waitId, waiter] of this.pendingTxidWaits) {
924
+ if (!this.hasTxid(waiter.txId)) continue
925
+ clearTimeout(waiter.timeoutId)
926
+ this.pendingTxidWaits.delete(waitId)
927
+ waiter.resolve(true)
928
+ }
929
+ }
930
+
931
+ private async awaitTxId(
932
+ txId: Txid,
933
+ timeout: number = 5000,
934
+ ): Promise<boolean> {
935
+ debug(
936
+ `${this.collectionId ? `[${this.collectionId}] ` : ``}awaitTxId called with txid %d`,
937
+ txId,
938
+ )
939
+ if (typeof txId !== `number`) {
940
+ throw new ExpectedNumberInAwaitTxIdError(typeof txId, this.collectionId)
941
+ }
942
+ if (this.hasTxid(txId)) return true
943
+
944
+ return new Promise((resolve, reject) => {
945
+ const waitId = this.nextWaiterId++
946
+ const timeoutId = setTimeout(() => {
947
+ this.pendingTxidWaits.delete(waitId)
948
+ reject(new TimeoutWaitingForTxIdError(txId, this.collectionId))
949
+ }, timeout)
950
+ this.pendingTxidWaits.set(waitId, {
951
+ txId,
952
+ resolve,
953
+ reject,
954
+ timeoutId,
955
+ })
956
+ })
957
+ }
958
+
959
+ private async awaitMatch(
960
+ matchFn: MatchFunction<T>,
961
+ timeout: number = 3000,
962
+ ): Promise<boolean> {
963
+ debug(
964
+ `${this.collectionId ? `[${this.collectionId}] ` : ``}awaitMatch called with custom function`,
965
+ )
966
+
967
+ for (const message of this.matchBuffer.committedMessages) {
968
+ if (!matchFn(message)) continue
969
+ return true
970
+ }
971
+ for (const message of this.matchBuffer.pendingMessages) {
972
+ if (matchFn(message)) return this.registerMatch(matchFn, timeout, true)
973
+ }
974
+ return this.registerMatch(matchFn, timeout, false)
975
+ }
976
+
977
+ private registerMatch(
978
+ matchFn: MatchFunction<T>,
979
+ timeout: number,
980
+ matched: boolean,
981
+ ): Promise<boolean> {
982
+ return new Promise((resolve, reject) => {
983
+ const matchId = this.nextWaiterId++
984
+ const timeoutId = setTimeout(() => {
985
+ this.pendingMatches.delete(matchId)
986
+ reject(new TimeoutWaitingForMatchError(this.collectionId))
987
+ }, timeout)
988
+ this.pendingMatches.set(matchId, {
989
+ matchFn,
990
+ resolve,
991
+ reject,
992
+ timeoutId,
993
+ matched,
994
+ })
995
+ })
996
+ }
997
+
998
+ beginMatchGeneration(messages: ReadonlyArray<Message<T>>): void {
999
+ if (!messages.some(isMustRefetchMessage)) return
1000
+ this.matchBuffer = { committedMessages: [], pendingMessages: [] }
1001
+ for (const match of this.pendingMatches.values()) match.matched = false
1002
+ }
1003
+
1004
+ observeMatchMessage(message: Message<T>): void {
1005
+ if (
1006
+ isChangeMessage(message) ||
1007
+ isMoveOutMessage(message) ||
1008
+ isMoveInMessage(message)
1009
+ ) {
1010
+ this.matchBuffer.pendingMessages.push(message)
1011
+ let overflow =
1012
+ this.matchBuffer.committedMessages.length +
1013
+ this.matchBuffer.pendingMessages.length -
1014
+ 1000
1015
+ if (overflow > 0) {
1016
+ const committedOverflow = Math.min(
1017
+ overflow,
1018
+ this.matchBuffer.committedMessages.length,
1019
+ )
1020
+ this.matchBuffer.committedMessages.splice(0, committedOverflow)
1021
+ overflow -= committedOverflow
1022
+ if (overflow > 0) {
1023
+ this.matchBuffer.pendingMessages.splice(0, overflow)
1024
+ }
1025
+ }
1026
+ }
1027
+
1028
+ const epoch = this.epoch
1029
+ for (const [matchId, match] of this.pendingMatches) {
1030
+ if (match.matched) continue
1031
+ try {
1032
+ match.matched = match.matchFn(message)
1033
+ } catch (error) {
1034
+ clearTimeout(match.timeoutId)
1035
+ this.pendingMatches.delete(matchId)
1036
+ match.reject(error instanceof Error ? error : new Error(String(error)))
1037
+ }
1038
+ // Reentry can register replacement-session waiters in this same Map.
1039
+ if (!this.isActive(epoch)) return
1040
+ }
1041
+ }
1042
+
1043
+ commitMatches(): void {
1044
+ this.matchBuffer.committedMessages.push(...this.matchBuffer.pendingMessages)
1045
+ this.matchBuffer.pendingMessages = []
1046
+ if (this.matchBuffer.committedMessages.length > 1000) {
1047
+ this.matchBuffer.committedMessages.splice(
1048
+ 0,
1049
+ this.matchBuffer.committedMessages.length - 1000,
1050
+ )
1051
+ }
1052
+ for (const [matchId, match] of this.pendingMatches) {
1053
+ if (!match.matched) continue
1054
+ clearTimeout(match.timeoutId)
1055
+ this.pendingMatches.delete(matchId)
1056
+ match.resolve(true)
1057
+ }
1058
+ }
1059
+ }
1060
+
729
1061
  /**
730
1062
  * Creates Electric collection options for use with a standard Collection
731
1063
  *
@@ -779,267 +1111,55 @@ export function electricCollectionOptions<T extends Row<unknown>>(
779
1111
  utils: ElectricCollectionUtils<T>
780
1112
  schema?: any
781
1113
  } {
782
- const seenTxids = new Store<Set<Txid>>(new Set([]))
783
- const seenSnapshots = new Store<Array<PostgresSnapshot>>([])
784
- const hydratedResumeState = new Store<ElectricResumeState | undefined>(
785
- undefined,
786
- )
1114
+ let descriptorLifecycle = new ElectricLifecycle<T>(config.id)
1115
+ let utilityLifecycle = descriptorLifecycle
787
1116
  const internalSyncMode = config.syncMode ?? `eager`
788
1117
  const finalSyncMode =
789
1118
  internalSyncMode === `progressive` ? `on-demand` : internalSyncMode
790
- const pendingMatches = new Store<
791
- Map<
792
- string,
793
- {
794
- matchFn: (message: Message<any>) => boolean
795
- resolve: (value: boolean) => void
796
- reject: (error: Error) => void
797
- timeoutId: ReturnType<typeof setTimeout>
798
- matched: boolean
799
- }
800
- >
801
- >(new Map())
802
-
803
- // Buffer messages since last up-to-date to handle race conditions
804
- const currentBatchMessages = new Store<Array<Message<any>>>([])
805
-
806
- // Track whether the current batch has been committed (up-to-date received)
807
- // This allows awaitMatch to resolve immediately for messages from committed batches
808
- const batchCommitted = new Store<boolean>(false)
809
-
810
- /**
811
- * Helper function to remove multiple matches from the pendingMatches store
812
- */
813
- const removePendingMatches = (matchIds: Array<string>) => {
814
- if (matchIds.length > 0) {
815
- pendingMatches.setState((current) => {
816
- const newMatches = new Map(current)
817
- matchIds.forEach((id) => newMatches.delete(id))
818
- return newMatches
819
- })
820
- }
821
- }
822
-
823
- /**
824
- * Helper function to resolve and cleanup matched pending matches
825
- */
826
- const resolveMatchedPendingMatches = () => {
827
- const matchesToResolve: Array<string> = []
828
- pendingMatches.state.forEach((match, matchId) => {
829
- if (match.matched) {
830
- clearTimeout(match.timeoutId)
831
- match.resolve(true)
832
- matchesToResolve.push(matchId)
833
- debug(
834
- `${config.id ? `[${config.id}] ` : ``}awaitMatch resolved on up-to-date for match %s`,
835
- matchId,
836
- )
837
- }
838
- })
839
- removePendingMatches(matchesToResolve)
840
- }
841
- const sync = createElectricSync<T>(config.shapeOptions, {
842
- seenTxids,
843
- seenSnapshots,
844
- hydratedResumeState,
845
- syncMode: internalSyncMode,
846
- pendingMatches,
847
- currentBatchMessages,
848
- batchCommitted,
849
- removePendingMatches,
850
- resolveMatchedPendingMatches,
851
- collectionId: config.id,
852
- testHooks: config[ELECTRIC_TEST_HOOKS],
853
- })
854
-
855
- /**
856
- * Wait for a specific transaction ID to be synced
857
- * @param txId The transaction ID to wait for as a number
858
- * @param timeout Optional timeout in milliseconds (defaults to 5000ms)
859
- * @returns Promise that resolves when the txId is synced
860
- */
861
- const awaitTxId: AwaitTxIdFn = async (
862
- txId: Txid,
863
- timeout: number = 5000,
864
- ): Promise<boolean> => {
865
- debug(
866
- `${config.id ? `[${config.id}] ` : ``}awaitTxId called with txid %d`,
867
- txId,
868
- )
869
- if (typeof txId !== `number`) {
870
- throw new ExpectedNumberInAwaitTxIdError(typeof txId, config.id)
871
- }
872
-
873
- // First check if the txid is in the seenTxids store
874
- const hasTxid = seenTxids.state.has(txId)
875
- if (hasTxid) return true
876
-
877
- // Then check if the txid is in any of the seen snapshots
878
- const hasSnapshot = seenSnapshots.state.some((snapshot) =>
879
- isVisibleInSnapshot(txId, snapshot),
880
- )
881
- if (hasSnapshot) return true
882
-
883
- return new Promise((resolve, reject) => {
884
- const cleanup = () => {
885
- clearTimeout(timeoutId)
886
- subSeenTxids.unsubscribe()
887
- subSeenSnapshots.unsubscribe()
888
- }
889
-
890
- const timeoutId = setTimeout(() => {
891
- cleanup()
892
- reject(new TimeoutWaitingForTxIdError(txId, config.id))
893
- }, timeout)
894
-
895
- const subSeenTxids = seenTxids.subscribe(() => {
896
- if (seenTxids.state.has(txId)) {
897
- debug(
898
- `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o`,
899
- txId,
900
- )
901
- cleanup()
902
- resolve(true)
903
- }
904
- })
905
-
906
- const subSeenSnapshots = seenSnapshots.subscribe(() => {
907
- const visibleSnapshot = seenSnapshots.state.find((snapshot) =>
908
- isVisibleInSnapshot(txId, snapshot),
909
- )
910
- if (visibleSnapshot) {
911
- debug(
912
- `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o in snapshot %o`,
913
- txId,
914
- visibleSnapshot,
915
- )
916
- cleanup()
917
- resolve(true)
918
- }
919
- })
920
- })
921
- }
922
-
923
- /**
924
- * Wait for a custom match function to find a matching message
925
- * @param matchFn Function that returns true when a message matches
926
- * @param timeout Optional timeout in milliseconds (defaults to 5000ms)
927
- * @returns Promise that resolves when a matching message is found
928
- */
929
- const awaitMatch: AwaitMatchFn<any> = async (
930
- matchFn: MatchFunction<any>,
931
- timeout: number = 3000,
932
- ): Promise<boolean> => {
933
- debug(
934
- `${config.id ? `[${config.id}] ` : ``}awaitMatch called with custom function`,
935
- )
936
-
937
- return new Promise((resolve, reject) => {
938
- const matchId = Math.random().toString(36)
939
-
940
- const cleanupMatch = () => {
941
- pendingMatches.setState((current) => {
942
- const newMatches = new Map(current)
943
- newMatches.delete(matchId)
944
- return newMatches
945
- })
946
- }
947
-
948
- const onTimeout = () => {
949
- cleanupMatch()
950
- reject(new TimeoutWaitingForMatchError(config.id))
951
- }
952
-
953
- const timeoutId = setTimeout(onTimeout, timeout)
954
-
955
- // We need access to the stream messages to check against the match function
956
- // This will be handled by the sync configuration
957
- const checkMatch = (message: Message<any>) => {
958
- if (matchFn(message)) {
959
- debug(
960
- `${config.id ? `[${config.id}] ` : ``}awaitMatch found matching message, waiting for up-to-date`,
961
- )
962
- // Mark as matched but don't resolve yet - wait for up-to-date
963
- pendingMatches.setState((current) => {
964
- const newMatches = new Map(current)
965
- const existing = newMatches.get(matchId)
966
- if (existing) {
967
- newMatches.set(matchId, { ...existing, matched: true })
968
- }
969
- return newMatches
970
- })
971
- return true
972
- }
973
- return false
974
- }
975
-
976
- // Check against current batch messages first to handle race conditions
977
- for (const message of currentBatchMessages.state) {
978
- if (matchFn(message)) {
979
- // If batch is committed (up-to-date already received), resolve immediately
980
- // just like awaitTxId does when it finds a txid in seenTxids
981
- if (batchCommitted.state) {
982
- debug(
983
- `${config.id ? `[${config.id}] ` : ``}awaitMatch found immediate match in committed batch, resolving immediately`,
984
- )
985
- clearTimeout(timeoutId)
986
- resolve(true)
987
- return
988
- }
989
-
990
- // If batch is not yet committed, register match and wait for up-to-date
991
- debug(
992
- `${config.id ? `[${config.id}] ` : ``}awaitMatch found immediate match in current batch, waiting for up-to-date`,
993
- )
994
- pendingMatches.setState((current) => {
995
- const newMatches = new Map(current)
996
- newMatches.set(matchId, {
997
- matchFn: checkMatch,
998
- resolve,
999
- reject,
1000
- timeoutId,
1001
- matched: true, // Already matched, will resolve on up-to-date
1002
- })
1003
- return newMatches
1004
- })
1005
- return
1006
- }
1007
- }
1008
-
1009
- // Store the match function for the sync process to use
1010
- // We'll add this to a pending matches store
1011
- pendingMatches.setState((current) => {
1012
- const newMatches = new Map(current)
1013
- newMatches.set(matchId, {
1014
- matchFn: checkMatch,
1015
- resolve,
1016
- reject,
1017
- timeoutId,
1018
- matched: false,
1019
- })
1020
- return newMatches
1021
- })
1119
+ const boundLifecycles = new WeakMap<object, ElectricLifecycle<T>>()
1120
+ const awaitTxId: AwaitTxIdFn = (txId, timeout) =>
1121
+ utilityLifecycle.utils.awaitTxId(txId, timeout)
1122
+
1123
+ const awaitMatch: AwaitMatchFn<T> = (matchFn, timeout) =>
1124
+ utilityLifecycle.utils.awaitMatch(matchFn, timeout)
1125
+
1126
+ const createSync = () =>
1127
+ createElectricSync<T>(config.shapeOptions, {
1128
+ getLifecycle: (collection) =>
1129
+ boundLifecycles.get(collection) ?? descriptorLifecycle,
1130
+ syncMode: internalSyncMode,
1131
+ collectionId: config.id,
1132
+ testHooks: config[ELECTRIC_TEST_HOOKS],
1022
1133
  })
1023
- }
1134
+ const sync = createSync()
1024
1135
 
1025
1136
  /**
1026
1137
  * Process matching strategy and wait for synchronization
1027
1138
  */
1028
1139
  const processMatchingStrategy = async (
1029
1140
  result: MatchingStrategy,
1141
+ waitForTxId: AwaitTxIdFn,
1030
1142
  ): Promise<void> => {
1031
1143
  // Only wait if result contains txid
1032
1144
  if (result && `txid` in result) {
1033
1145
  const timeout = result.timeout
1034
1146
  // Handle both single txid and array of txids
1035
1147
  if (Array.isArray(result.txid)) {
1036
- await Promise.all(result.txid.map((txid) => awaitTxId(txid, timeout)))
1148
+ await Promise.all(result.txid.map((txid) => waitForTxId(txid, timeout)))
1037
1149
  } else {
1038
- await awaitTxId(result.txid, timeout)
1150
+ await waitForTxId(result.txid, timeout)
1039
1151
  }
1040
1152
  }
1041
1153
  // If result is void/undefined, don't wait - mutation completes immediately
1042
1154
  }
1155
+ const getMutationAwaitTxId = (params: unknown): AwaitTxIdFn => {
1156
+ const collection = (
1157
+ params as {
1158
+ collection?: { utils?: { awaitTxId?: AwaitTxIdFn } }
1159
+ }
1160
+ ).collection
1161
+ return collection?.utils?.awaitTxId ?? awaitTxId
1162
+ }
1043
1163
 
1044
1164
  // Create wrapper handlers for direct persistence operations that handle different matching strategies
1045
1165
  const wrappedOnInsert = config.onInsert
@@ -1051,7 +1171,10 @@ export function electricCollectionOptions<T extends Row<unknown>>(
1051
1171
  >,
1052
1172
  ) => {
1053
1173
  const handlerResult = await config.onInsert!(params)
1054
- await processMatchingStrategy(handlerResult)
1174
+ await processMatchingStrategy(
1175
+ handlerResult,
1176
+ getMutationAwaitTxId(params),
1177
+ )
1055
1178
  return handlerResult
1056
1179
  }
1057
1180
  : undefined
@@ -1065,7 +1188,10 @@ export function electricCollectionOptions<T extends Row<unknown>>(
1065
1188
  >,
1066
1189
  ) => {
1067
1190
  const handlerResult = await config.onUpdate!(params)
1068
- await processMatchingStrategy(handlerResult)
1191
+ await processMatchingStrategy(
1192
+ handlerResult,
1193
+ getMutationAwaitTxId(params),
1194
+ )
1069
1195
  return handlerResult
1070
1196
  }
1071
1197
  : undefined
@@ -1079,7 +1205,10 @@ export function electricCollectionOptions<T extends Row<unknown>>(
1079
1205
  >,
1080
1206
  ) => {
1081
1207
  const handlerResult = await config.onDelete!(params)
1082
- await processMatchingStrategy(handlerResult)
1208
+ await processMatchingStrategy(
1209
+ handlerResult,
1210
+ getMutationAwaitTxId(params),
1211
+ )
1083
1212
  return handlerResult
1084
1213
  }
1085
1214
  : undefined
@@ -1093,37 +1222,67 @@ export function electricCollectionOptions<T extends Row<unknown>>(
1093
1222
  ...restConfig
1094
1223
  } = config
1095
1224
 
1096
- const options = {
1097
- ...restConfig,
1098
- syncMode: finalSyncMode,
1099
- sync: {
1100
- ...sync,
1101
- exportSyncMeta: (): ElectricSyncMeta => ({
1102
- version: 1,
1103
- ...(hydratedResumeState.state
1104
- ? { resume: hydratedResumeState.state }
1105
- : {}),
1106
- seenTxids: Array.from(seenTxids.state).sort((a, b) => a - b),
1107
- }),
1108
- importSyncMeta: (meta: unknown): void => {
1109
- const parsed = parseElectricSyncMeta(meta)
1110
- if (!parsed) {
1111
- return
1225
+ const utilityTemplate: ElectricCollectionUtils<T> = {
1226
+ awaitTxId,
1227
+ awaitMatch,
1228
+ }
1229
+ const consumeDescriptorLifecycle = (): ElectricLifecycle<T> => {
1230
+ const lifecycle = descriptorLifecycle
1231
+ descriptorLifecycle = new ElectricLifecycle<T>(config.id)
1232
+ utilityLifecycle = lifecycle
1233
+ return lifecycle
1234
+ }
1235
+ const createBoundSync = (
1236
+ source: SyncConfig<T>,
1237
+ utilities: object,
1238
+ ): SyncConfig<T> => {
1239
+ const lifecycle = consumeDescriptorLifecycle()
1240
+ let collectionKey: object | undefined
1241
+ Object.assign(utilities, lifecycle.utils)
1242
+
1243
+ const boundSync: SyncConfig<T> = {
1244
+ ...source,
1245
+ sync: (params) => {
1246
+ // A copied materialized config may delegate through an older binding.
1247
+ // The outermost binding owns this collection; inner wrappers only run
1248
+ // the source sync and must not retarget either owner's lifecycle.
1249
+ if (!boundLifecycles.has(params.collection)) {
1250
+ collectionKey = params.collection
1251
+ boundLifecycles.set(params.collection, lifecycle)
1112
1252
  }
1113
-
1114
- hydratedResumeState.setState(() => parsed.resume)
1115
- seenTxids.setState(() => new Set(parsed.seenTxids))
1253
+ return source.sync(params)
1116
1254
  },
1255
+ exportSyncMeta: () => lifecycle.exportMeta(),
1256
+ importSyncMeta: (meta) => lifecycle.importMeta(meta),
1257
+ mergeSyncMeta: mergeElectricSyncMeta,
1258
+ }
1259
+ return withCollectionSyncConfigCleanup(boundSync, () => {
1260
+ lifecycle.retire()
1261
+ if (collectionKey) boundLifecycles.delete(collectionKey)
1262
+ })
1263
+ }
1264
+ const syncTemplate = withCollectionSyncConfigFactory(
1265
+ {
1266
+ ...sync,
1267
+ exportSyncMeta: () => descriptorLifecycle.exportMeta(),
1268
+ importSyncMeta: (meta) => descriptorLifecycle.importMeta(meta),
1117
1269
  mergeSyncMeta: mergeElectricSyncMeta,
1118
1270
  },
1271
+ createBoundSync,
1272
+ )
1273
+ const options = {
1274
+ ...restConfig,
1275
+ syncMode: finalSyncMode,
1276
+ sync: syncTemplate,
1119
1277
  onInsert: wrappedOnInsert,
1120
1278
  onUpdate: wrappedOnUpdate,
1121
1279
  onDelete: wrappedOnDelete,
1122
- utils: {
1123
- awaitTxId,
1124
- awaitMatch,
1125
- },
1280
+ utils: utilityTemplate,
1126
1281
  }
1282
+ Object.defineProperty(options, `utils`, {
1283
+ enumerable: true,
1284
+ get: () => ({ ...utilityTemplate }),
1285
+ })
1127
1286
 
1128
1287
  return withCollectionConfigFactory(options, () =>
1129
1288
  (
@@ -1141,356 +1300,350 @@ function createElectricSync<T extends Row<unknown>>(
1141
1300
  shapeOptions: ShapeStreamOptions<GetExtensions<T>>,
1142
1301
  options: {
1143
1302
  syncMode: ElectricSyncMode
1144
- seenTxids: Store<Set<Txid>>
1145
- seenSnapshots: Store<Array<PostgresSnapshot>>
1146
- hydratedResumeState: Store<ElectricResumeState | undefined>
1147
- pendingMatches: Store<
1148
- Map<
1149
- string,
1150
- {
1151
- matchFn: (message: Message<T>) => boolean
1152
- resolve: (value: boolean) => void
1153
- reject: (error: Error) => void
1154
- timeoutId: ReturnType<typeof setTimeout>
1155
- matched: boolean
1156
- }
1157
- >
1158
- >
1159
- currentBatchMessages: Store<Array<Message<T>>>
1160
- batchCommitted: Store<boolean>
1161
- removePendingMatches: (matchIds: Array<string>) => void
1162
- resolveMatchedPendingMatches: () => void
1303
+ getLifecycle: (collection: object) => ElectricLifecycle<T>
1163
1304
  collectionId?: string
1164
1305
  testHooks?: ElectricTestHooks
1165
1306
  },
1166
1307
  ): SyncConfig<T> {
1167
- const {
1168
- seenTxids,
1169
- seenSnapshots,
1170
- hydratedResumeState,
1171
- syncMode,
1172
- pendingMatches,
1173
- currentBatchMessages,
1174
- batchCommitted,
1175
- removePendingMatches,
1176
- resolveMatchedPendingMatches,
1177
- collectionId,
1178
- testHooks,
1179
- } = options
1180
- const MAX_BATCH_MESSAGES = 1000 // Safety limit for message buffer
1181
-
1182
- // Store for the relation schema information
1183
- const relationSchema = new Store<string | undefined>(undefined)
1184
-
1185
- const tagCache = new Map<MoveTag, ParsedMoveTag>()
1186
-
1187
- // Parses a tag string into a ParsedMoveTag.
1188
- // It memoizes the result parsed tag such that future calls
1189
- // for the same tag string return the same ParsedMoveTag array.
1190
- const parseTag = (tag: MoveTag): ParsedMoveTag => {
1191
- const cachedTag = tagCache.get(tag)
1192
- if (cachedTag) {
1193
- return cachedTag
1308
+ const { getLifecycle, syncMode, collectionId, testHooks } = options
1309
+
1310
+ let relationSchema: string | undefined
1311
+ let warnedUnverifiableResume = false
1312
+
1313
+ const createTagState = () => {
1314
+ const tagCache = new Map<MoveTag, ParsedMoveTag>()
1315
+
1316
+ // Parses a tag string into a ParsedMoveTag.
1317
+ // It memoizes the result parsed tag such that future calls
1318
+ // for the same tag string return the same ParsedMoveTag array.
1319
+ const parseTag = (tag: MoveTag): ParsedMoveTag => {
1320
+ const cachedTag = tagCache.get(tag)
1321
+ if (cachedTag) {
1322
+ return cachedTag
1323
+ }
1324
+
1325
+ const parsedTag = parseTagString(tag)
1326
+ tagCache.set(tag, parsedTag)
1327
+ return parsedTag
1194
1328
  }
1195
1329
 
1196
- const parsedTag = parseTagString(tag)
1197
- tagCache.set(tag, parsedTag)
1198
- return parsedTag
1199
- }
1330
+ // Tag tracking state
1331
+ const rowTagSets = new Map<RowId, Set<MoveTag>>()
1332
+ const tagIndex: TagIndex = []
1333
+ let tagLength: number | undefined = undefined
1334
+
1335
+ // DNF state: active_conditions are per-row, disjunct_positions are global
1336
+ // (fixed by the shape's WHERE clause, derived once from the first tagged message).
1337
+ const rowActiveConditions = new Map<RowId, ActiveConditions>()
1338
+ let disjunctPositions: DisjunctPositions | undefined = undefined
1339
+
1340
+ /**
1341
+ * Initialize the tag index with the correct length
1342
+ */
1343
+ const initializeTagIndex = (length: number): void => {
1344
+ if (tagIndex.length < length) {
1345
+ // Extend the index array to the required length
1346
+ for (let i = tagIndex.length; i < length; i++) {
1347
+ tagIndex[i] = new Map()
1348
+ }
1349
+ }
1350
+ }
1200
1351
 
1201
- // Tag tracking state
1202
- const rowTagSets = new Map<RowId, Set<MoveTag>>()
1203
- const tagIndex: TagIndex = []
1204
- let tagLength: number | undefined = undefined
1352
+ /**
1353
+ * Add tags to a row and update the tag index
1354
+ */
1355
+ const addTagsToRow = (
1356
+ tags: Array<MoveTag>,
1357
+ rowId: RowId,
1358
+ rowTagSet: Set<MoveTag>,
1359
+ ): void => {
1360
+ for (const tag of tags) {
1361
+ const parsedTag = parseTag(tag)
1362
+
1363
+ // Infer tag length from first tag
1364
+ if (tagLength === undefined) {
1365
+ tagLength = getTagLength(parsedTag)
1366
+ initializeTagIndex(tagLength)
1367
+ }
1205
1368
 
1206
- // DNF state: active_conditions are per-row, disjunct_positions are global
1207
- // (fixed by the shape's WHERE clause, derived once from the first tagged message).
1208
- const rowActiveConditions = new Map<RowId, ActiveConditions>()
1209
- let disjunctPositions: DisjunctPositions | undefined = undefined
1369
+ // Validate tag length matches
1370
+ const currentTagLength = getTagLength(parsedTag)
1371
+ if (currentTagLength !== tagLength) {
1372
+ debug(
1373
+ `${collectionId ? `[${collectionId}] ` : ``}Tag length mismatch: expected ${tagLength}, got ${currentTagLength}`,
1374
+ )
1375
+ continue
1376
+ }
1210
1377
 
1211
- /**
1212
- * Initialize the tag index with the correct length
1213
- */
1214
- const initializeTagIndex = (length: number): void => {
1215
- if (tagIndex.length < length) {
1216
- // Extend the index array to the required length
1217
- for (let i = tagIndex.length; i < length; i++) {
1218
- tagIndex[i] = new Map()
1378
+ rowTagSet.add(tag)
1379
+ addTagToIndex(parsedTag, rowId, tagIndex, tagLength)
1219
1380
  }
1220
1381
  }
1221
- }
1222
1382
 
1223
- /**
1224
- * Add tags to a row and update the tag index
1225
- */
1226
- const addTagsToRow = (
1227
- tags: Array<MoveTag>,
1228
- rowId: RowId,
1229
- rowTagSet: Set<MoveTag>,
1230
- ): void => {
1231
- for (const tag of tags) {
1232
- const parsedTag = parseTag(tag)
1233
-
1234
- // Infer tag length from first tag
1383
+ /**
1384
+ * Remove tags from a row and update the tag index
1385
+ */
1386
+ const removeTagsFromRow = (
1387
+ removedTags: Array<MoveTag>,
1388
+ rowId: RowId,
1389
+ rowTagSet: Set<MoveTag>,
1390
+ ): void => {
1235
1391
  if (tagLength === undefined) {
1236
- tagLength = getTagLength(parsedTag)
1237
- initializeTagIndex(tagLength)
1392
+ return
1238
1393
  }
1239
1394
 
1240
- // Validate tag length matches
1241
- const currentTagLength = getTagLength(parsedTag)
1242
- if (currentTagLength !== tagLength) {
1243
- debug(
1244
- `${collectionId ? `[${collectionId}] ` : ``}Tag length mismatch: expected ${tagLength}, got ${currentTagLength}`,
1245
- )
1246
- continue
1395
+ for (const tag of removedTags) {
1396
+ const parsedTag = parseTag(tag)
1397
+ rowTagSet.delete(tag)
1398
+ removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength)
1399
+ // We aggresively evict the tag from the cache
1400
+ // if this tag is shared with another row
1401
+ // and is not removed from that other row
1402
+ // then next time we encounter the tag it will be parsed again
1403
+ tagCache.delete(tag)
1247
1404
  }
1248
-
1249
- rowTagSet.add(tag)
1250
- addTagToIndex(parsedTag, rowId, tagIndex, tagLength)
1251
1405
  }
1252
- }
1253
1406
 
1254
- /**
1255
- * Remove tags from a row and update the tag index
1256
- */
1257
- const removeTagsFromRow = (
1258
- removedTags: Array<MoveTag>,
1259
- rowId: RowId,
1260
- rowTagSet: Set<MoveTag>,
1261
- ): void => {
1262
- if (tagLength === undefined) {
1263
- return
1264
- }
1407
+ /**
1408
+ * Process tags for a change message (add and remove tags)
1409
+ */
1410
+ const processTagsForChangeMessage = (
1411
+ tags: Array<MoveTag> | undefined,
1412
+ removedTags: Array<MoveTag> | undefined,
1413
+ rowId: RowId,
1414
+ activeConditions?: ActiveConditions,
1415
+ ): Set<MoveTag> => {
1416
+ // Initialize tag set for this row if it doesn't exist (needed for checking deletion)
1417
+ if (!rowTagSets.has(rowId)) {
1418
+ rowTagSets.set(rowId, new Set())
1419
+ }
1420
+ const rowTagSet = rowTagSets.get(rowId)!
1265
1421
 
1266
- for (const tag of removedTags) {
1267
- const parsedTag = parseTag(tag)
1268
- rowTagSet.delete(tag)
1269
- removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength)
1270
- // We aggresively evict the tag from the cache
1271
- // if this tag is shared with another row
1272
- // and is not removed from that other row
1273
- // then next time we encounter the tag it will be parsed again
1274
- tagCache.delete(tag)
1275
- }
1276
- }
1422
+ // Add new tags
1423
+ if (tags) {
1424
+ addTagsToRow(tags, rowId, rowTagSet)
1277
1425
 
1278
- /**
1279
- * Process tags for a change message (add and remove tags)
1280
- */
1281
- const processTagsForChangeMessage = (
1282
- tags: Array<MoveTag> | undefined,
1283
- removedTags: Array<MoveTag> | undefined,
1284
- rowId: RowId,
1285
- activeConditions?: ActiveConditions,
1286
- ): Set<MoveTag> => {
1287
- // Initialize tag set for this row if it doesn't exist (needed for checking deletion)
1288
- if (!rowTagSets.has(rowId)) {
1289
- rowTagSets.set(rowId, new Set())
1290
- }
1291
- const rowTagSet = rowTagSets.get(rowId)!
1426
+ // Derive disjunct positions once — they are fixed by the shape's WHERE clause.
1427
+ if (disjunctPositions === undefined) {
1428
+ const parsedTags = tags.map(parseTag)
1429
+ disjunctPositions = deriveDisjunctPositions(parsedTags)
1430
+ }
1431
+ }
1292
1432
 
1293
- // Add new tags
1294
- if (tags) {
1295
- addTagsToRow(tags, rowId, rowTagSet)
1433
+ // Remove tags
1434
+ if (removedTags) {
1435
+ removeTagsFromRow(removedTags, rowId, rowTagSet)
1436
+ }
1296
1437
 
1297
- // Derive disjunct positions once — they are fixed by the shape's WHERE clause.
1298
- if (disjunctPositions === undefined) {
1299
- const parsedTags = tags.map(parseTag)
1300
- disjunctPositions = deriveDisjunctPositions(parsedTags)
1438
+ // Store active conditions if provided (overwrite on re-send)
1439
+ if (activeConditions && activeConditions.length > 0) {
1440
+ rowActiveConditions.set(rowId, [...activeConditions])
1301
1441
  }
1302
- }
1303
1442
 
1304
- // Remove tags
1305
- if (removedTags) {
1306
- removeTagsFromRow(removedTags, rowId, rowTagSet)
1443
+ return rowTagSet
1307
1444
  }
1308
1445
 
1309
- // Store active conditions if provided (overwrite on re-send)
1310
- if (activeConditions && activeConditions.length > 0) {
1311
- rowActiveConditions.set(rowId, [...activeConditions])
1446
+ /**
1447
+ * Clear all tag tracking state (used when truncating)
1448
+ */
1449
+ const clearTagTrackingState = (): void => {
1450
+ rowTagSets.clear()
1451
+ tagIndex.length = 0
1452
+ tagLength = undefined
1453
+ rowActiveConditions.clear()
1454
+ disjunctPositions = undefined
1312
1455
  }
1313
1456
 
1314
- return rowTagSet
1315
- }
1457
+ /**
1458
+ * Remove all tags for a row from both the tag set and the index
1459
+ * Used when a row is deleted
1460
+ */
1461
+ const clearTagsForRow = (rowId: RowId): void => {
1462
+ if (tagLength === undefined) {
1463
+ return
1464
+ }
1316
1465
 
1317
- /**
1318
- * Clear all tag tracking state (used when truncating)
1319
- */
1320
- const clearTagTrackingState = (): void => {
1321
- rowTagSets.clear()
1322
- tagIndex.length = 0
1323
- tagLength = undefined
1324
- rowActiveConditions.clear()
1325
- disjunctPositions = undefined
1326
- }
1466
+ const rowTagSet = rowTagSets.get(rowId)
1467
+ if (!rowTagSet) {
1468
+ return
1469
+ }
1327
1470
 
1328
- /**
1329
- * Remove all tags for a row from both the tag set and the index
1330
- * Used when a row is deleted
1331
- */
1332
- const clearTagsForRow = (rowId: RowId): void => {
1333
- if (tagLength === undefined) {
1334
- return
1335
- }
1471
+ // Remove each tag from the index
1472
+ for (const tag of rowTagSet) {
1473
+ const parsedTag = parseTag(tag)
1474
+ const currentTagLength = getTagLength(parsedTag)
1475
+ if (currentTagLength === tagLength) {
1476
+ removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength)
1477
+ }
1478
+ tagCache.delete(tag)
1479
+ }
1336
1480
 
1337
- const rowTagSet = rowTagSets.get(rowId)
1338
- if (!rowTagSet) {
1339
- return
1481
+ // Remove the row from the tag sets map
1482
+ rowTagSets.delete(rowId)
1483
+ rowActiveConditions.delete(rowId)
1340
1484
  }
1341
1485
 
1342
- // Remove each tag from the index
1343
- for (const tag of rowTagSet) {
1344
- const parsedTag = parseTag(tag)
1345
- const currentTagLength = getTagLength(parsedTag)
1346
- if (currentTagLength === tagLength) {
1347
- removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength)
1486
+ /**
1487
+ * Remove matching tags from a row based on a pattern
1488
+ * Returns true if the row should be deleted (no longer visible)
1489
+ */
1490
+ const removeMatchingTagsFromRow = (
1491
+ rowId: RowId,
1492
+ pattern: MovePattern,
1493
+ ): boolean => {
1494
+ const rowTagSet = rowTagSets.get(rowId)
1495
+ if (!rowTagSet) {
1496
+ return false
1348
1497
  }
1349
- tagCache.delete(tag)
1350
- }
1351
-
1352
- // Remove the row from the tag sets map
1353
- rowTagSets.delete(rowId)
1354
- rowActiveConditions.delete(rowId)
1355
- }
1356
1498
 
1357
- /**
1358
- * Remove matching tags from a row based on a pattern
1359
- * Returns true if the row should be deleted (no longer visible)
1360
- */
1361
- const removeMatchingTagsFromRow = (
1362
- rowId: RowId,
1363
- pattern: MovePattern,
1364
- ): boolean => {
1365
- const rowTagSet = rowTagSets.get(rowId)
1366
- if (!rowTagSet) {
1367
- return false
1368
- }
1499
+ // DNF mode: check visibility using active conditions.
1500
+ // Tag index entries are preserved so that move-in can re-activate positions.
1501
+ const activeConditions = rowActiveConditions.get(rowId)
1502
+ if (activeConditions && disjunctPositions) {
1503
+ // Set the condition at this pattern's position to false
1504
+ activeConditions[pattern.pos] = false
1505
+
1506
+ if (!rowVisible(activeConditions, disjunctPositions)) {
1507
+ // Row is no longer visible — clean up all state including tag index
1508
+ for (const tag of rowTagSet) {
1509
+ const parsedTag = parseTag(tag)
1510
+ removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength!)
1511
+ tagCache.delete(tag)
1512
+ }
1513
+ rowTagSets.delete(rowId)
1514
+ rowActiveConditions.delete(rowId)
1515
+ return true
1516
+ }
1517
+ return false
1518
+ }
1369
1519
 
1370
- // DNF mode: check visibility using active conditions.
1371
- // Tag index entries are preserved so that move-in can re-activate positions.
1372
- const activeConditions = rowActiveConditions.get(rowId)
1373
- if (activeConditions && disjunctPositions) {
1374
- // Set the condition at this pattern's position to false
1375
- activeConditions[pattern.pos] = false
1376
-
1377
- if (!rowVisible(activeConditions, disjunctPositions)) {
1378
- // Row is no longer visible — clean up all state including tag index
1379
- for (const tag of rowTagSet) {
1380
- const parsedTag = parseTag(tag)
1520
+ // Simple shape (no subquery dependencies — server sends no active_conditions):
1521
+ // Remove matching tags and delete if tag set is empty
1522
+ for (const tag of rowTagSet) {
1523
+ const parsedTag = parseTag(tag)
1524
+ if (tagMatchesPattern(parsedTag, pattern)) {
1525
+ rowTagSet.delete(tag)
1381
1526
  removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength!)
1382
- tagCache.delete(tag)
1383
1527
  }
1528
+ }
1529
+
1530
+ if (rowTagSet.size === 0) {
1384
1531
  rowTagSets.delete(rowId)
1385
- rowActiveConditions.delete(rowId)
1386
1532
  return true
1387
1533
  }
1534
+
1388
1535
  return false
1389
1536
  }
1390
1537
 
1391
- // Simple shape (no subquery dependencies — server sends no active_conditions):
1392
- // Remove matching tags and delete if tag set is empty
1393
- for (const tag of rowTagSet) {
1394
- const parsedTag = parseTag(tag)
1395
- if (tagMatchesPattern(parsedTag, pattern)) {
1396
- rowTagSet.delete(tag)
1397
- removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength!)
1538
+ /**
1539
+ * Process move-out event: remove matching tags from rows and delete rows with empty tag sets
1540
+ */
1541
+ const processMoveOutEvent = (
1542
+ patterns: Array<MovePattern>,
1543
+ begin: () => void,
1544
+ write: (message: ChangeMessageOrDeleteKeyMessage<T>) => void,
1545
+ transactionStarted: boolean,
1546
+ onDelete: (rowId: RowId) => void,
1547
+ ): boolean => {
1548
+ if (tagLength === undefined) {
1549
+ debug(
1550
+ `${collectionId ? `[${collectionId}] ` : ``}Received move-out message but no tag length set yet, ignoring`,
1551
+ )
1552
+ return transactionStarted
1398
1553
  }
1399
- }
1400
-
1401
- if (rowTagSet.size === 0) {
1402
- rowTagSets.delete(rowId)
1403
- return true
1404
- }
1405
1554
 
1406
- return false
1407
- }
1555
+ let txStarted = transactionStarted
1408
1556
 
1409
- /**
1410
- * Process move-out event: remove matching tags from rows and delete rows with empty tag sets
1411
- */
1412
- const processMoveOutEvent = (
1413
- patterns: Array<MovePattern>,
1414
- begin: () => void,
1415
- write: (message: ChangeMessageOrDeleteKeyMessage<T>) => void,
1416
- transactionStarted: boolean,
1417
- ): boolean => {
1418
- if (tagLength === undefined) {
1419
- debug(
1420
- `${collectionId ? `[${collectionId}] ` : ``}Received move-out message but no tag length set yet, ignoring`,
1421
- )
1422
- return transactionStarted
1423
- }
1557
+ // Process all patterns and collect rows to delete
1558
+ for (const pattern of patterns) {
1559
+ // Find all rows that match this pattern
1560
+ const affectedRowIds = findRowsMatchingPattern(pattern, tagIndex)
1424
1561
 
1425
- let txStarted = transactionStarted
1426
-
1427
- // Process all patterns and collect rows to delete
1428
- for (const pattern of patterns) {
1429
- // Find all rows that match this pattern
1430
- const affectedRowIds = findRowsMatchingPattern(pattern, tagIndex)
1562
+ for (const rowId of affectedRowIds) {
1563
+ if (removeMatchingTagsFromRow(rowId, pattern)) {
1564
+ // Delete rows with empty tag sets
1565
+ if (!txStarted) {
1566
+ begin()
1567
+ txStarted = true
1568
+ }
1431
1569
 
1432
- for (const rowId of affectedRowIds) {
1433
- if (removeMatchingTagsFromRow(rowId, pattern)) {
1434
- // Delete rows with empty tag sets
1435
- if (!txStarted) {
1436
- begin()
1437
- txStarted = true
1570
+ write({
1571
+ type: `delete`,
1572
+ key: rowId,
1573
+ })
1574
+ onDelete(rowId)
1438
1575
  }
1439
-
1440
- write({
1441
- type: `delete`,
1442
- key: rowId,
1443
- })
1444
1576
  }
1445
1577
  }
1446
- }
1447
-
1448
- return txStarted
1449
- }
1450
1578
 
1451
- /**
1452
- * Process move-in event: re-activate conditions for rows matching the patterns.
1453
- * This is a silent operation — no messages are emitted to the collection.
1454
- */
1455
- const processMoveInEvent = (patterns: Array<MovePattern>): void => {
1456
- if (tagLength === undefined) {
1457
- debug(
1458
- `${collectionId ? `[${collectionId}] ` : ``}Received move-in message but no tag length set yet, ignoring`,
1459
- )
1460
- return
1579
+ return txStarted
1461
1580
  }
1462
1581
 
1463
- for (const pattern of patterns) {
1464
- const affectedRowIds = findRowsMatchingPattern(pattern, tagIndex)
1582
+ /**
1583
+ * Process move-in event: re-activate conditions for rows matching the patterns.
1584
+ * This is a silent operation — no messages are emitted to the collection.
1585
+ */
1586
+ const processMoveInEvent = (patterns: Array<MovePattern>): void => {
1587
+ if (tagLength === undefined) {
1588
+ debug(
1589
+ `${collectionId ? `[${collectionId}] ` : ``}Received move-in message but no tag length set yet, ignoring`,
1590
+ )
1591
+ return
1592
+ }
1593
+
1594
+ for (const pattern of patterns) {
1595
+ const affectedRowIds = findRowsMatchingPattern(pattern, tagIndex)
1465
1596
 
1466
- for (const rowId of affectedRowIds) {
1467
- const activeConditions = rowActiveConditions.get(rowId)
1468
- if (activeConditions) {
1469
- activeConditions[pattern.pos] = true
1597
+ for (const rowId of affectedRowIds) {
1598
+ const activeConditions = rowActiveConditions.get(rowId)
1599
+ if (activeConditions) {
1600
+ activeConditions[pattern.pos] = true
1601
+ }
1470
1602
  }
1471
1603
  }
1472
1604
  }
1473
- }
1474
-
1475
- /**
1476
- * Get the sync metadata for insert operations
1477
- * @returns Record containing relation information
1478
- */
1479
- const getSyncMetadata = (): Record<string, unknown> => {
1480
- // Use the stored schema if available, otherwise default to 'public'
1481
- const schema = relationSchema.state || `public`
1482
1605
 
1483
1606
  return {
1484
- relation: shapeOptions.params?.table
1485
- ? [schema, shapeOptions.params.table]
1486
- : undefined,
1607
+ hasTags: () => tagLength !== undefined,
1608
+ processTagsForChangeMessage,
1609
+ clearTagTrackingState,
1610
+ clearTagsForRow,
1611
+ processMoveOutEvent,
1612
+ processMoveInEvent,
1487
1613
  }
1488
1614
  }
1489
-
1490
- let unsubscribeStream: () => void
1615
+ // Tags belong to a collection, and survive a compatible resume of that
1616
+ // collection. Reusing an options descriptor must not share visibility.
1617
+ const collectionTags = new WeakMap<
1618
+ object,
1619
+ ReturnType<typeof createTagState>
1620
+ >()
1491
1621
 
1492
1622
  return {
1623
+ getSyncMetadata: () => ({
1624
+ relation: shapeOptions.params?.table
1625
+ ? [relationSchema || `public`, shapeOptions.params.table]
1626
+ : undefined,
1627
+ }),
1493
1628
  sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => {
1629
+ const retainsTagState = collectionTags.has(params.collection)
1630
+ let tagState = collectionTags.get(params.collection)
1631
+ if (!tagState) {
1632
+ tagState = createTagState()
1633
+ collectionTags.set(params.collection, tagState)
1634
+ }
1635
+ const {
1636
+ processTagsForChangeMessage,
1637
+ clearTagTrackingState,
1638
+ clearTagsForRow,
1639
+ processMoveOutEvent,
1640
+ processMoveInEvent,
1641
+ } = tagState
1642
+ const lifecycle = getLifecycle(params.collection)
1643
+ const lifecycleEpoch = lifecycle.start()
1644
+ const isActiveLifecycle = () => lifecycle.isActive(lifecycleEpoch)
1645
+ Object.assign(params.collection.utils, lifecycle.utils)
1646
+
1494
1647
  const {
1495
1648
  begin,
1496
1649
  write,
@@ -1526,9 +1679,15 @@ function createElectricSync<T extends Row<unknown>>(
1526
1679
  return parseElectricResumeState(persistedResumeState)
1527
1680
  }
1528
1681
 
1682
+ const persistedMetadata = metadata as
1683
+ | ElectricSyncMetadataWithHydration
1684
+ | undefined
1685
+ const scanPersisted = persistedMetadata?.row.scanPersisted
1686
+ const whenHydrated = persistedMetadata?.row.whenHydrated
1687
+
1529
1688
  const persistedResumeState = getNewestElectricResumeState(
1530
1689
  readPersistedResumeState(),
1531
- hydratedResumeState.state,
1690
+ lifecycle.resumeState,
1532
1691
  )
1533
1692
  const shapeIdentity = getStableShapeIdentity({
1534
1693
  url: shapeOptions.url,
@@ -1537,11 +1696,52 @@ function createElectricSync<T extends Row<unknown>>(
1537
1696
  const hasIncompatiblePersistedResume =
1538
1697
  persistedResumeState?.kind === `resume` &&
1539
1698
  persistedResumeState.shapeId !== shapeIdentity
1699
+ const hasUnverifiablePersistedResume =
1700
+ shapeOptions.offset === undefined &&
1701
+ shapeOptions.handle === undefined &&
1702
+ persistedResumeState?.kind === `resume` &&
1703
+ scanPersisted !== undefined &&
1704
+ whenHydrated === undefined
1705
+ if (hasUnverifiablePersistedResume && !warnedUnverifiableResume) {
1706
+ warnedUnverifiableResume = true
1707
+ console.warn(
1708
+ `Electric persistence cannot verify hydration for saved resume state. Update the persistence adapter alongside Electric to enable safe resume.`,
1709
+ )
1710
+ }
1711
+ const needsFullSnapshot =
1712
+ shapeOptions.offset === undefined &&
1713
+ shapeOptions.handle === undefined &&
1714
+ persistedResumeState !== undefined &&
1715
+ (persistedResumeState.kind === `reset` ||
1716
+ (!retainsTagState && persistedResumeState.requiresTagState !== false))
1540
1717
  const canUsePersistedResume =
1541
1718
  shapeOptions.offset === undefined &&
1542
1719
  shapeOptions.handle === undefined &&
1543
1720
  persistedResumeState?.kind === `resume` &&
1544
- !hasIncompatiblePersistedResume
1721
+ !hasIncompatiblePersistedResume &&
1722
+ !hasUnverifiablePersistedResume &&
1723
+ // Cached rows do not contain authoritative tag/active-condition state.
1724
+ // Unknown (older) metadata is conservative; untagged shapes still resume.
1725
+ !needsFullSnapshot
1726
+ const hasExplicitResumeOffset =
1727
+ shapeOptions.offset !== undefined && shapeOptions.offset !== `-1`
1728
+ if (!canUsePersistedResume && !hasExplicitResumeOffset) {
1729
+ clearTagTrackingState()
1730
+ }
1731
+ const receivesCompleteRows = shapeOptions.params?.replica === `full`
1732
+ // Eager and progressive streams that start after the initial offset can
1733
+ // only apply partial updates when the local materialization is complete.
1734
+ const requiresCompleteResume =
1735
+ syncMode !== `on-demand` &&
1736
+ (canUsePersistedResume ||
1737
+ (hasExplicitResumeOffset && !receivesCompleteRows))
1738
+ // A fresh snapshot replaces its hydrated cache; omitting the old offset
1739
+ // alone would merge rows that no longer exist on the server.
1740
+ let freshSnapshotPending =
1741
+ (syncMode === `eager` || needsFullSnapshot) &&
1742
+ !canUsePersistedResume &&
1743
+ !hasExplicitResumeOffset &&
1744
+ whenHydrated !== undefined
1545
1745
 
1546
1746
  // Wrap markReady to wait for test hook in progressive mode
1547
1747
  let progressiveReadyGate: Promise<void> | null = null
@@ -1573,44 +1773,36 @@ function createElectricSync<T extends Row<unknown>>(
1573
1773
 
1574
1774
  // Abort controller for the stream - wraps the signal if provided
1575
1775
  const abortController = new AbortController()
1776
+ const forwardExternalAbort = () => abortController.abort()
1576
1777
 
1577
1778
  if (shapeOptions.signal) {
1578
- shapeOptions.signal.addEventListener(
1579
- `abort`,
1580
- () => {
1581
- abortController.abort()
1582
- },
1583
- {
1584
- once: true,
1585
- },
1586
- )
1779
+ shapeOptions.signal.addEventListener(`abort`, forwardExternalAbort, {
1780
+ once: true,
1781
+ })
1587
1782
  if (shapeOptions.signal.aborted) {
1588
1783
  abortController.abort()
1589
1784
  }
1590
1785
  }
1591
1786
 
1592
- // Cleanup pending matches on abort
1593
1787
  abortController.signal.addEventListener(`abort`, () => {
1594
- pendingMatches.setState((current) => {
1595
- current.forEach((match) => {
1596
- clearTimeout(match.timeoutId)
1597
- match.reject(new StreamAbortedError())
1598
- })
1599
- return new Map() // Clear all pending matches
1600
- })
1788
+ lifecycle.retire(lifecycleEpoch)
1601
1789
  })
1602
1790
 
1603
1791
  const stream = new ShapeStream({
1604
1792
  ...shapeOptions,
1605
- // In on-demand mode, we only want to sync changes, so we set the log to `changes_only`
1606
- log: syncMode === `on-demand` ? `changes_only` : undefined,
1793
+ // Recovery needs a complete snapshot even for on-demand shapes.
1794
+ // Normal on-demand startup still subscribes to changes only.
1795
+ log:
1796
+ syncMode === `on-demand` && !needsFullSnapshot
1797
+ ? `changes_only`
1798
+ : undefined,
1607
1799
  // In on-demand mode, we only need the changes from the point of time the collection was created
1608
1800
  // so we default to `now` when there is no saved offset.
1609
1801
  offset:
1610
1802
  shapeOptions.offset ??
1611
1803
  (canUsePersistedResume
1612
1804
  ? (persistedResumeState.offset as Offset)
1613
- : syncMode === `on-demand`
1805
+ : syncMode === `on-demand` && !needsFullSnapshot
1614
1806
  ? `now`
1615
1807
  : undefined),
1616
1808
  handle:
@@ -1650,12 +1842,19 @@ function createElectricSync<T extends Row<unknown>>(
1650
1842
  // resume starts from an already-committed stream offset, so the next
1651
1843
  // up-to-date message must not run the initial atomic swap again.
1652
1844
  let hasReceivedUpToDate =
1653
- syncMode === `progressive` && canUsePersistedResume
1845
+ syncMode === `progressive` && requiresCompleteResume
1846
+ // A must-refetch starts a new snapshot generation. Until its up-to-date
1847
+ // commit is applied, old Collection keys cannot make an update valid and
1848
+ // the durable resume marker must remain reset.
1849
+ let isResettingSnapshot = false
1850
+ let resetGeneration = 0
1654
1851
 
1655
1852
  // Progressive mode state
1656
1853
  // Helper to determine if we're buffering the initial sync
1657
1854
  const isBufferingInitialSync = () =>
1658
- syncMode === `progressive` && !hasReceivedUpToDate
1855
+ syncMode === `progressive` &&
1856
+ !hasReceivedUpToDate &&
1857
+ !isResettingSnapshot
1659
1858
  const bufferedMessages: Array<Message<T>> = [] // Buffer change messages during initial sync
1660
1859
 
1661
1860
  // Track keys that have been synced to handle overlapping subset queries.
@@ -1663,8 +1862,12 @@ function createElectricSync<T extends Row<unknown>>(
1663
1862
  // for each response. We convert subsequent inserts to updates to avoid
1664
1863
  // duplicate key errors when the row's data has changed between requests.
1665
1864
  const syncedKeys = new Set<string | number>()
1865
+ let resumeInvalid = false
1666
1866
 
1667
1867
  const stageResumeMetadata = () => {
1868
+ if (!isActiveLifecycle() || resumeInvalid) {
1869
+ return
1870
+ }
1668
1871
  const shapeHandle = stream.shapeHandle
1669
1872
  const lastOffset = stream.lastOffset
1670
1873
  if (!shapeHandle || lastOffset === `-1`) {
@@ -1677,8 +1880,9 @@ function createElectricSync<T extends Row<unknown>>(
1677
1880
  handle: shapeHandle,
1678
1881
  shapeId: shapeIdentity,
1679
1882
  updatedAt: Date.now(),
1883
+ requiresTagState: tagState.hasTags(),
1680
1884
  }
1681
- hydratedResumeState.setState(() => resumeState)
1885
+ lifecycle.resumeState = resumeState
1682
1886
  metadata?.collection.set(`electric:resume`, resumeState)
1683
1887
  }
1684
1888
 
@@ -1687,7 +1891,7 @@ function createElectricSync<T extends Row<unknown>>(
1687
1891
  kind: `reset`,
1688
1892
  updatedAt: Date.now(),
1689
1893
  }
1690
- hydratedResumeState.setState(() => resetState)
1894
+ lifecycle.resumeState = resetState
1691
1895
 
1692
1896
  if (metadata) {
1693
1897
  begin({ immediate: true })
@@ -1696,7 +1900,11 @@ function createElectricSync<T extends Row<unknown>>(
1696
1900
  }
1697
1901
  }
1698
1902
 
1699
- if (hasIncompatiblePersistedResume) {
1903
+ if (
1904
+ hasIncompatiblePersistedResume ||
1905
+ hasUnverifiablePersistedResume ||
1906
+ (needsFullSnapshot && persistedResumeState.kind === `resume`)
1907
+ ) {
1700
1908
  commitResetResumeMetadataImmediately()
1701
1909
  }
1702
1910
 
@@ -1776,32 +1984,85 @@ function createElectricSync<T extends Row<unknown>>(
1776
1984
  signal: abortController.signal,
1777
1985
  })
1778
1986
 
1779
- unsubscribeStream = stream.subscribe((messages: Array<Message<T>>) => {
1987
+ const resumeKeysPromise =
1988
+ requiresCompleteResume || freshSnapshotPending
1989
+ ? whenHydrated?.()
1990
+ : undefined
1991
+ let areResumeKeysReady = !resumeKeysPromise
1992
+ const pendingResumeBatches: Array<Array<Message<T>>> = []
1993
+ let unsubscribeStream: () => void = () => {}
1994
+
1995
+ const invalidateResume = () => {
1996
+ resumeInvalid = true
1997
+ if (transactionStarted) {
1998
+ const cancellation = new AbortController()
1999
+ cancellation.abort()
2000
+ commit(cancellation.signal)
2001
+ transactionStarted = false
2002
+ }
2003
+ syncedKeys.clear()
2004
+ newTxids.clear()
2005
+ newSnapshots.length = 0
2006
+ commitResetResumeMetadataImmediately()
2007
+ streamErrorVersion++
2008
+ unsubscribeStream()
2009
+ abortController.abort()
2010
+ markError(
2011
+ new Error(
2012
+ `Electric resume state referenced an unseen row; a full snapshot is required`,
2013
+ ),
2014
+ )
2015
+ }
2016
+
2017
+ const processMessages = (messages: Array<Message<T>>): void => {
2018
+ if (!isActiveLifecycle() || resumeInvalid) {
2019
+ return
2020
+ }
2021
+
2022
+ if (freshSnapshotPending) {
2023
+ freshSnapshotPending = false
2024
+ begin()
2025
+ transactionStarted = true
2026
+ truncate()
2027
+ syncedKeys.clear()
2028
+ clearTagTrackingState()
2029
+ isResettingSnapshot = true
2030
+ resetGeneration++
2031
+ }
2032
+
2033
+ // Applied rows can also arrive through persistence invalidations.
2034
+ // Overlay only unapplied writes, once per callback rather than once
2035
+ // per message. A queued truncate fences off the previous snapshot.
2036
+ const pendingPresence = new Map<string | number, boolean>()
2037
+ let usesBaseline = true
2038
+ for (const pending of collection._state.pendingSyncedTransactions) {
2039
+ if (pending.truncate) {
2040
+ pendingPresence.clear()
2041
+ usesBaseline = false
2042
+ }
2043
+ for (const operation of pending.operations) {
2044
+ pendingPresence.set(operation.key, operation.type !== `delete`)
2045
+ }
2046
+ }
2047
+ for (const message of bufferedMessages) {
2048
+ if (isChangeMessage(message)) {
2049
+ pendingPresence.set(
2050
+ collection.getKeyFromItem(message.value),
2051
+ message.headers.operation !== `delete`,
2052
+ )
2053
+ }
2054
+ }
2055
+
1780
2056
  // Track commit point type - up-to-date takes precedence as it also triggers progressive mode atomic swap
1781
2057
  let commitPoint: `up-to-date` | `subset-end` | null = null
1782
2058
 
1783
- // Don't clear the buffer between batches - this preserves messages for awaitMatch
1784
- // to find even if multiple batches arrive before awaitMatch is called.
1785
- // The buffer is naturally limited by MAX_BATCH_MESSAGES (oldest messages are dropped).
1786
- // Reset batchCommitted since we're starting a new batch
1787
- batchCommitted.setState(() => false)
2059
+ lifecycle.beginMatchGeneration(messages)
1788
2060
 
1789
2061
  for (const message of messages) {
1790
- // Add message to current batch buffer (for race condition handling)
1791
- if (
1792
- isChangeMessage(message) ||
1793
- isMoveOutMessage(message) ||
1794
- isMoveInMessage(message)
1795
- ) {
1796
- currentBatchMessages.setState((currentBuffer) => {
1797
- const newBuffer = [...currentBuffer, message]
1798
- // Limit buffer size for safety
1799
- if (newBuffer.length > MAX_BATCH_MESSAGES) {
1800
- newBuffer.splice(0, newBuffer.length - MAX_BATCH_MESSAGES)
1801
- }
1802
- return newBuffer
1803
- })
1804
- }
2062
+ lifecycle.observeMatchMessage(message)
2063
+ // A match predicate can synchronously clean up and restart sync.
2064
+ // Nothing after that boundary belongs to the replacement session.
2065
+ if (!isActiveLifecycle()) return
1805
2066
 
1806
2067
  // Check for txids in the message and add them to our store
1807
2068
  // Skip during buffered initial sync in progressive mode (txids will be extracted during atomic swap)
@@ -1814,34 +2075,31 @@ function createElectricSync<T extends Row<unknown>>(
1814
2075
  message.headers.txids?.forEach((txid) => newTxids.add(txid))
1815
2076
  }
1816
2077
 
1817
- // Check pending matches against this message
1818
- // Note: matchFn will mark matches internally, we don't resolve here
1819
- const matchesToRemove: Array<string> = []
1820
- pendingMatches.state.forEach((match, matchId) => {
1821
- if (!match.matched) {
1822
- try {
1823
- match.matchFn(message)
1824
- } catch (err) {
1825
- // If matchFn throws, clean up and reject the promise
1826
- clearTimeout(match.timeoutId)
1827
- match.reject(
1828
- err instanceof Error ? err : new Error(String(err)),
1829
- )
1830
- matchesToRemove.push(matchId)
1831
- debug(`matchFn error: %o`, err)
2078
+ if (isChangeMessage(message)) {
2079
+ const rowId = collection.getKeyFromItem(message.value)
2080
+ const operation = message.headers.operation
2081
+ const hasKnownRow =
2082
+ pendingPresence.get(rowId) ??
2083
+ (usesBaseline && collection._state.syncedData.has(rowId))
2084
+ if (operation === `update` && !hasKnownRow) {
2085
+ // Validate after all earlier events, including tag move-outs.
2086
+ // Cancel staged writes before publishing any part of an invalid
2087
+ // resumed callback; the next lifecycle must take a full snapshot.
2088
+ if (requiresCompleteResume && !isResettingSnapshot) {
2089
+ invalidateResume()
2090
+ return
1832
2091
  }
2092
+ if (!receivesCompleteRows) continue
1833
2093
  }
1834
- })
1835
-
1836
- // Remove matches that errored
1837
- removePendingMatches(matchesToRemove)
2094
+ pendingPresence.set(rowId, operation !== `delete`)
2095
+ }
1838
2096
 
1839
2097
  if (isChangeMessage(message)) {
1840
2098
  // Check if the message contains schema information
1841
2099
  const schema = message.headers.schema
1842
2100
  if (schema && typeof schema === `string`) {
1843
2101
  // Store the schema for future use if it's a valid string
1844
- relationSchema.setState(() => schema)
2102
+ relationSchema = schema
1845
2103
  }
1846
2104
 
1847
2105
  // In buffered initial sync of progressive mode, buffer messages instead of writing
@@ -1887,6 +2145,10 @@ function createElectricSync<T extends Row<unknown>>(
1887
2145
  begin,
1888
2146
  write,
1889
2147
  transactionStarted,
2148
+ (rowId) => {
2149
+ pendingPresence.set(rowId, false)
2150
+ syncedKeys.delete(rowId)
2151
+ },
1890
2152
  )
1891
2153
  }
1892
2154
  } else if (isMoveInMessage(message)) {
@@ -1917,6 +2179,10 @@ function createElectricSync<T extends Row<unknown>>(
1917
2179
 
1918
2180
  // Clear synced keys tracking since we're starting fresh
1919
2181
  syncedKeys.clear()
2182
+ pendingPresence.clear()
2183
+ usesBaseline = false
2184
+ isResettingSnapshot = true
2185
+ resetGeneration++
1920
2186
 
1921
2187
  // Reset the loadSubset deduplication state since we're starting fresh
1922
2188
  // This ensures that previously loaded predicates don't prevent refetching after truncate
@@ -1929,9 +2195,20 @@ function createElectricSync<T extends Row<unknown>>(
1929
2195
  }
1930
2196
  }
1931
2197
 
2198
+ // A subset completion cannot publish a partial cold-recovery snapshot.
2199
+ if (
2200
+ needsFullSnapshot &&
2201
+ isResettingSnapshot &&
2202
+ commitPoint === `subset-end`
2203
+ )
2204
+ return
2205
+
1932
2206
  if (commitPoint !== null) {
1933
2207
  let applied: SyncAppliedReceipt = true
1934
2208
  const wasBufferingInitialSync = isBufferingInitialSync()
2209
+ const finishesReset =
2210
+ isResettingSnapshot && commitPoint === `up-to-date`
2211
+ const finishingResetGeneration = resetGeneration
1935
2212
  // PROGRESSIVE MODE: Atomic swap on first up-to-date (not subset-end)
1936
2213
  // EXCEPTION: Skip atomic swap if a transaction is already started (e.g., from must-refetch).
1937
2214
  // In that case, do a normal commit to properly close the existing transaction.
@@ -1976,7 +2253,13 @@ function createElectricSync<T extends Row<unknown>>(
1976
2253
  bufferedMsg.headers.patterns,
1977
2254
  begin,
1978
2255
  write,
1979
- transactionStarted,
2256
+ // The swap already opened a transaction, even though the
2257
+ // normal-stream transactionStarted flag is still false.
2258
+ true,
2259
+ (rowId) => {
2260
+ pendingPresence.set(rowId, false)
2261
+ syncedKeys.delete(rowId)
2262
+ },
1980
2263
  )
1981
2264
  } else if (isMoveInMessage(bufferedMsg)) {
1982
2265
  // Process buffered move-in messages during atomic swap
@@ -1999,7 +2282,9 @@ function createElectricSync<T extends Row<unknown>>(
1999
2282
  // Normal mode or on-demand: commit transaction if one was started
2000
2283
  // Both up-to-date and subset-end trigger a commit
2001
2284
  if (transactionStarted) {
2002
- stageResumeMetadata()
2285
+ if (!isResettingSnapshot || finishesReset) {
2286
+ stageResumeMetadata()
2287
+ }
2003
2288
  applied = commit()
2004
2289
  transactionStarted = false
2005
2290
  } else if (commitPoint === `up-to-date` && metadata) {
@@ -2019,63 +2304,97 @@ function createElectricSync<T extends Row<unknown>>(
2019
2304
  )
2020
2305
  }
2021
2306
 
2307
+ if (finishesReset) {
2308
+ const finishReset = () => {
2309
+ if (resetGeneration === finishingResetGeneration) {
2310
+ isResettingSnapshot = false
2311
+ }
2312
+ }
2313
+ if (applied === true) {
2314
+ finishReset()
2315
+ } else {
2316
+ void applied.then(finishReset, () => undefined)
2317
+ }
2318
+ }
2319
+
2022
2320
  // Track that we've received the first up-to-date for progressive mode
2023
2321
  if (commitPoint === `up-to-date`) {
2024
2322
  hasReceivedUpToDate = true
2025
2323
  }
2026
2324
 
2027
- // Always commit txids when we receive up-to-date, regardless of transaction state
2028
- seenTxids.setState((currentTxids) => {
2029
- const clonedSeen = new Set<Txid>(currentTxids)
2030
- if (newTxids.size > 0) {
2031
- debug(
2032
- `${collectionId ? `[${collectionId}] ` : ``}new txids synced from pg %O`,
2033
- Array.from(newTxids),
2034
- )
2035
- }
2036
- newTxids.forEach((txid) => clonedSeen.add(txid))
2037
- newTxids.clear()
2038
- return clonedSeen
2039
- })
2040
-
2041
- // Always commit snapshots when we receive up-to-date, regardless of transaction state
2042
- seenSnapshots.setState((currentSnapshots) => {
2043
- const seen = [...currentSnapshots, ...newSnapshots]
2044
- newSnapshots.forEach((snapshot) =>
2045
- debug(
2046
- `${collectionId ? `[${collectionId}] ` : ``}new snapshot synced from pg %o`,
2047
- snapshot,
2048
- ),
2325
+ // Stream evidence is the acknowledgement boundary used by mutation
2326
+ // handlers. It must publish before a parked applied receipt or the
2327
+ // optimistic transaction and its acknowledgement can deadlock.
2328
+ if (newTxids.size > 0) {
2329
+ debug(
2330
+ `${collectionId ? `[${collectionId}] ` : ``}new txids synced from pg %O`,
2331
+ Array.from(newTxids),
2049
2332
  )
2050
- newSnapshots.length = 0
2051
- return seen
2052
- })
2053
-
2054
- // Resolve all matched pending matches on up-to-date or subset-end
2055
- // Set batchCommitted BEFORE resolving to avoid timing window where late awaitMatch
2056
- // calls could register as "matched" after resolver pass already ran
2057
- batchCommitted.setState(() => true)
2333
+ }
2334
+ newSnapshots.forEach((snapshot) =>
2335
+ debug(
2336
+ `${collectionId ? `[${collectionId}] ` : ``}new snapshot synced from pg %o`,
2337
+ snapshot,
2338
+ ),
2339
+ )
2340
+ lifecycle.publishEvidence(newTxids, newSnapshots)
2341
+ newTxids.clear()
2342
+ newSnapshots.length = 0
2343
+ lifecycle.commitMatches()
2344
+ }
2345
+ }
2058
2346
 
2059
- resolveMatchedPendingMatches()
2347
+ unsubscribeStream = stream.subscribe((messages: Array<Message<T>>) => {
2348
+ if (!areResumeKeysReady) {
2349
+ pendingResumeBatches.push([...messages])
2350
+ return
2060
2351
  }
2352
+ processMessages(messages)
2061
2353
  })
2062
2354
 
2355
+ if (!areResumeKeysReady && resumeKeysPromise) {
2356
+ void resumeKeysPromise.then(
2357
+ () => {
2358
+ if (abortController.signal.aborted) return
2359
+
2360
+ areResumeKeysReady = true
2361
+
2362
+ const queuedBatches = pendingResumeBatches.splice(0)
2363
+ queuedBatches.forEach(processMessages)
2364
+ },
2365
+ (error: unknown) => {
2366
+ if (abortController.signal.aborted) return
2367
+
2368
+ pendingResumeBatches.length = 0
2369
+ resumeInvalid = true
2370
+ commitResetResumeMetadataImmediately()
2371
+ streamErrorVersion++
2372
+ unsubscribeStream()
2373
+ abortController.abort()
2374
+ markError(error)
2375
+ },
2376
+ )
2377
+ }
2378
+
2063
2379
  // Return the deduplicated loadSubset if available (on-demand or progressive mode)
2064
2380
  // The loadSubset method is auto-bound, so it can be safely returned directly
2065
2381
  return {
2066
2382
  loadSubset: loadSubsetDedupe?.loadSubset,
2067
2383
  cleanup: () => {
2384
+ shapeOptions.signal?.removeEventListener(
2385
+ `abort`,
2386
+ forwardExternalAbort,
2387
+ )
2068
2388
  // Unsubscribe from the stream
2069
2389
  unsubscribeStream()
2070
2390
  // Abort the abort controller to stop the stream
2071
2391
  abortController.abort()
2392
+ pendingResumeBatches.length = 0
2072
2393
  // Reset deduplication tracking so collection can load fresh data if restarted
2073
2394
  loadSubsetDedupe?.reset()
2074
- hydratedResumeState.setState(() => undefined)
2395
+ lifecycle.retire(lifecycleEpoch)
2075
2396
  },
2076
2397
  }
2077
2398
  },
2078
- // Expose the getSyncMetadata function
2079
- getSyncMetadata,
2080
2399
  }
2081
2400
  }