@tanstack/electric-db-collection 0.3.18 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/electric.ts CHANGED
@@ -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 { DeduplicatedLoadSubset, and } from '@tanstack/db'
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
- return {
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
- if (!persistedResumeState || typeof persistedResumeState !== `object`) {
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 = readPersistedResumeState()
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
- metadata.collection.set(`electric:resume`, {
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
- if (!metadata) {
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
- commit()
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
  },