@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/dist/cjs/electric.cjs +648 -435
- package/dist/cjs/electric.cjs.map +1 -1
- package/dist/cjs/sql-compiler.cjs +5 -4
- package/dist/cjs/sql-compiler.cjs.map +1 -1
- package/dist/esm/electric.js +650 -437
- package/dist/esm/electric.js.map +1 -1
- package/dist/esm/sql-compiler.js +5 -4
- package/dist/esm/sql-compiler.js.map +1 -1
- package/package.json +4 -3
- package/src/electric.ts +942 -647
- package/src/sql-compiler.ts +18 -4
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
|
-
|
|
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
|
-
|
|
808
|
-
|
|
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
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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) =>
|
|
1148
|
+
await Promise.all(result.txid.map((txid) => waitForTxId(txid, timeout)))
|
|
1062
1149
|
} else {
|
|
1063
|
-
await
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
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
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
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
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
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
|
-
|
|
1262
|
-
initializeTagIndex(tagLength)
|
|
1392
|
+
return
|
|
1263
1393
|
}
|
|
1264
1394
|
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
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
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
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
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
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
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1433
|
+
// Remove tags
|
|
1434
|
+
if (removedTags) {
|
|
1435
|
+
removeTagsFromRow(removedTags, rowId, rowTagSet)
|
|
1436
|
+
}
|
|
1321
1437
|
|
|
1322
|
-
//
|
|
1323
|
-
if (
|
|
1324
|
-
|
|
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
|
-
|
|
1330
|
-
if (removedTags) {
|
|
1331
|
-
removeTagsFromRow(removedTags, rowId, rowTagSet)
|
|
1443
|
+
return rowTagSet
|
|
1332
1444
|
}
|
|
1333
1445
|
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
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
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
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
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1481
|
+
// Remove the row from the tag sets map
|
|
1482
|
+
rowTagSets.delete(rowId)
|
|
1483
|
+
rowActiveConditions.delete(rowId)
|
|
1365
1484
|
}
|
|
1366
1485
|
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
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
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
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
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
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
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
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
|
-
|
|
1489
|
-
|
|
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
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
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
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1607
|
+
hasTags: () => tagLength !== undefined,
|
|
1608
|
+
processTagsForChangeMessage,
|
|
1609
|
+
clearTagTrackingState,
|
|
1610
|
+
clearTagsForRow,
|
|
1611
|
+
processMoveOutEvent,
|
|
1612
|
+
processMoveInEvent,
|
|
1512
1613
|
}
|
|
1513
1614
|
}
|
|
1514
|
-
|
|
1515
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1626
|
-
|
|
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` &&
|
|
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` &&
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
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
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
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
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2395
|
+
lifecycle.retire(lifecycleEpoch)
|
|
2099
2396
|
},
|
|
2100
2397
|
}
|
|
2101
2398
|
},
|
|
2102
|
-
// Expose the getSyncMetadata function
|
|
2103
|
-
getSyncMetadata,
|
|
2104
2399
|
}
|
|
2105
2400
|
}
|