@tanstack/electric-db-collection 0.2.41 → 0.2.43

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
@@ -47,6 +47,7 @@ import type {
47
47
  ControlMessage,
48
48
  GetExtensions,
49
49
  Message,
50
+ Offset,
50
51
  PostgresSnapshot,
51
52
  Row,
52
53
  ShapeStreamOptions,
@@ -322,6 +323,46 @@ function parseSnapshotMessage(message: SnapshotEndMessage): PostgresSnapshot {
322
323
  }
323
324
  }
324
325
 
326
+ function toStableSerializable(value: unknown): unknown {
327
+ if (value == null) {
328
+ return value
329
+ }
330
+
331
+ if (Array.isArray(value)) {
332
+ return value.map((entry) => toStableSerializable(entry))
333
+ }
334
+
335
+ if (value instanceof Date) {
336
+ return value.toISOString()
337
+ }
338
+
339
+ if (typeof value === `object`) {
340
+ const record = value as Record<string, unknown>
341
+ const stableRecord: Record<string, unknown> = {}
342
+ const keys = Object.keys(record).sort((left, right) =>
343
+ left < right ? -1 : left > right ? 1 : 0,
344
+ )
345
+ for (const key of keys) {
346
+ stableRecord[key] = toStableSerializable(record[key])
347
+ }
348
+ return stableRecord
349
+ }
350
+
351
+ return value
352
+ }
353
+
354
+ function getStableShapeIdentity(shapeOptions: {
355
+ url: string
356
+ params?: Record<string, unknown>
357
+ }): string {
358
+ return JSON.stringify(
359
+ toStableSerializable({
360
+ url: shapeOptions.url,
361
+ params: shapeOptions.params ?? null,
362
+ }),
363
+ )
364
+ }
365
+
325
366
  // Check if a message contains txids in its headers
326
367
  function hasTxids<T extends Row<unknown>>(
327
368
  message: Message<T>,
@@ -666,26 +707,29 @@ export function electricCollectionOptions<T extends Row<unknown>>(
666
707
  if (hasSnapshot) return true
667
708
 
668
709
  return new Promise((resolve, reject) => {
710
+ const cleanup = () => {
711
+ clearTimeout(timeoutId)
712
+ subSeenTxids.unsubscribe()
713
+ subSeenSnapshots.unsubscribe()
714
+ }
715
+
669
716
  const timeoutId = setTimeout(() => {
670
- unsubscribeSeenTxids()
671
- unsubscribeSeenSnapshots()
717
+ cleanup()
672
718
  reject(new TimeoutWaitingForTxIdError(txId, config.id))
673
719
  }, timeout)
674
720
 
675
- const unsubscribeSeenTxids = seenTxids.subscribe(() => {
721
+ const subSeenTxids = seenTxids.subscribe(() => {
676
722
  if (seenTxids.state.has(txId)) {
677
723
  debug(
678
724
  `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o`,
679
725
  txId,
680
726
  )
681
- clearTimeout(timeoutId)
682
- unsubscribeSeenTxids()
683
- unsubscribeSeenSnapshots()
727
+ cleanup()
684
728
  resolve(true)
685
729
  }
686
730
  })
687
731
 
688
- const unsubscribeSeenSnapshots = seenSnapshots.subscribe(() => {
732
+ const subSeenSnapshots = seenSnapshots.subscribe(() => {
689
733
  const visibleSnapshot = seenSnapshots.state.find((snapshot) =>
690
734
  isVisibleInSnapshot(txId, snapshot),
691
735
  )
@@ -695,9 +739,7 @@ export function electricCollectionOptions<T extends Row<unknown>>(
695
739
  txId,
696
740
  visibleSnapshot,
697
741
  )
698
- clearTimeout(timeoutId)
699
- unsubscribeSeenSnapshots()
700
- unsubscribeSeenTxids()
742
+ cleanup()
701
743
  resolve(true)
702
744
  }
703
745
  })
@@ -1181,7 +1223,61 @@ function createElectricSync<T extends Row<unknown>>(
1181
1223
 
1182
1224
  return {
1183
1225
  sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => {
1184
- const { begin, write, commit, markReady, truncate, collection } = params
1226
+ const {
1227
+ begin,
1228
+ write,
1229
+ commit,
1230
+ markReady,
1231
+ truncate,
1232
+ collection,
1233
+ metadata,
1234
+ } = params
1235
+ const readPersistedResumeState = () => {
1236
+ const persistedResumeState = metadata?.collection.get(`electric:resume`)
1237
+ if (!persistedResumeState || typeof persistedResumeState !== `object`) {
1238
+ return undefined
1239
+ }
1240
+
1241
+ const record = persistedResumeState as Record<string, unknown>
1242
+ if (
1243
+ record.kind === `resume` &&
1244
+ typeof record.offset === `string` &&
1245
+ typeof record.handle === `string` &&
1246
+ typeof record.shapeId === `string` &&
1247
+ typeof record.updatedAt === `number`
1248
+ ) {
1249
+ return {
1250
+ kind: `resume` as const,
1251
+ offset: record.offset,
1252
+ handle: record.handle,
1253
+ shapeId: record.shapeId,
1254
+ updatedAt: record.updatedAt,
1255
+ }
1256
+ }
1257
+
1258
+ if (record.kind === `reset` && typeof record.updatedAt === `number`) {
1259
+ return {
1260
+ kind: `reset` as const,
1261
+ updatedAt: record.updatedAt,
1262
+ }
1263
+ }
1264
+
1265
+ return undefined
1266
+ }
1267
+
1268
+ const persistedResumeState = readPersistedResumeState()
1269
+ const shapeIdentity = getStableShapeIdentity({
1270
+ url: shapeOptions.url,
1271
+ params: shapeOptions.params as Record<string, unknown> | undefined,
1272
+ })
1273
+ const hasIncompatiblePersistedResume =
1274
+ persistedResumeState?.kind === `resume` &&
1275
+ persistedResumeState.shapeId !== shapeIdentity
1276
+ const canUsePersistedResume =
1277
+ shapeOptions.offset === undefined &&
1278
+ shapeOptions.handle === undefined &&
1279
+ persistedResumeState?.kind === `resume` &&
1280
+ !hasIncompatiblePersistedResume
1185
1281
 
1186
1282
  // Wrap markReady to wait for test hook in progressive mode
1187
1283
  let progressiveReadyGate: Promise<void> | null = null
@@ -1239,7 +1335,15 @@ function createElectricSync<T extends Row<unknown>>(
1239
1335
  // In on-demand mode, we only need the changes from the point of time the collection was created
1240
1336
  // so we default to `now` when there is no saved offset.
1241
1337
  offset:
1242
- shapeOptions.offset ?? (syncMode === `on-demand` ? `now` : undefined),
1338
+ shapeOptions.offset ??
1339
+ (canUsePersistedResume
1340
+ ? (persistedResumeState.offset as Offset)
1341
+ : syncMode === `on-demand`
1342
+ ? `now`
1343
+ : undefined),
1344
+ handle:
1345
+ shapeOptions.handle ??
1346
+ (canUsePersistedResume ? persistedResumeState.handle : undefined),
1243
1347
  signal: abortController.signal,
1244
1348
  onError: (errorParams) => {
1245
1349
  // Just immediately mark ready if there's an error to avoid blocking
@@ -1280,6 +1384,42 @@ function createElectricSync<T extends Row<unknown>>(
1280
1384
  // duplicate key errors when the row's data has changed between requests.
1281
1385
  const syncedKeys = new Set<string | number>()
1282
1386
 
1387
+ const stageResumeMetadata = () => {
1388
+ if (!metadata) {
1389
+ return
1390
+ }
1391
+ const shapeHandle = stream.shapeHandle
1392
+ const lastOffset = stream.lastOffset
1393
+ if (!shapeHandle || lastOffset === `-1`) {
1394
+ return
1395
+ }
1396
+
1397
+ metadata.collection.set(`electric:resume`, {
1398
+ kind: `resume`,
1399
+ offset: lastOffset,
1400
+ handle: shapeHandle,
1401
+ shapeId: shapeIdentity,
1402
+ updatedAt: Date.now(),
1403
+ })
1404
+ }
1405
+
1406
+ const commitResetResumeMetadataImmediately = () => {
1407
+ if (!metadata) {
1408
+ return
1409
+ }
1410
+
1411
+ begin({ immediate: true })
1412
+ metadata.collection.set(`electric:resume`, {
1413
+ kind: `reset`,
1414
+ updatedAt: Date.now(),
1415
+ })
1416
+ commit()
1417
+ }
1418
+
1419
+ if (hasIncompatiblePersistedResume) {
1420
+ commitResetResumeMetadataImmediately()
1421
+ }
1422
+
1283
1423
  /**
1284
1424
  * Process a change message: handle tags and write the mutation
1285
1425
  */
@@ -1458,6 +1598,8 @@ function createElectricSync<T extends Row<unknown>>(
1458
1598
  `${collectionId ? `[${collectionId}] ` : ``}Received must-refetch message, starting transaction with truncate`,
1459
1599
  )
1460
1600
 
1601
+ commitResetResumeMetadataImmediately()
1602
+
1461
1603
  // Start a transaction and truncate the collection
1462
1604
  if (!transactionStarted) {
1463
1605
  begin()
@@ -1534,6 +1676,7 @@ function createElectricSync<T extends Row<unknown>>(
1534
1676
  }
1535
1677
 
1536
1678
  // Commit the atomic swap
1679
+ stageResumeMetadata()
1537
1680
  commit()
1538
1681
 
1539
1682
  // Exit buffering phase by marking that we've received up-to-date
@@ -1547,8 +1690,13 @@ function createElectricSync<T extends Row<unknown>>(
1547
1690
  // Normal mode or on-demand: commit transaction if one was started
1548
1691
  // Both up-to-date and subset-end trigger a commit
1549
1692
  if (transactionStarted) {
1693
+ stageResumeMetadata()
1550
1694
  commit()
1551
1695
  transactionStarted = false
1696
+ } else if (commitPoint === `up-to-date` && metadata) {
1697
+ begin()
1698
+ stageResumeMetadata()
1699
+ commit()
1552
1700
  }
1553
1701
  }
1554
1702
  wrappedMarkReady(isBufferingInitialSync())