@tanstack/electric-db-collection 0.4.8 → 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,13 +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,
11
10
  LoadSubsetOperationAbortedError,
12
11
  and,
13
12
  withCollectionConfigFactory,
13
+ withCollectionSyncConfigCleanup,
14
+ withCollectionSyncConfigFactory,
14
15
  } from '@tanstack/db'
15
16
  import {
16
17
  ExpectedNumberInAwaitTxIdError,
@@ -50,6 +51,7 @@ import type {
50
51
  LoadSubsetOptions,
51
52
  SyncAppliedReceipt,
52
53
  SyncConfig,
54
+ SyncMetadataApi,
53
55
  SyncMode,
54
56
  UpdateMutationFnParams,
55
57
  UtilsRecord,
@@ -65,6 +67,14 @@ import type {
65
67
  ShapeStreamOptions,
66
68
  } from '@electric-sql/client'
67
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
+
68
78
  // Re-export for user convenience in custom match functions
69
79
  export { isChangeMessage, isControlMessage } from '@electric-sql/client'
70
80
 
@@ -100,6 +110,7 @@ type ElectricResumeState =
100
110
  handle: string
101
111
  shapeId: string
102
112
  updatedAt: number
113
+ requiresTagState?: boolean
103
114
  }
104
115
  | {
105
116
  kind: `reset`
@@ -112,6 +123,47 @@ type ElectricSyncMeta = {
112
123
  seenTxids: Array<Txid>
113
124
  }
114
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
+
115
167
  function parseElectricResumeState(
116
168
  value: unknown,
117
169
  ): ElectricResumeState | undefined {
@@ -134,6 +186,9 @@ function parseElectricResumeState(
134
186
  handle: record.handle,
135
187
  shapeId: record.shapeId,
136
188
  updatedAt: record.updatedAt,
189
+ ...(typeof record.requiresTagState === `boolean` && {
190
+ requiresTagState: record.requiresTagState,
191
+ }),
137
192
  }
138
193
  }
139
194
 
@@ -216,7 +271,21 @@ function getNewestElectricResumeState(
216
271
  ): ElectricResumeState | undefined {
217
272
  if (!current) return incoming
218
273
  if (!incoming) return current
219
- 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 }
220
289
  }
221
290
 
222
291
  /**
@@ -751,6 +820,244 @@ export interface ElectricCollectionUtils<
751
820
  awaitMatch: AwaitMatchFn<T>
752
821
  }
753
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
+
754
1061
  /**
755
1062
  * Creates Electric collection options for use with a standard Collection
756
1063
  *
@@ -804,267 +1111,55 @@ export function electricCollectionOptions<T extends Row<unknown>>(
804
1111
  utils: ElectricCollectionUtils<T>
805
1112
  schema?: any
806
1113
  } {
807
- const seenTxids = new Store<Set<Txid>>(new Set([]))
808
- const seenSnapshots = new Store<Array<PostgresSnapshot>>([])
809
- const hydratedResumeState = new Store<ElectricResumeState | undefined>(
810
- undefined,
811
- )
1114
+ let descriptorLifecycle = new ElectricLifecycle<T>(config.id)
1115
+ let utilityLifecycle = descriptorLifecycle
812
1116
  const internalSyncMode = config.syncMode ?? `eager`
813
1117
  const finalSyncMode =
814
1118
  internalSyncMode === `progressive` ? `on-demand` : internalSyncMode
815
- const pendingMatches = new Store<
816
- Map<
817
- string,
818
- {
819
- matchFn: (message: Message<any>) => boolean
820
- resolve: (value: boolean) => void
821
- reject: (error: Error) => void
822
- timeoutId: ReturnType<typeof setTimeout>
823
- matched: boolean
824
- }
825
- >
826
- >(new Map())
827
-
828
- // Buffer messages since last up-to-date to handle race conditions
829
- const currentBatchMessages = new Store<Array<Message<any>>>([])
830
-
831
- // Track whether the current batch has been committed (up-to-date received)
832
- // This allows awaitMatch to resolve immediately for messages from committed batches
833
- const batchCommitted = new Store<boolean>(false)
834
-
835
- /**
836
- * Helper function to remove multiple matches from the pendingMatches store
837
- */
838
- const removePendingMatches = (matchIds: Array<string>) => {
839
- if (matchIds.length > 0) {
840
- pendingMatches.setState((current) => {
841
- const newMatches = new Map(current)
842
- matchIds.forEach((id) => newMatches.delete(id))
843
- return newMatches
844
- })
845
- }
846
- }
847
-
848
- /**
849
- * Helper function to resolve and cleanup matched pending matches
850
- */
851
- const resolveMatchedPendingMatches = () => {
852
- const matchesToResolve: Array<string> = []
853
- pendingMatches.state.forEach((match, matchId) => {
854
- if (match.matched) {
855
- clearTimeout(match.timeoutId)
856
- match.resolve(true)
857
- matchesToResolve.push(matchId)
858
- debug(
859
- `${config.id ? `[${config.id}] ` : ``}awaitMatch resolved on up-to-date for match %s`,
860
- matchId,
861
- )
862
- }
863
- })
864
- removePendingMatches(matchesToResolve)
865
- }
866
- const sync = createElectricSync<T>(config.shapeOptions, {
867
- seenTxids,
868
- seenSnapshots,
869
- hydratedResumeState,
870
- syncMode: internalSyncMode,
871
- pendingMatches,
872
- currentBatchMessages,
873
- batchCommitted,
874
- removePendingMatches,
875
- resolveMatchedPendingMatches,
876
- collectionId: config.id,
877
- testHooks: config[ELECTRIC_TEST_HOOKS],
878
- })
879
-
880
- /**
881
- * Wait for a specific transaction ID to be synced
882
- * @param txId The transaction ID to wait for as a number
883
- * @param timeout Optional timeout in milliseconds (defaults to 5000ms)
884
- * @returns Promise that resolves when the txId is synced
885
- */
886
- const awaitTxId: AwaitTxIdFn = async (
887
- txId: Txid,
888
- timeout: number = 5000,
889
- ): Promise<boolean> => {
890
- debug(
891
- `${config.id ? `[${config.id}] ` : ``}awaitTxId called with txid %d`,
892
- txId,
893
- )
894
- if (typeof txId !== `number`) {
895
- throw new ExpectedNumberInAwaitTxIdError(typeof txId, config.id)
896
- }
897
-
898
- // First check if the txid is in the seenTxids store
899
- const hasTxid = seenTxids.state.has(txId)
900
- if (hasTxid) return true
901
-
902
- // Then check if the txid is in any of the seen snapshots
903
- const hasSnapshot = seenSnapshots.state.some((snapshot) =>
904
- isVisibleInSnapshot(txId, snapshot),
905
- )
906
- if (hasSnapshot) return true
907
-
908
- return new Promise((resolve, reject) => {
909
- const cleanup = () => {
910
- clearTimeout(timeoutId)
911
- subSeenTxids.unsubscribe()
912
- subSeenSnapshots.unsubscribe()
913
- }
914
-
915
- const timeoutId = setTimeout(() => {
916
- cleanup()
917
- reject(new TimeoutWaitingForTxIdError(txId, config.id))
918
- }, timeout)
919
-
920
- const subSeenTxids = seenTxids.subscribe(() => {
921
- if (seenTxids.state.has(txId)) {
922
- debug(
923
- `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o`,
924
- txId,
925
- )
926
- cleanup()
927
- resolve(true)
928
- }
929
- })
930
-
931
- const subSeenSnapshots = seenSnapshots.subscribe(() => {
932
- const visibleSnapshot = seenSnapshots.state.find((snapshot) =>
933
- isVisibleInSnapshot(txId, snapshot),
934
- )
935
- if (visibleSnapshot) {
936
- debug(
937
- `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o in snapshot %o`,
938
- txId,
939
- visibleSnapshot,
940
- )
941
- cleanup()
942
- resolve(true)
943
- }
944
- })
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],
945
1133
  })
946
- }
947
-
948
- /**
949
- * Wait for a custom match function to find a matching message
950
- * @param matchFn Function that returns true when a message matches
951
- * @param timeout Optional timeout in milliseconds (defaults to 5000ms)
952
- * @returns Promise that resolves when a matching message is found
953
- */
954
- const awaitMatch: AwaitMatchFn<any> = async (
955
- matchFn: MatchFunction<any>,
956
- timeout: number = 3000,
957
- ): Promise<boolean> => {
958
- debug(
959
- `${config.id ? `[${config.id}] ` : ``}awaitMatch called with custom function`,
960
- )
961
-
962
- return new Promise((resolve, reject) => {
963
- const matchId = Math.random().toString(36)
964
-
965
- const cleanupMatch = () => {
966
- pendingMatches.setState((current) => {
967
- const newMatches = new Map(current)
968
- newMatches.delete(matchId)
969
- return newMatches
970
- })
971
- }
972
-
973
- const onTimeout = () => {
974
- cleanupMatch()
975
- reject(new TimeoutWaitingForMatchError(config.id))
976
- }
977
-
978
- const timeoutId = setTimeout(onTimeout, timeout)
979
-
980
- // We need access to the stream messages to check against the match function
981
- // This will be handled by the sync configuration
982
- const checkMatch = (message: Message<any>) => {
983
- if (matchFn(message)) {
984
- debug(
985
- `${config.id ? `[${config.id}] ` : ``}awaitMatch found matching message, waiting for up-to-date`,
986
- )
987
- // Mark as matched but don't resolve yet - wait for up-to-date
988
- pendingMatches.setState((current) => {
989
- const newMatches = new Map(current)
990
- const existing = newMatches.get(matchId)
991
- if (existing) {
992
- newMatches.set(matchId, { ...existing, matched: true })
993
- }
994
- return newMatches
995
- })
996
- return true
997
- }
998
- return false
999
- }
1000
-
1001
- // Check against current batch messages first to handle race conditions
1002
- for (const message of currentBatchMessages.state) {
1003
- if (matchFn(message)) {
1004
- // If batch is committed (up-to-date already received), resolve immediately
1005
- // just like awaitTxId does when it finds a txid in seenTxids
1006
- if (batchCommitted.state) {
1007
- debug(
1008
- `${config.id ? `[${config.id}] ` : ``}awaitMatch found immediate match in committed batch, resolving immediately`,
1009
- )
1010
- clearTimeout(timeoutId)
1011
- resolve(true)
1012
- return
1013
- }
1014
-
1015
- // If batch is not yet committed, register match and wait for up-to-date
1016
- debug(
1017
- `${config.id ? `[${config.id}] ` : ``}awaitMatch found immediate match in current batch, waiting for up-to-date`,
1018
- )
1019
- pendingMatches.setState((current) => {
1020
- const newMatches = new Map(current)
1021
- newMatches.set(matchId, {
1022
- matchFn: checkMatch,
1023
- resolve,
1024
- reject,
1025
- timeoutId,
1026
- matched: true, // Already matched, will resolve on up-to-date
1027
- })
1028
- return newMatches
1029
- })
1030
- return
1031
- }
1032
- }
1033
-
1034
- // Store the match function for the sync process to use
1035
- // We'll add this to a pending matches store
1036
- pendingMatches.setState((current) => {
1037
- const newMatches = new Map(current)
1038
- newMatches.set(matchId, {
1039
- matchFn: checkMatch,
1040
- resolve,
1041
- reject,
1042
- timeoutId,
1043
- matched: false,
1044
- })
1045
- return newMatches
1046
- })
1047
- })
1048
- }
1134
+ const sync = createSync()
1049
1135
 
1050
1136
  /**
1051
1137
  * Process matching strategy and wait for synchronization
1052
1138
  */
1053
1139
  const processMatchingStrategy = async (
1054
1140
  result: MatchingStrategy,
1141
+ waitForTxId: AwaitTxIdFn,
1055
1142
  ): Promise<void> => {
1056
1143
  // Only wait if result contains txid
1057
1144
  if (result && `txid` in result) {
1058
1145
  const timeout = result.timeout
1059
1146
  // Handle both single txid and array of txids
1060
1147
  if (Array.isArray(result.txid)) {
1061
- await Promise.all(result.txid.map((txid) => awaitTxId(txid, timeout)))
1148
+ await Promise.all(result.txid.map((txid) => waitForTxId(txid, timeout)))
1062
1149
  } else {
1063
- await awaitTxId(result.txid, timeout)
1150
+ await waitForTxId(result.txid, timeout)
1064
1151
  }
1065
1152
  }
1066
1153
  // If result is void/undefined, don't wait - mutation completes immediately
1067
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
+ }
1068
1163
 
1069
1164
  // Create wrapper handlers for direct persistence operations that handle different matching strategies
1070
1165
  const wrappedOnInsert = config.onInsert
@@ -1076,7 +1171,10 @@ export function electricCollectionOptions<T extends Row<unknown>>(
1076
1171
  >,
1077
1172
  ) => {
1078
1173
  const handlerResult = await config.onInsert!(params)
1079
- await processMatchingStrategy(handlerResult)
1174
+ await processMatchingStrategy(
1175
+ handlerResult,
1176
+ getMutationAwaitTxId(params),
1177
+ )
1080
1178
  return handlerResult
1081
1179
  }
1082
1180
  : undefined
@@ -1090,7 +1188,10 @@ export function electricCollectionOptions<T extends Row<unknown>>(
1090
1188
  >,
1091
1189
  ) => {
1092
1190
  const handlerResult = await config.onUpdate!(params)
1093
- await processMatchingStrategy(handlerResult)
1191
+ await processMatchingStrategy(
1192
+ handlerResult,
1193
+ getMutationAwaitTxId(params),
1194
+ )
1094
1195
  return handlerResult
1095
1196
  }
1096
1197
  : undefined
@@ -1104,7 +1205,10 @@ export function electricCollectionOptions<T extends Row<unknown>>(
1104
1205
  >,
1105
1206
  ) => {
1106
1207
  const handlerResult = await config.onDelete!(params)
1107
- await processMatchingStrategy(handlerResult)
1208
+ await processMatchingStrategy(
1209
+ handlerResult,
1210
+ getMutationAwaitTxId(params),
1211
+ )
1108
1212
  return handlerResult
1109
1213
  }
1110
1214
  : undefined
@@ -1118,37 +1222,67 @@ export function electricCollectionOptions<T extends Row<unknown>>(
1118
1222
  ...restConfig
1119
1223
  } = config
1120
1224
 
1121
- const options = {
1122
- ...restConfig,
1123
- syncMode: finalSyncMode,
1124
- sync: {
1125
- ...sync,
1126
- exportSyncMeta: (): ElectricSyncMeta => ({
1127
- version: 1,
1128
- ...(hydratedResumeState.state
1129
- ? { resume: hydratedResumeState.state }
1130
- : {}),
1131
- seenTxids: Array.from(seenTxids.state).sort((a, b) => a - b),
1132
- }),
1133
- importSyncMeta: (meta: unknown): void => {
1134
- const parsed = parseElectricSyncMeta(meta)
1135
- if (!parsed) {
1136
- 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)
1137
1252
  }
1138
-
1139
- hydratedResumeState.setState(() => parsed.resume)
1140
- seenTxids.setState(() => new Set(parsed.seenTxids))
1253
+ return source.sync(params)
1141
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),
1142
1269
  mergeSyncMeta: mergeElectricSyncMeta,
1143
1270
  },
1271
+ createBoundSync,
1272
+ )
1273
+ const options = {
1274
+ ...restConfig,
1275
+ syncMode: finalSyncMode,
1276
+ sync: syncTemplate,
1144
1277
  onInsert: wrappedOnInsert,
1145
1278
  onUpdate: wrappedOnUpdate,
1146
1279
  onDelete: wrappedOnDelete,
1147
- utils: {
1148
- awaitTxId,
1149
- awaitMatch,
1150
- },
1280
+ utils: utilityTemplate,
1151
1281
  }
1282
+ Object.defineProperty(options, `utils`, {
1283
+ enumerable: true,
1284
+ get: () => ({ ...utilityTemplate }),
1285
+ })
1152
1286
 
1153
1287
  return withCollectionConfigFactory(options, () =>
1154
1288
  (
@@ -1166,356 +1300,350 @@ function createElectricSync<T extends Row<unknown>>(
1166
1300
  shapeOptions: ShapeStreamOptions<GetExtensions<T>>,
1167
1301
  options: {
1168
1302
  syncMode: ElectricSyncMode
1169
- seenTxids: Store<Set<Txid>>
1170
- seenSnapshots: Store<Array<PostgresSnapshot>>
1171
- hydratedResumeState: Store<ElectricResumeState | undefined>
1172
- pendingMatches: Store<
1173
- Map<
1174
- string,
1175
- {
1176
- matchFn: (message: Message<T>) => boolean
1177
- resolve: (value: boolean) => void
1178
- reject: (error: Error) => void
1179
- timeoutId: ReturnType<typeof setTimeout>
1180
- matched: boolean
1181
- }
1182
- >
1183
- >
1184
- currentBatchMessages: Store<Array<Message<T>>>
1185
- batchCommitted: Store<boolean>
1186
- removePendingMatches: (matchIds: Array<string>) => void
1187
- resolveMatchedPendingMatches: () => void
1303
+ getLifecycle: (collection: object) => ElectricLifecycle<T>
1188
1304
  collectionId?: string
1189
1305
  testHooks?: ElectricTestHooks
1190
1306
  },
1191
1307
  ): SyncConfig<T> {
1192
- const {
1193
- seenTxids,
1194
- seenSnapshots,
1195
- hydratedResumeState,
1196
- syncMode,
1197
- pendingMatches,
1198
- currentBatchMessages,
1199
- batchCommitted,
1200
- removePendingMatches,
1201
- resolveMatchedPendingMatches,
1202
- collectionId,
1203
- testHooks,
1204
- } = options
1205
- const MAX_BATCH_MESSAGES = 1000 // Safety limit for message buffer
1206
-
1207
- // Store for the relation schema information
1208
- const relationSchema = new Store<string | undefined>(undefined)
1209
-
1210
- const tagCache = new Map<MoveTag, ParsedMoveTag>()
1211
-
1212
- // Parses a tag string into a ParsedMoveTag.
1213
- // It memoizes the result parsed tag such that future calls
1214
- // for the same tag string return the same ParsedMoveTag array.
1215
- const parseTag = (tag: MoveTag): ParsedMoveTag => {
1216
- const cachedTag = tagCache.get(tag)
1217
- if (cachedTag) {
1218
- 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
1219
1328
  }
1220
1329
 
1221
- const parsedTag = parseTagString(tag)
1222
- tagCache.set(tag, parsedTag)
1223
- return parsedTag
1224
- }
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
+ }
1225
1351
 
1226
- // Tag tracking state
1227
- const rowTagSets = new Map<RowId, Set<MoveTag>>()
1228
- const tagIndex: TagIndex = []
1229
- 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
+ }
1230
1368
 
1231
- // DNF state: active_conditions are per-row, disjunct_positions are global
1232
- // (fixed by the shape's WHERE clause, derived once from the first tagged message).
1233
- const rowActiveConditions = new Map<RowId, ActiveConditions>()
1234
- 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
+ }
1235
1377
 
1236
- /**
1237
- * Initialize the tag index with the correct length
1238
- */
1239
- const initializeTagIndex = (length: number): void => {
1240
- if (tagIndex.length < length) {
1241
- // Extend the index array to the required length
1242
- for (let i = tagIndex.length; i < length; i++) {
1243
- tagIndex[i] = new Map()
1378
+ rowTagSet.add(tag)
1379
+ addTagToIndex(parsedTag, rowId, tagIndex, tagLength)
1244
1380
  }
1245
1381
  }
1246
- }
1247
1382
 
1248
- /**
1249
- * Add tags to a row and update the tag index
1250
- */
1251
- const addTagsToRow = (
1252
- tags: Array<MoveTag>,
1253
- rowId: RowId,
1254
- rowTagSet: Set<MoveTag>,
1255
- ): void => {
1256
- for (const tag of tags) {
1257
- const parsedTag = parseTag(tag)
1258
-
1259
- // 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 => {
1260
1391
  if (tagLength === undefined) {
1261
- tagLength = getTagLength(parsedTag)
1262
- initializeTagIndex(tagLength)
1392
+ return
1263
1393
  }
1264
1394
 
1265
- // Validate tag length matches
1266
- const currentTagLength = getTagLength(parsedTag)
1267
- if (currentTagLength !== tagLength) {
1268
- debug(
1269
- `${collectionId ? `[${collectionId}] ` : ``}Tag length mismatch: expected ${tagLength}, got ${currentTagLength}`,
1270
- )
1271
- 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)
1272
1404
  }
1273
-
1274
- rowTagSet.add(tag)
1275
- addTagToIndex(parsedTag, rowId, tagIndex, tagLength)
1276
1405
  }
1277
- }
1278
1406
 
1279
- /**
1280
- * Remove tags from a row and update the tag index
1281
- */
1282
- const removeTagsFromRow = (
1283
- removedTags: Array<MoveTag>,
1284
- rowId: RowId,
1285
- rowTagSet: Set<MoveTag>,
1286
- ): void => {
1287
- if (tagLength === undefined) {
1288
- return
1289
- }
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)!
1290
1421
 
1291
- for (const tag of removedTags) {
1292
- const parsedTag = parseTag(tag)
1293
- rowTagSet.delete(tag)
1294
- removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength)
1295
- // We aggresively evict the tag from the cache
1296
- // if this tag is shared with another row
1297
- // and is not removed from that other row
1298
- // then next time we encounter the tag it will be parsed again
1299
- tagCache.delete(tag)
1300
- }
1301
- }
1422
+ // Add new tags
1423
+ if (tags) {
1424
+ addTagsToRow(tags, rowId, rowTagSet)
1302
1425
 
1303
- /**
1304
- * Process tags for a change message (add and remove tags)
1305
- */
1306
- const processTagsForChangeMessage = (
1307
- tags: Array<MoveTag> | undefined,
1308
- removedTags: Array<MoveTag> | undefined,
1309
- rowId: RowId,
1310
- activeConditions?: ActiveConditions,
1311
- ): Set<MoveTag> => {
1312
- // Initialize tag set for this row if it doesn't exist (needed for checking deletion)
1313
- if (!rowTagSets.has(rowId)) {
1314
- rowTagSets.set(rowId, new Set())
1315
- }
1316
- 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
+ }
1317
1432
 
1318
- // Add new tags
1319
- if (tags) {
1320
- addTagsToRow(tags, rowId, rowTagSet)
1433
+ // Remove tags
1434
+ if (removedTags) {
1435
+ removeTagsFromRow(removedTags, rowId, rowTagSet)
1436
+ }
1321
1437
 
1322
- // Derive disjunct positions once — they are fixed by the shape's WHERE clause.
1323
- if (disjunctPositions === undefined) {
1324
- const parsedTags = tags.map(parseTag)
1325
- disjunctPositions = deriveDisjunctPositions(parsedTags)
1438
+ // Store active conditions if provided (overwrite on re-send)
1439
+ if (activeConditions && activeConditions.length > 0) {
1440
+ rowActiveConditions.set(rowId, [...activeConditions])
1326
1441
  }
1327
- }
1328
1442
 
1329
- // Remove tags
1330
- if (removedTags) {
1331
- removeTagsFromRow(removedTags, rowId, rowTagSet)
1443
+ return rowTagSet
1332
1444
  }
1333
1445
 
1334
- // Store active conditions if provided (overwrite on re-send)
1335
- if (activeConditions && activeConditions.length > 0) {
1336
- 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
1337
1455
  }
1338
1456
 
1339
- return rowTagSet
1340
- }
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
+ }
1341
1465
 
1342
- /**
1343
- * Clear all tag tracking state (used when truncating)
1344
- */
1345
- const clearTagTrackingState = (): void => {
1346
- rowTagSets.clear()
1347
- tagIndex.length = 0
1348
- tagLength = undefined
1349
- rowActiveConditions.clear()
1350
- disjunctPositions = undefined
1351
- }
1466
+ const rowTagSet = rowTagSets.get(rowId)
1467
+ if (!rowTagSet) {
1468
+ return
1469
+ }
1352
1470
 
1353
- /**
1354
- * Remove all tags for a row from both the tag set and the index
1355
- * Used when a row is deleted
1356
- */
1357
- const clearTagsForRow = (rowId: RowId): void => {
1358
- if (tagLength === undefined) {
1359
- return
1360
- }
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
+ }
1361
1480
 
1362
- const rowTagSet = rowTagSets.get(rowId)
1363
- if (!rowTagSet) {
1364
- return
1481
+ // Remove the row from the tag sets map
1482
+ rowTagSets.delete(rowId)
1483
+ rowActiveConditions.delete(rowId)
1365
1484
  }
1366
1485
 
1367
- // Remove each tag from the index
1368
- for (const tag of rowTagSet) {
1369
- const parsedTag = parseTag(tag)
1370
- const currentTagLength = getTagLength(parsedTag)
1371
- if (currentTagLength === tagLength) {
1372
- 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
1373
1497
  }
1374
- tagCache.delete(tag)
1375
- }
1376
-
1377
- // Remove the row from the tag sets map
1378
- rowTagSets.delete(rowId)
1379
- rowActiveConditions.delete(rowId)
1380
- }
1381
1498
 
1382
- /**
1383
- * Remove matching tags from a row based on a pattern
1384
- * Returns true if the row should be deleted (no longer visible)
1385
- */
1386
- const removeMatchingTagsFromRow = (
1387
- rowId: RowId,
1388
- pattern: MovePattern,
1389
- ): boolean => {
1390
- const rowTagSet = rowTagSets.get(rowId)
1391
- if (!rowTagSet) {
1392
- return false
1393
- }
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
+ }
1394
1519
 
1395
- // DNF mode: check visibility using active conditions.
1396
- // Tag index entries are preserved so that move-in can re-activate positions.
1397
- const activeConditions = rowActiveConditions.get(rowId)
1398
- if (activeConditions && disjunctPositions) {
1399
- // Set the condition at this pattern's position to false
1400
- activeConditions[pattern.pos] = false
1401
-
1402
- if (!rowVisible(activeConditions, disjunctPositions)) {
1403
- // Row is no longer visible — clean up all state including tag index
1404
- for (const tag of rowTagSet) {
1405
- 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)
1406
1526
  removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength!)
1407
- tagCache.delete(tag)
1408
1527
  }
1528
+ }
1529
+
1530
+ if (rowTagSet.size === 0) {
1409
1531
  rowTagSets.delete(rowId)
1410
- rowActiveConditions.delete(rowId)
1411
1532
  return true
1412
1533
  }
1534
+
1413
1535
  return false
1414
1536
  }
1415
1537
 
1416
- // Simple shape (no subquery dependencies — server sends no active_conditions):
1417
- // Remove matching tags and delete if tag set is empty
1418
- for (const tag of rowTagSet) {
1419
- const parsedTag = parseTag(tag)
1420
- if (tagMatchesPattern(parsedTag, pattern)) {
1421
- rowTagSet.delete(tag)
1422
- 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
1423
1553
  }
1424
- }
1425
-
1426
- if (rowTagSet.size === 0) {
1427
- rowTagSets.delete(rowId)
1428
- return true
1429
- }
1430
-
1431
- return false
1432
- }
1433
1554
 
1434
- /**
1435
- * Process move-out event: remove matching tags from rows and delete rows with empty tag sets
1436
- */
1437
- const processMoveOutEvent = (
1438
- patterns: Array<MovePattern>,
1439
- begin: () => void,
1440
- write: (message: ChangeMessageOrDeleteKeyMessage<T>) => void,
1441
- transactionStarted: boolean,
1442
- ): boolean => {
1443
- if (tagLength === undefined) {
1444
- debug(
1445
- `${collectionId ? `[${collectionId}] ` : ``}Received move-out message but no tag length set yet, ignoring`,
1446
- )
1447
- return transactionStarted
1448
- }
1555
+ let txStarted = transactionStarted
1449
1556
 
1450
- let txStarted = transactionStarted
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)
1451
1561
 
1452
- // Process all patterns and collect rows to delete
1453
- for (const pattern of patterns) {
1454
- // Find all rows that match this pattern
1455
- 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
+ }
1456
1569
 
1457
- for (const rowId of affectedRowIds) {
1458
- if (removeMatchingTagsFromRow(rowId, pattern)) {
1459
- // Delete rows with empty tag sets
1460
- if (!txStarted) {
1461
- begin()
1462
- txStarted = true
1570
+ write({
1571
+ type: `delete`,
1572
+ key: rowId,
1573
+ })
1574
+ onDelete(rowId)
1463
1575
  }
1464
-
1465
- write({
1466
- type: `delete`,
1467
- key: rowId,
1468
- })
1469
1576
  }
1470
1577
  }
1471
- }
1472
-
1473
- return txStarted
1474
- }
1475
1578
 
1476
- /**
1477
- * Process move-in event: re-activate conditions for rows matching the patterns.
1478
- * This is a silent operation — no messages are emitted to the collection.
1479
- */
1480
- const processMoveInEvent = (patterns: Array<MovePattern>): void => {
1481
- if (tagLength === undefined) {
1482
- debug(
1483
- `${collectionId ? `[${collectionId}] ` : ``}Received move-in message but no tag length set yet, ignoring`,
1484
- )
1485
- return
1579
+ return txStarted
1486
1580
  }
1487
1581
 
1488
- for (const pattern of patterns) {
1489
- 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)
1490
1596
 
1491
- for (const rowId of affectedRowIds) {
1492
- const activeConditions = rowActiveConditions.get(rowId)
1493
- if (activeConditions) {
1494
- 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
+ }
1495
1602
  }
1496
1603
  }
1497
1604
  }
1498
- }
1499
-
1500
- /**
1501
- * Get the sync metadata for insert operations
1502
- * @returns Record containing relation information
1503
- */
1504
- const getSyncMetadata = (): Record<string, unknown> => {
1505
- // Use the stored schema if available, otherwise default to 'public'
1506
- const schema = relationSchema.state || `public`
1507
1605
 
1508
1606
  return {
1509
- relation: shapeOptions.params?.table
1510
- ? [schema, shapeOptions.params.table]
1511
- : undefined,
1607
+ hasTags: () => tagLength !== undefined,
1608
+ processTagsForChangeMessage,
1609
+ clearTagTrackingState,
1610
+ clearTagsForRow,
1611
+ processMoveOutEvent,
1612
+ processMoveInEvent,
1512
1613
  }
1513
1614
  }
1514
-
1515
- 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
+ >()
1516
1621
 
1517
1622
  return {
1623
+ getSyncMetadata: () => ({
1624
+ relation: shapeOptions.params?.table
1625
+ ? [relationSchema || `public`, shapeOptions.params.table]
1626
+ : undefined,
1627
+ }),
1518
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
+
1519
1647
  const {
1520
1648
  begin,
1521
1649
  write,
@@ -1551,9 +1679,15 @@ function createElectricSync<T extends Row<unknown>>(
1551
1679
  return parseElectricResumeState(persistedResumeState)
1552
1680
  }
1553
1681
 
1682
+ const persistedMetadata = metadata as
1683
+ | ElectricSyncMetadataWithHydration
1684
+ | undefined
1685
+ const scanPersisted = persistedMetadata?.row.scanPersisted
1686
+ const whenHydrated = persistedMetadata?.row.whenHydrated
1687
+
1554
1688
  const persistedResumeState = getNewestElectricResumeState(
1555
1689
  readPersistedResumeState(),
1556
- hydratedResumeState.state,
1690
+ lifecycle.resumeState,
1557
1691
  )
1558
1692
  const shapeIdentity = getStableShapeIdentity({
1559
1693
  url: shapeOptions.url,
@@ -1562,11 +1696,52 @@ function createElectricSync<T extends Row<unknown>>(
1562
1696
  const hasIncompatiblePersistedResume =
1563
1697
  persistedResumeState?.kind === `resume` &&
1564
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))
1565
1717
  const canUsePersistedResume =
1566
1718
  shapeOptions.offset === undefined &&
1567
1719
  shapeOptions.handle === undefined &&
1568
1720
  persistedResumeState?.kind === `resume` &&
1569
- !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
1570
1745
 
1571
1746
  // Wrap markReady to wait for test hook in progressive mode
1572
1747
  let progressiveReadyGate: Promise<void> | null = null
@@ -1609,28 +1784,25 @@ function createElectricSync<T extends Row<unknown>>(
1609
1784
  }
1610
1785
  }
1611
1786
 
1612
- // Cleanup pending matches on abort
1613
1787
  abortController.signal.addEventListener(`abort`, () => {
1614
- pendingMatches.setState((current) => {
1615
- current.forEach((match) => {
1616
- clearTimeout(match.timeoutId)
1617
- match.reject(new StreamAbortedError())
1618
- })
1619
- return new Map() // Clear all pending matches
1620
- })
1788
+ lifecycle.retire(lifecycleEpoch)
1621
1789
  })
1622
1790
 
1623
1791
  const stream = new ShapeStream({
1624
1792
  ...shapeOptions,
1625
- // In on-demand mode, we only want to sync changes, so we set the log to `changes_only`
1626
- 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,
1627
1799
  // In on-demand mode, we only need the changes from the point of time the collection was created
1628
1800
  // so we default to `now` when there is no saved offset.
1629
1801
  offset:
1630
1802
  shapeOptions.offset ??
1631
1803
  (canUsePersistedResume
1632
1804
  ? (persistedResumeState.offset as Offset)
1633
- : syncMode === `on-demand`
1805
+ : syncMode === `on-demand` && !needsFullSnapshot
1634
1806
  ? `now`
1635
1807
  : undefined),
1636
1808
  handle:
@@ -1670,12 +1842,19 @@ function createElectricSync<T extends Row<unknown>>(
1670
1842
  // resume starts from an already-committed stream offset, so the next
1671
1843
  // up-to-date message must not run the initial atomic swap again.
1672
1844
  let hasReceivedUpToDate =
1673
- 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
1674
1851
 
1675
1852
  // Progressive mode state
1676
1853
  // Helper to determine if we're buffering the initial sync
1677
1854
  const isBufferingInitialSync = () =>
1678
- syncMode === `progressive` && !hasReceivedUpToDate
1855
+ syncMode === `progressive` &&
1856
+ !hasReceivedUpToDate &&
1857
+ !isResettingSnapshot
1679
1858
  const bufferedMessages: Array<Message<T>> = [] // Buffer change messages during initial sync
1680
1859
 
1681
1860
  // Track keys that have been synced to handle overlapping subset queries.
@@ -1683,8 +1862,12 @@ function createElectricSync<T extends Row<unknown>>(
1683
1862
  // for each response. We convert subsequent inserts to updates to avoid
1684
1863
  // duplicate key errors when the row's data has changed between requests.
1685
1864
  const syncedKeys = new Set<string | number>()
1865
+ let resumeInvalid = false
1686
1866
 
1687
1867
  const stageResumeMetadata = () => {
1868
+ if (!isActiveLifecycle() || resumeInvalid) {
1869
+ return
1870
+ }
1688
1871
  const shapeHandle = stream.shapeHandle
1689
1872
  const lastOffset = stream.lastOffset
1690
1873
  if (!shapeHandle || lastOffset === `-1`) {
@@ -1697,8 +1880,9 @@ function createElectricSync<T extends Row<unknown>>(
1697
1880
  handle: shapeHandle,
1698
1881
  shapeId: shapeIdentity,
1699
1882
  updatedAt: Date.now(),
1883
+ requiresTagState: tagState.hasTags(),
1700
1884
  }
1701
- hydratedResumeState.setState(() => resumeState)
1885
+ lifecycle.resumeState = resumeState
1702
1886
  metadata?.collection.set(`electric:resume`, resumeState)
1703
1887
  }
1704
1888
 
@@ -1707,7 +1891,7 @@ function createElectricSync<T extends Row<unknown>>(
1707
1891
  kind: `reset`,
1708
1892
  updatedAt: Date.now(),
1709
1893
  }
1710
- hydratedResumeState.setState(() => resetState)
1894
+ lifecycle.resumeState = resetState
1711
1895
 
1712
1896
  if (metadata) {
1713
1897
  begin({ immediate: true })
@@ -1716,7 +1900,11 @@ function createElectricSync<T extends Row<unknown>>(
1716
1900
  }
1717
1901
  }
1718
1902
 
1719
- if (hasIncompatiblePersistedResume) {
1903
+ if (
1904
+ hasIncompatiblePersistedResume ||
1905
+ hasUnverifiablePersistedResume ||
1906
+ (needsFullSnapshot && persistedResumeState.kind === `resume`)
1907
+ ) {
1720
1908
  commitResetResumeMetadataImmediately()
1721
1909
  }
1722
1910
 
@@ -1796,32 +1984,85 @@ function createElectricSync<T extends Row<unknown>>(
1796
1984
  signal: abortController.signal,
1797
1985
  })
1798
1986
 
1799
- 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
+
1800
2056
  // Track commit point type - up-to-date takes precedence as it also triggers progressive mode atomic swap
1801
2057
  let commitPoint: `up-to-date` | `subset-end` | null = null
1802
2058
 
1803
- // Don't clear the buffer between batches - this preserves messages for awaitMatch
1804
- // to find even if multiple batches arrive before awaitMatch is called.
1805
- // The buffer is naturally limited by MAX_BATCH_MESSAGES (oldest messages are dropped).
1806
- // Reset batchCommitted since we're starting a new batch
1807
- batchCommitted.setState(() => false)
2059
+ lifecycle.beginMatchGeneration(messages)
1808
2060
 
1809
2061
  for (const message of messages) {
1810
- // Add message to current batch buffer (for race condition handling)
1811
- if (
1812
- isChangeMessage(message) ||
1813
- isMoveOutMessage(message) ||
1814
- isMoveInMessage(message)
1815
- ) {
1816
- currentBatchMessages.setState((currentBuffer) => {
1817
- const newBuffer = [...currentBuffer, message]
1818
- // Limit buffer size for safety
1819
- if (newBuffer.length > MAX_BATCH_MESSAGES) {
1820
- newBuffer.splice(0, newBuffer.length - MAX_BATCH_MESSAGES)
1821
- }
1822
- return newBuffer
1823
- })
1824
- }
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
1825
2066
 
1826
2067
  // Check for txids in the message and add them to our store
1827
2068
  // Skip during buffered initial sync in progressive mode (txids will be extracted during atomic swap)
@@ -1834,34 +2075,31 @@ function createElectricSync<T extends Row<unknown>>(
1834
2075
  message.headers.txids?.forEach((txid) => newTxids.add(txid))
1835
2076
  }
1836
2077
 
1837
- // Check pending matches against this message
1838
- // Note: matchFn will mark matches internally, we don't resolve here
1839
- const matchesToRemove: Array<string> = []
1840
- pendingMatches.state.forEach((match, matchId) => {
1841
- if (!match.matched) {
1842
- try {
1843
- match.matchFn(message)
1844
- } catch (err) {
1845
- // If matchFn throws, clean up and reject the promise
1846
- clearTimeout(match.timeoutId)
1847
- match.reject(
1848
- err instanceof Error ? err : new Error(String(err)),
1849
- )
1850
- matchesToRemove.push(matchId)
1851
- 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
1852
2091
  }
2092
+ if (!receivesCompleteRows) continue
1853
2093
  }
1854
- })
1855
-
1856
- // Remove matches that errored
1857
- removePendingMatches(matchesToRemove)
2094
+ pendingPresence.set(rowId, operation !== `delete`)
2095
+ }
1858
2096
 
1859
2097
  if (isChangeMessage(message)) {
1860
2098
  // Check if the message contains schema information
1861
2099
  const schema = message.headers.schema
1862
2100
  if (schema && typeof schema === `string`) {
1863
2101
  // Store the schema for future use if it's a valid string
1864
- relationSchema.setState(() => schema)
2102
+ relationSchema = schema
1865
2103
  }
1866
2104
 
1867
2105
  // In buffered initial sync of progressive mode, buffer messages instead of writing
@@ -1907,6 +2145,10 @@ function createElectricSync<T extends Row<unknown>>(
1907
2145
  begin,
1908
2146
  write,
1909
2147
  transactionStarted,
2148
+ (rowId) => {
2149
+ pendingPresence.set(rowId, false)
2150
+ syncedKeys.delete(rowId)
2151
+ },
1910
2152
  )
1911
2153
  }
1912
2154
  } else if (isMoveInMessage(message)) {
@@ -1937,6 +2179,10 @@ function createElectricSync<T extends Row<unknown>>(
1937
2179
 
1938
2180
  // Clear synced keys tracking since we're starting fresh
1939
2181
  syncedKeys.clear()
2182
+ pendingPresence.clear()
2183
+ usesBaseline = false
2184
+ isResettingSnapshot = true
2185
+ resetGeneration++
1940
2186
 
1941
2187
  // Reset the loadSubset deduplication state since we're starting fresh
1942
2188
  // This ensures that previously loaded predicates don't prevent refetching after truncate
@@ -1949,9 +2195,20 @@ function createElectricSync<T extends Row<unknown>>(
1949
2195
  }
1950
2196
  }
1951
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
+
1952
2206
  if (commitPoint !== null) {
1953
2207
  let applied: SyncAppliedReceipt = true
1954
2208
  const wasBufferingInitialSync = isBufferingInitialSync()
2209
+ const finishesReset =
2210
+ isResettingSnapshot && commitPoint === `up-to-date`
2211
+ const finishingResetGeneration = resetGeneration
1955
2212
  // PROGRESSIVE MODE: Atomic swap on first up-to-date (not subset-end)
1956
2213
  // EXCEPTION: Skip atomic swap if a transaction is already started (e.g., from must-refetch).
1957
2214
  // In that case, do a normal commit to properly close the existing transaction.
@@ -1996,7 +2253,13 @@ function createElectricSync<T extends Row<unknown>>(
1996
2253
  bufferedMsg.headers.patterns,
1997
2254
  begin,
1998
2255
  write,
1999
- 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
+ },
2000
2263
  )
2001
2264
  } else if (isMoveInMessage(bufferedMsg)) {
2002
2265
  // Process buffered move-in messages during atomic swap
@@ -2019,7 +2282,9 @@ function createElectricSync<T extends Row<unknown>>(
2019
2282
  // Normal mode or on-demand: commit transaction if one was started
2020
2283
  // Both up-to-date and subset-end trigger a commit
2021
2284
  if (transactionStarted) {
2022
- stageResumeMetadata()
2285
+ if (!isResettingSnapshot || finishesReset) {
2286
+ stageResumeMetadata()
2287
+ }
2023
2288
  applied = commit()
2024
2289
  transactionStarted = false
2025
2290
  } else if (commitPoint === `up-to-date` && metadata) {
@@ -2039,47 +2304,78 @@ function createElectricSync<T extends Row<unknown>>(
2039
2304
  )
2040
2305
  }
2041
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
+
2042
2320
  // Track that we've received the first up-to-date for progressive mode
2043
2321
  if (commitPoint === `up-to-date`) {
2044
2322
  hasReceivedUpToDate = true
2045
2323
  }
2046
2324
 
2047
- // Always commit txids when we receive up-to-date, regardless of transaction state
2048
- seenTxids.setState((currentTxids) => {
2049
- const clonedSeen = new Set<Txid>(currentTxids)
2050
- if (newTxids.size > 0) {
2051
- debug(
2052
- `${collectionId ? `[${collectionId}] ` : ``}new txids synced from pg %O`,
2053
- Array.from(newTxids),
2054
- )
2055
- }
2056
- newTxids.forEach((txid) => clonedSeen.add(txid))
2057
- newTxids.clear()
2058
- return clonedSeen
2059
- })
2060
-
2061
- // Always commit snapshots when we receive up-to-date, regardless of transaction state
2062
- seenSnapshots.setState((currentSnapshots) => {
2063
- const seen = [...currentSnapshots, ...newSnapshots]
2064
- newSnapshots.forEach((snapshot) =>
2065
- debug(
2066
- `${collectionId ? `[${collectionId}] ` : ``}new snapshot synced from pg %o`,
2067
- snapshot,
2068
- ),
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),
2069
2332
  )
2070
- newSnapshots.length = 0
2071
- return seen
2072
- })
2073
-
2074
- // Resolve all matched pending matches on up-to-date or subset-end
2075
- // Set batchCommitted BEFORE resolving to avoid timing window where late awaitMatch
2076
- // calls could register as "matched" after resolver pass already ran
2077
- 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
+ }
2078
2346
 
2079
- resolveMatchedPendingMatches()
2347
+ unsubscribeStream = stream.subscribe((messages: Array<Message<T>>) => {
2348
+ if (!areResumeKeysReady) {
2349
+ pendingResumeBatches.push([...messages])
2350
+ return
2080
2351
  }
2352
+ processMessages(messages)
2081
2353
  })
2082
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
+
2083
2379
  // Return the deduplicated loadSubset if available (on-demand or progressive mode)
2084
2380
  // The loadSubset method is auto-bound, so it can be safely returned directly
2085
2381
  return {
@@ -2093,13 +2389,12 @@ function createElectricSync<T extends Row<unknown>>(
2093
2389
  unsubscribeStream()
2094
2390
  // Abort the abort controller to stop the stream
2095
2391
  abortController.abort()
2392
+ pendingResumeBatches.length = 0
2096
2393
  // Reset deduplication tracking so collection can load fresh data if restarted
2097
2394
  loadSubsetDedupe?.reset()
2098
- hydratedResumeState.setState(() => undefined)
2395
+ lifecycle.retire(lifecycleEpoch)
2099
2396
  },
2100
2397
  }
2101
2398
  },
2102
- // Expose the getSyncMetadata function
2103
- getSyncMetadata,
2104
2399
  }
2105
2400
  }