@tanstack/electric-db-collection 0.3.18 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/electric.cjs +113 -35
- package/dist/cjs/electric.cjs.map +1 -1
- package/dist/esm/electric.js +114 -36
- package/dist/esm/electric.js.map +1 -1
- package/package.json +2 -2
- package/src/electric.ts +186 -47
package/src/electric.ts
CHANGED
|
@@ -6,7 +6,11 @@ import {
|
|
|
6
6
|
} from '@electric-sql/client'
|
|
7
7
|
import { Store } from '@tanstack/store'
|
|
8
8
|
import DebugModule from 'debug'
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
DeduplicatedLoadSubset,
|
|
11
|
+
and,
|
|
12
|
+
withCollectionConfigFactory,
|
|
13
|
+
} from '@tanstack/db'
|
|
10
14
|
import {
|
|
11
15
|
ExpectedNumberInAwaitTxIdError,
|
|
12
16
|
StreamAbortedError,
|
|
@@ -87,6 +91,132 @@ export interface ElectricTestHooks {
|
|
|
87
91
|
*/
|
|
88
92
|
export type Txid = number
|
|
89
93
|
|
|
94
|
+
type ElectricResumeState =
|
|
95
|
+
| {
|
|
96
|
+
kind: `resume`
|
|
97
|
+
offset: string
|
|
98
|
+
handle: string
|
|
99
|
+
shapeId: string
|
|
100
|
+
updatedAt: number
|
|
101
|
+
}
|
|
102
|
+
| {
|
|
103
|
+
kind: `reset`
|
|
104
|
+
updatedAt: number
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type ElectricSyncMeta = {
|
|
108
|
+
version: 1
|
|
109
|
+
resume?: ElectricResumeState
|
|
110
|
+
seenTxids: Array<Txid>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseElectricResumeState(
|
|
114
|
+
value: unknown,
|
|
115
|
+
): ElectricResumeState | undefined {
|
|
116
|
+
if (!value || typeof value !== `object`) {
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const record = value as Record<string, unknown>
|
|
121
|
+
if (
|
|
122
|
+
record.kind === `resume` &&
|
|
123
|
+
typeof record.offset === `string` &&
|
|
124
|
+
typeof record.handle === `string` &&
|
|
125
|
+
typeof record.shapeId === `string` &&
|
|
126
|
+
typeof record.updatedAt === `number` &&
|
|
127
|
+
Number.isFinite(record.updatedAt)
|
|
128
|
+
) {
|
|
129
|
+
return {
|
|
130
|
+
kind: `resume`,
|
|
131
|
+
offset: record.offset,
|
|
132
|
+
handle: record.handle,
|
|
133
|
+
shapeId: record.shapeId,
|
|
134
|
+
updatedAt: record.updatedAt,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (
|
|
139
|
+
record.kind === `reset` &&
|
|
140
|
+
typeof record.updatedAt === `number` &&
|
|
141
|
+
Number.isFinite(record.updatedAt)
|
|
142
|
+
) {
|
|
143
|
+
return {
|
|
144
|
+
kind: `reset`,
|
|
145
|
+
updatedAt: record.updatedAt,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return undefined
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseElectricSyncMeta(value: unknown): ElectricSyncMeta | undefined {
|
|
153
|
+
if (!value || typeof value !== `object`) {
|
|
154
|
+
return undefined
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const record = value as Record<string, unknown>
|
|
158
|
+
if (
|
|
159
|
+
record.version !== 1 ||
|
|
160
|
+
!Array.isArray(record.seenTxids) ||
|
|
161
|
+
!record.seenTxids.every(
|
|
162
|
+
(txid) => typeof txid === `number` && Number.isFinite(txid),
|
|
163
|
+
)
|
|
164
|
+
) {
|
|
165
|
+
return undefined
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const resume =
|
|
169
|
+
record.resume === undefined
|
|
170
|
+
? undefined
|
|
171
|
+
: parseElectricResumeState(record.resume)
|
|
172
|
+
if (record.resume !== undefined && resume === undefined) {
|
|
173
|
+
return undefined
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
version: 1,
|
|
178
|
+
...(resume ? { resume } : {}),
|
|
179
|
+
seenTxids: Array.from(new Set(record.seenTxids)).sort((a, b) => a - b),
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function mergeElectricSyncMeta(
|
|
184
|
+
current: unknown,
|
|
185
|
+
incoming: unknown,
|
|
186
|
+
): ElectricSyncMeta | unknown {
|
|
187
|
+
const currentMeta = parseElectricSyncMeta(current)
|
|
188
|
+
const incomingMeta = parseElectricSyncMeta(incoming)
|
|
189
|
+
|
|
190
|
+
if (!incomingMeta) {
|
|
191
|
+
return current
|
|
192
|
+
}
|
|
193
|
+
if (!currentMeta) {
|
|
194
|
+
return incomingMeta
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const resume = getNewestElectricResumeState(
|
|
198
|
+
currentMeta.resume,
|
|
199
|
+
incomingMeta.resume,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
version: 1,
|
|
204
|
+
...(resume ? { resume } : {}),
|
|
205
|
+
seenTxids: Array.from(
|
|
206
|
+
new Set([...currentMeta.seenTxids, ...incomingMeta.seenTxids]),
|
|
207
|
+
).sort((a, b) => a - b),
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function getNewestElectricResumeState(
|
|
212
|
+
current: ElectricResumeState | undefined,
|
|
213
|
+
incoming: ElectricResumeState | undefined,
|
|
214
|
+
): ElectricResumeState | undefined {
|
|
215
|
+
if (!current) return incoming
|
|
216
|
+
if (!incoming) return current
|
|
217
|
+
return incoming.updatedAt >= current.updatedAt ? incoming : current
|
|
218
|
+
}
|
|
219
|
+
|
|
90
220
|
/**
|
|
91
221
|
* Custom match function type - receives stream messages and returns boolean
|
|
92
222
|
* indicating if the mutation has been synchronized
|
|
@@ -633,6 +763,9 @@ export function electricCollectionOptions<T extends Row<unknown>>(
|
|
|
633
763
|
} {
|
|
634
764
|
const seenTxids = new Store<Set<Txid>>(new Set([]))
|
|
635
765
|
const seenSnapshots = new Store<Array<PostgresSnapshot>>([])
|
|
766
|
+
const hydratedResumeState = new Store<ElectricResumeState | undefined>(
|
|
767
|
+
undefined,
|
|
768
|
+
)
|
|
636
769
|
const internalSyncMode = config.syncMode ?? `eager`
|
|
637
770
|
const finalSyncMode =
|
|
638
771
|
internalSyncMode === `progressive` ? `on-demand` : internalSyncMode
|
|
@@ -690,6 +823,7 @@ export function electricCollectionOptions<T extends Row<unknown>>(
|
|
|
690
823
|
const sync = createElectricSync<T>(config.shapeOptions, {
|
|
691
824
|
seenTxids,
|
|
692
825
|
seenSnapshots,
|
|
826
|
+
hydratedResumeState,
|
|
693
827
|
syncMode: internalSyncMode,
|
|
694
828
|
pendingMatches,
|
|
695
829
|
currentBatchMessages,
|
|
@@ -941,10 +1075,29 @@ export function electricCollectionOptions<T extends Row<unknown>>(
|
|
|
941
1075
|
...restConfig
|
|
942
1076
|
} = config
|
|
943
1077
|
|
|
944
|
-
|
|
1078
|
+
const options = {
|
|
945
1079
|
...restConfig,
|
|
946
1080
|
syncMode: finalSyncMode,
|
|
947
|
-
sync
|
|
1081
|
+
sync: {
|
|
1082
|
+
...sync,
|
|
1083
|
+
exportSyncMeta: (): ElectricSyncMeta => ({
|
|
1084
|
+
version: 1,
|
|
1085
|
+
...(hydratedResumeState.state
|
|
1086
|
+
? { resume: hydratedResumeState.state }
|
|
1087
|
+
: {}),
|
|
1088
|
+
seenTxids: Array.from(seenTxids.state).sort((a, b) => a - b),
|
|
1089
|
+
}),
|
|
1090
|
+
importSyncMeta: (meta: unknown): void => {
|
|
1091
|
+
const parsed = parseElectricSyncMeta(meta)
|
|
1092
|
+
if (!parsed) {
|
|
1093
|
+
return
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
hydratedResumeState.setState(() => parsed.resume)
|
|
1097
|
+
seenTxids.setState(() => new Set(parsed.seenTxids))
|
|
1098
|
+
},
|
|
1099
|
+
mergeSyncMeta: mergeElectricSyncMeta,
|
|
1100
|
+
},
|
|
948
1101
|
onInsert: wrappedOnInsert,
|
|
949
1102
|
onUpdate: wrappedOnUpdate,
|
|
950
1103
|
onDelete: wrappedOnDelete,
|
|
@@ -953,6 +1106,14 @@ export function electricCollectionOptions<T extends Row<unknown>>(
|
|
|
953
1106
|
awaitMatch,
|
|
954
1107
|
},
|
|
955
1108
|
}
|
|
1109
|
+
|
|
1110
|
+
return withCollectionConfigFactory(options, () =>
|
|
1111
|
+
(
|
|
1112
|
+
electricCollectionOptions as (
|
|
1113
|
+
nextConfig: ElectricCollectionConfig<T, any>,
|
|
1114
|
+
) => typeof options
|
|
1115
|
+
)(config),
|
|
1116
|
+
)
|
|
956
1117
|
}
|
|
957
1118
|
|
|
958
1119
|
/**
|
|
@@ -964,6 +1125,7 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
964
1125
|
syncMode: ElectricSyncMode
|
|
965
1126
|
seenTxids: Store<Set<Txid>>
|
|
966
1127
|
seenSnapshots: Store<Array<PostgresSnapshot>>
|
|
1128
|
+
hydratedResumeState: Store<ElectricResumeState | undefined>
|
|
967
1129
|
pendingMatches: Store<
|
|
968
1130
|
Map<
|
|
969
1131
|
string,
|
|
@@ -987,6 +1149,7 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
987
1149
|
const {
|
|
988
1150
|
seenTxids,
|
|
989
1151
|
seenSnapshots,
|
|
1152
|
+
hydratedResumeState,
|
|
990
1153
|
syncMode,
|
|
991
1154
|
pendingMatches,
|
|
992
1155
|
currentBatchMessages,
|
|
@@ -1319,40 +1482,15 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1319
1482
|
collection,
|
|
1320
1483
|
metadata,
|
|
1321
1484
|
} = params
|
|
1322
|
-
const readPersistedResumeState = () => {
|
|
1485
|
+
const readPersistedResumeState = (): ElectricResumeState | undefined => {
|
|
1323
1486
|
const persistedResumeState = metadata?.collection.get(`electric:resume`)
|
|
1324
|
-
|
|
1325
|
-
return undefined
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
const record = persistedResumeState as Record<string, unknown>
|
|
1329
|
-
if (
|
|
1330
|
-
record.kind === `resume` &&
|
|
1331
|
-
typeof record.offset === `string` &&
|
|
1332
|
-
typeof record.handle === `string` &&
|
|
1333
|
-
typeof record.shapeId === `string` &&
|
|
1334
|
-
typeof record.updatedAt === `number`
|
|
1335
|
-
) {
|
|
1336
|
-
return {
|
|
1337
|
-
kind: `resume` as const,
|
|
1338
|
-
offset: record.offset,
|
|
1339
|
-
handle: record.handle,
|
|
1340
|
-
shapeId: record.shapeId,
|
|
1341
|
-
updatedAt: record.updatedAt,
|
|
1342
|
-
}
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
|
-
if (record.kind === `reset` && typeof record.updatedAt === `number`) {
|
|
1346
|
-
return {
|
|
1347
|
-
kind: `reset` as const,
|
|
1348
|
-
updatedAt: record.updatedAt,
|
|
1349
|
-
}
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
return undefined
|
|
1487
|
+
return parseElectricResumeState(persistedResumeState)
|
|
1353
1488
|
}
|
|
1354
1489
|
|
|
1355
|
-
const persistedResumeState =
|
|
1490
|
+
const persistedResumeState = getNewestElectricResumeState(
|
|
1491
|
+
readPersistedResumeState(),
|
|
1492
|
+
hydratedResumeState.state,
|
|
1493
|
+
)
|
|
1356
1494
|
const shapeIdentity = getStableShapeIdentity({
|
|
1357
1495
|
url: shapeOptions.url,
|
|
1358
1496
|
params: shapeOptions.params as Record<string, unknown> | undefined,
|
|
@@ -1476,35 +1614,35 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1476
1614
|
const syncedKeys = new Set<string | number>()
|
|
1477
1615
|
|
|
1478
1616
|
const stageResumeMetadata = () => {
|
|
1479
|
-
if (!metadata) {
|
|
1480
|
-
return
|
|
1481
|
-
}
|
|
1482
1617
|
const shapeHandle = stream.shapeHandle
|
|
1483
1618
|
const lastOffset = stream.lastOffset
|
|
1484
1619
|
if (!shapeHandle || lastOffset === `-1`) {
|
|
1485
1620
|
return
|
|
1486
1621
|
}
|
|
1487
1622
|
|
|
1488
|
-
|
|
1623
|
+
const resumeState: ElectricResumeState = {
|
|
1489
1624
|
kind: `resume`,
|
|
1490
1625
|
offset: lastOffset,
|
|
1491
1626
|
handle: shapeHandle,
|
|
1492
1627
|
shapeId: shapeIdentity,
|
|
1493
1628
|
updatedAt: Date.now(),
|
|
1494
|
-
}
|
|
1629
|
+
}
|
|
1630
|
+
hydratedResumeState.setState(() => resumeState)
|
|
1631
|
+
metadata?.collection.set(`electric:resume`, resumeState)
|
|
1495
1632
|
}
|
|
1496
1633
|
|
|
1497
1634
|
const commitResetResumeMetadataImmediately = () => {
|
|
1498
|
-
|
|
1499
|
-
return
|
|
1500
|
-
}
|
|
1501
|
-
|
|
1502
|
-
begin({ immediate: true })
|
|
1503
|
-
metadata.collection.set(`electric:resume`, {
|
|
1635
|
+
const resetState: ElectricResumeState = {
|
|
1504
1636
|
kind: `reset`,
|
|
1505
1637
|
updatedAt: Date.now(),
|
|
1506
|
-
}
|
|
1507
|
-
|
|
1638
|
+
}
|
|
1639
|
+
hydratedResumeState.setState(() => resetState)
|
|
1640
|
+
|
|
1641
|
+
if (metadata) {
|
|
1642
|
+
begin({ immediate: true })
|
|
1643
|
+
metadata.collection.set(`electric:resume`, resetState)
|
|
1644
|
+
commit()
|
|
1645
|
+
}
|
|
1508
1646
|
}
|
|
1509
1647
|
|
|
1510
1648
|
if (hasIncompatiblePersistedResume) {
|
|
@@ -1869,6 +2007,7 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1869
2007
|
abortController.abort()
|
|
1870
2008
|
// Reset deduplication tracking so collection can load fresh data if restarted
|
|
1871
2009
|
loadSubsetDedupe?.reset()
|
|
2010
|
+
hydratedResumeState.setState(() => undefined)
|
|
1872
2011
|
},
|
|
1873
2012
|
}
|
|
1874
2013
|
},
|