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