@tanstack/electric-db-collection 0.4.10 → 0.5.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
@@ -9,6 +9,8 @@ import {
9
9
  DeduplicatedLoadSubset,
10
10
  LoadSubsetOperationAbortedError,
11
11
  and,
12
+ validateSyncPersistenceCapability,
13
+ warnOnce,
12
14
  withCollectionConfigFactory,
13
15
  withCollectionSyncConfigCleanup,
14
16
  withCollectionSyncConfigFactory,
@@ -51,10 +53,8 @@ import type {
51
53
  LoadSubsetOptions,
52
54
  SyncAppliedReceipt,
53
55
  SyncConfig,
54
- SyncMetadataApi,
55
56
  SyncMode,
56
57
  UpdateMutationFnParams,
57
- UtilsRecord,
58
58
  } from '@tanstack/db'
59
59
  import type { StandardSchemaV1 } from '@standard-schema/spec'
60
60
  import type {
@@ -67,21 +67,11 @@ import type {
67
67
  ShapeStreamOptions,
68
68
  } from '@electric-sql/client'
69
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
-
78
70
  // Re-export for user convenience in custom match functions
79
71
  export { isChangeMessage, isControlMessage } from '@electric-sql/client'
80
72
 
81
73
  const debug = DebugModule.debug(`ts/db:electric`)
82
74
 
83
- const FORCE_DISCONNECT_AND_REFRESH_TIMEOUT_MS = 250
84
-
85
75
  /**
86
76
  * Symbol for internal test hooks (hidden from public API)
87
77
  */
@@ -299,11 +289,11 @@ export type MatchFunction<T extends Row<unknown>> = (
299
289
  /**
300
290
  * Matching strategies for Electric synchronization
301
291
  * Handlers can return:
302
- * - Txid strategy: { txid: number | number[], timeout?: number } (recommended)
292
+ * - Txid strategy: { txid: number | number[], timeout?: number } (deprecated compatibility behavior)
303
293
  * - Void (no return value) - mutation completes without waiting
304
294
  *
305
295
  * The optional timeout property specifies how long to wait for the txid(s) in milliseconds.
306
- * If not specified, defaults to 5000ms.
296
+ * If not specified, defaults to 15000ms.
307
297
  */
308
298
  export type MatchingStrategy = {
309
299
  txid: Txid | Array<Txid>
@@ -352,13 +342,7 @@ export interface ElectricCollectionConfig<
352
342
  T extends Row<unknown> = Row<unknown>,
353
343
  TSchema extends StandardSchemaV1 = never,
354
344
  > extends Omit<
355
- BaseCollectionConfig<
356
- T,
357
- string | number,
358
- TSchema,
359
- ElectricCollectionUtils<T>,
360
- any
361
- >,
345
+ BaseCollectionConfig<T, string | number, TSchema, ElectricCollectionUtils<T>>,
362
346
  `onInsert` | `onUpdate` | `onDelete` | `syncMode`
363
347
  > {
364
348
  /**
@@ -375,43 +359,82 @@ export interface ElectricCollectionConfig<
375
359
 
376
360
  /**
377
361
  * Optional asynchronous handler function called before an insert operation
362
+ *
363
+ * **IMPORTANT - Electric Synchronization:**
364
+ * This handler **must not resolve** until synchronization is confirmed.
365
+ * Await one of these synchronization utilities before the handler completes:
366
+ * 1. `await collection.utils.awaitTxId(txid)` (recommended for most cases)
367
+ * 2. `await collection.utils.awaitMatch(fn)` for custom matching logic
368
+ *
369
+ * Simply returning without waiting for sync will drop optimistic state too early, causing UI glitches.
370
+ *
378
371
  * @param params Object containing transaction and collection information
379
- * @returns Promise resolving to { txid, timeout? } or void
372
+ * @returns Promise that should resolve after synchronization is complete
373
+ *
374
+ * **Deprecation notice:** Returning `{ txid }` from handlers is deprecated and will be removed in v1.0.
375
+ * Use `await collection.utils.awaitTxId(txid)` within the handler instead.
376
+ *
380
377
  * @example
381
- * // Basic Electric insert handler with txid (recommended)
382
- * onInsert: async ({ transaction }) => {
378
+ * // Recommended: Wait for txid to sync
379
+ * onInsert: async ({ transaction, collection }) => {
383
380
  * const newItem = transaction.mutations[0].modified
384
381
  * const result = await api.todos.create({
385
382
  * data: newItem
386
383
  * })
387
- * return { txid: result.txid }
384
+ * // Wait for txid to sync before handler completes
385
+ * await collection.utils.awaitTxId(result.txid)
388
386
  * }
389
387
  *
390
388
  * @example
391
389
  * // Insert handler with custom timeout
392
- * onInsert: async ({ transaction }) => {
390
+ * onInsert: async ({ transaction, collection }) => {
393
391
  * const newItem = transaction.mutations[0].modified
394
392
  * const result = await api.todos.create({
395
393
  * data: newItem
396
394
  * })
397
- * return { txid: result.txid, timeout: 10000 } // Wait up to 10 seconds
395
+ * // Wait up to 10 seconds for txid
396
+ * await collection.utils.awaitTxId(result.txid, 10000)
398
397
  * }
399
398
  *
400
399
  * @example
401
- * // Insert handler with multiple items - return array of txids
402
- * onInsert: async ({ transaction }) => {
400
+ * // Insert handler with timeout error handling
401
+ * onInsert: async ({ transaction, collection }) => {
402
+ * const newItem = transaction.mutations[0].modified
403
+ * const result = await api.todos.create({
404
+ * data: newItem
405
+ * })
406
+ *
407
+ * try {
408
+ * await collection.utils.awaitTxId(result.txid, 5000)
409
+ * } catch (error) {
410
+ * // Decide sync timeout policy:
411
+ * // - Throw to rollback optimistic state
412
+ * // - Catch to keep optimistic state (eventual consistency)
413
+ * // - Schedule background retry
414
+ * console.warn('Sync timeout, keeping optimistic state:', error)
415
+ * // Don't throw - allow optimistic state to persist
416
+ * }
417
+ * }
418
+ *
419
+ * @example
420
+ * // Insert handler with multiple items
421
+ * onInsert: async ({ transaction, collection }) => {
403
422
  * const items = transaction.mutations.map(m => m.modified)
404
423
  * const results = await Promise.all(
405
424
  * items.map(item => api.todos.create({ data: item }))
406
425
  * )
407
- * return { txid: results.map(r => r.txid) }
426
+ * // Wait for all txids to sync
427
+ * await Promise.all(
428
+ * results.map(r => collection.utils.awaitTxId(r.txid))
429
+ * )
408
430
  * }
409
431
  *
410
432
  * @example
411
- * // Use awaitMatch utility for custom matching
433
+ * // Alternative: Use awaitMatch utility for custom matching logic
412
434
  * onInsert: async ({ transaction, collection }) => {
413
435
  * const newItem = transaction.mutations[0].modified
414
436
  * await api.todos.create({ data: newItem })
437
+ * // Wait for specific change to appear in sync stream
415
438
  * await collection.utils.awaitMatch(
416
439
  * (message) => isChangeMessage(message) &&
417
440
  * message.headers.operation === 'insert' &&
@@ -429,24 +452,39 @@ export interface ElectricCollectionConfig<
429
452
 
430
453
  /**
431
454
  * Optional asynchronous handler function called before an update operation
455
+ *
456
+ * **IMPORTANT - Electric Synchronization:**
457
+ * This handler **must not resolve** until synchronization is confirmed.
458
+ * Await one of these synchronization utilities before the handler completes:
459
+ * 1. `await collection.utils.awaitTxId(txid)` (recommended for most cases)
460
+ * 2. `await collection.utils.awaitMatch(fn)` for custom matching logic
461
+ *
462
+ * Simply returning without waiting for sync will drop optimistic state too early, causing UI glitches.
463
+ *
432
464
  * @param params Object containing transaction and collection information
433
- * @returns Promise resolving to { txid, timeout? } or void
465
+ * @returns Promise that should resolve after synchronization is complete
466
+ *
467
+ * **Deprecation notice:** Returning `{ txid }` from handlers is deprecated and will be removed in v1.0.
468
+ * Use `await collection.utils.awaitTxId(txid)` within the handler instead.
469
+ *
434
470
  * @example
435
- * // Basic Electric update handler with txid (recommended)
436
- * onUpdate: async ({ transaction }) => {
471
+ * // Recommended: Wait for txid to sync
472
+ * onUpdate: async ({ transaction, collection }) => {
437
473
  * const { original, changes } = transaction.mutations[0]
438
474
  * const result = await api.todos.update({
439
475
  * where: { id: original.id },
440
476
  * data: changes
441
477
  * })
442
- * return { txid: result.txid }
478
+ * // Wait for txid to sync before handler completes
479
+ * await collection.utils.awaitTxId(result.txid)
443
480
  * }
444
481
  *
445
482
  * @example
446
- * // Use awaitMatch utility for custom matching
483
+ * // Alternative: Use awaitMatch utility for custom matching logic
447
484
  * onUpdate: async ({ transaction, collection }) => {
448
485
  * const { original, changes } = transaction.mutations[0]
449
486
  * await api.todos.update({ where: { id: original.id }, data: changes })
487
+ * // Wait for specific change to appear in sync stream
450
488
  * await collection.utils.awaitMatch(
451
489
  * (message) => isChangeMessage(message) &&
452
490
  * message.headers.operation === 'update' &&
@@ -464,23 +502,38 @@ export interface ElectricCollectionConfig<
464
502
 
465
503
  /**
466
504
  * Optional asynchronous handler function called before a delete operation
505
+ *
506
+ * **IMPORTANT - Electric Synchronization:**
507
+ * This handler **must not resolve** until synchronization is confirmed.
508
+ * Await one of these synchronization utilities before the handler completes:
509
+ * 1. `await collection.utils.awaitTxId(txid)` (recommended for most cases)
510
+ * 2. `await collection.utils.awaitMatch(fn)` for custom matching logic
511
+ *
512
+ * Simply returning without waiting for sync will drop optimistic state too early, causing UI glitches.
513
+ *
467
514
  * @param params Object containing transaction and collection information
468
- * @returns Promise resolving to { txid, timeout? } or void
515
+ * @returns Promise that should resolve after synchronization is complete
516
+ *
517
+ * **Deprecation notice:** Returning `{ txid }` from handlers is deprecated and will be removed in v1.0.
518
+ * Use `await collection.utils.awaitTxId(txid)` within the handler instead.
519
+ *
469
520
  * @example
470
- * // Basic Electric delete handler with txid (recommended)
471
- * onDelete: async ({ transaction }) => {
521
+ * // Recommended: Wait for txid to sync
522
+ * onDelete: async ({ transaction, collection }) => {
472
523
  * const mutation = transaction.mutations[0]
473
524
  * const result = await api.todos.delete({
474
525
  * id: mutation.original.id
475
526
  * })
476
- * return { txid: result.txid }
527
+ * // Wait for txid to sync before handler completes
528
+ * await collection.utils.awaitTxId(result.txid)
477
529
  * }
478
530
  *
479
531
  * @example
480
- * // Use awaitMatch utility for custom matching
532
+ * // Alternative: Use awaitMatch utility for custom matching logic
481
533
  * onDelete: async ({ transaction, collection }) => {
482
534
  * const mutation = transaction.mutations[0]
483
535
  * await api.todos.delete({ id: mutation.original.id })
536
+ * // Wait for specific change to appear in sync stream
484
537
  * await collection.utils.awaitMatch(
485
538
  * (message) => isChangeMessage(message) &&
486
539
  * message.headers.operation === 'delete' &&
@@ -599,6 +652,7 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
599
652
  commit,
600
653
  getCommitCursor,
601
654
  waitForCommitsAfter,
655
+ recordSnapshotRow,
602
656
  collectionId,
603
657
  encodeColumnName,
604
658
  signal,
@@ -615,6 +669,7 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
615
669
  commit: (signal?: AbortSignal) => SyncAppliedReceipt
616
670
  getCommitCursor: () => number
617
671
  waitForCommitsAfter: (cursor: number) => Promise<void>
672
+ recordSnapshotRow?: (row: T, message: Message<T>) => void
618
673
  collectionId?: string
619
674
  /**
620
675
  * Optional function to encode column names (e.g., camelCase to snake_case).
@@ -676,7 +731,9 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
676
731
  metadata: { ...row.headers },
677
732
  })
678
733
  }
679
- await commit(opts.signal)
734
+ const applied = commit(opts.signal)
735
+ for (const row of rows) recordSnapshotRow?.(row.value, row)
736
+ if (applied !== true) await applied
680
737
  debug(`${logPrefix}Applied snapshot with ${rows.length} rows`)
681
738
  }
682
739
  } catch (error) {
@@ -695,56 +752,6 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
695
752
 
696
753
  const { cursor, where, orderBy, limit } = opts
697
754
 
698
- // When the stream is already up-to-date, it may be in a long-poll wait.
699
- // Forcing a disconnect-and-refresh ensures requestSnapshot gets a response
700
- // from a fresh server round-trip rather than waiting for the current poll to end.
701
- // Some native fetch implementations (notably React Native/Expo) may not abort
702
- // long-poll requests promptly. Bound the wait so on-demand live queries don't
703
- // remain loading until the long-poll naturally times out.
704
- // If the refresh fails or times out, we fall through to requestSnapshot which
705
- // still works.
706
- if (stream.isUpToDate) {
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
- }
720
- try {
721
- await Promise.race([
722
- stream.forceDisconnectAndRefresh(),
723
- new Promise<void>((resolve) => {
724
- timeoutId = setTimeout(
725
- resolve,
726
- FORCE_DISCONNECT_AND_REFRESH_TIMEOUT_MS,
727
- )
728
- }),
729
- aborted,
730
- ])
731
- } catch (error) {
732
- if (signal.aborted || opts.signal?.aborted) throw error
733
- if (handleSnapshotError(error, `forceDisconnectAndRefresh`)) {
734
- return
735
- }
736
- debug(
737
- `${logPrefix}forceDisconnectAndRefresh failed, proceeding to requestSnapshot: %o`,
738
- error,
739
- )
740
- } finally {
741
- clearTimeout(timeoutId)
742
- for (const abortSignal of abortSignals) {
743
- abortSignal.removeEventListener(`abort`, abort)
744
- }
745
- }
746
- }
747
-
748
755
  throwIfAborted()
749
756
 
750
757
  // Upstream limitation: ShapeStream.requestSnapshot() publishes its rows
@@ -815,7 +822,9 @@ export type AwaitMatchFn<T extends Row<unknown>> = (
815
822
  */
816
823
  export interface ElectricCollectionUtils<
817
824
  T extends Row<unknown> = Row<unknown>,
818
- > extends UtilsRecord {
825
+ > {
826
+ // Keep this interface closed: extending UtilsRecord would make every
827
+ // nonexistent adapter utility appear as `any`.
819
828
  awaitTxId: AwaitTxIdFn
820
829
  awaitMatch: AwaitMatchFn<T>
821
830
  }
@@ -930,7 +939,7 @@ class ElectricLifecycle<T extends Row<unknown>> {
930
939
 
931
940
  private async awaitTxId(
932
941
  txId: Txid,
933
- timeout: number = 5000,
942
+ timeout: number = 15000,
934
943
  ): Promise<boolean> {
935
944
  debug(
936
945
  `${this.collectionId ? `[${this.collectionId}] ` : ``}awaitTxId called with txid %d`,
@@ -958,7 +967,7 @@ class ElectricLifecycle<T extends Row<unknown>> {
958
967
 
959
968
  private async awaitMatch(
960
969
  matchFn: MatchFunction<T>,
961
- timeout: number = 3000,
970
+ timeout: number = 15000,
962
971
  ): Promise<boolean> {
963
972
  debug(
964
973
  `${this.collectionId ? `[${this.collectionId}] ` : ``}awaitMatch called with custom function`,
@@ -1137,17 +1146,32 @@ export function electricCollectionOptions<T extends Row<unknown>>(
1137
1146
  * Process matching strategy and wait for synchronization
1138
1147
  */
1139
1148
  const processMatchingStrategy = async (
1140
- result: MatchingStrategy,
1149
+ result: unknown,
1141
1150
  waitForTxId: AwaitTxIdFn,
1142
1151
  ): Promise<void> => {
1152
+ const canHaveProperties =
1153
+ (typeof result === `object` && result !== null) ||
1154
+ typeof result === `function`
1155
+
1143
1156
  // Only wait if result contains txid
1144
- if (result && `txid` in result) {
1145
- const timeout = result.timeout
1157
+ if (canHaveProperties && `txid` in result) {
1158
+ const strategy = result as Exclude<MatchingStrategy, void>
1159
+ // Warn about deprecated return value pattern
1160
+ warnOnce(
1161
+ 'electric-collection-txid-return',
1162
+ '[TanStack DB] DEPRECATED: Returning { txid } from mutation handlers is deprecated and will be removed in v1.0. ' +
1163
+ 'Use `await collection.utils.awaitTxId(txid)` instead of returning { txid }. ' +
1164
+ 'See migration guide: https://tanstack.com/db/latest/docs/collections/electric-collection#persistence-handlers--synchronization',
1165
+ )
1166
+
1167
+ const timeout = strategy.timeout
1146
1168
  // Handle both single txid and array of txids
1147
- if (Array.isArray(result.txid)) {
1148
- await Promise.all(result.txid.map((txid) => waitForTxId(txid, timeout)))
1169
+ if (Array.isArray(strategy.txid)) {
1170
+ await Promise.all(
1171
+ strategy.txid.map((txid) => waitForTxId(txid, timeout)),
1172
+ )
1149
1173
  } else {
1150
- await waitForTxId(result.txid, timeout)
1174
+ await waitForTxId(strategy.txid, timeout)
1151
1175
  }
1152
1176
  }
1153
1177
  // If result is void/undefined, don't wait - mutation completes immediately
@@ -1162,6 +1186,7 @@ export function electricCollectionOptions<T extends Row<unknown>>(
1162
1186
  }
1163
1187
 
1164
1188
  // Create wrapper handlers for direct persistence operations that handle different matching strategies
1189
+ // These wrappers process deprecated return values but don't pass them through
1165
1190
  const wrappedOnInsert = config.onInsert
1166
1191
  ? async (
1167
1192
  params: InsertMutationFnParams<
@@ -1169,13 +1194,12 @@ export function electricCollectionOptions<T extends Row<unknown>>(
1169
1194
  string | number,
1170
1195
  ElectricCollectionUtils<T>
1171
1196
  >,
1172
- ) => {
1197
+ ): Promise<void> => {
1173
1198
  const handlerResult = await config.onInsert!(params)
1174
1199
  await processMatchingStrategy(
1175
1200
  handlerResult,
1176
1201
  getMutationAwaitTxId(params),
1177
1202
  )
1178
- return handlerResult
1179
1203
  }
1180
1204
  : undefined
1181
1205
 
@@ -1186,13 +1210,12 @@ export function electricCollectionOptions<T extends Row<unknown>>(
1186
1210
  string | number,
1187
1211
  ElectricCollectionUtils<T>
1188
1212
  >,
1189
- ) => {
1213
+ ): Promise<void> => {
1190
1214
  const handlerResult = await config.onUpdate!(params)
1191
1215
  await processMatchingStrategy(
1192
1216
  handlerResult,
1193
1217
  getMutationAwaitTxId(params),
1194
1218
  )
1195
- return handlerResult
1196
1219
  }
1197
1220
  : undefined
1198
1221
 
@@ -1203,13 +1226,12 @@ export function electricCollectionOptions<T extends Row<unknown>>(
1203
1226
  string | number,
1204
1227
  ElectricCollectionUtils<T>
1205
1228
  >,
1206
- ) => {
1229
+ ): Promise<void> => {
1207
1230
  const handlerResult = await config.onDelete!(params)
1208
1231
  await processMatchingStrategy(
1209
1232
  handlerResult,
1210
1233
  getMutationAwaitTxId(params),
1211
1234
  )
1212
- return handlerResult
1213
1235
  }
1214
1236
  : undefined
1215
1237
 
@@ -1308,7 +1330,6 @@ function createElectricSync<T extends Row<unknown>>(
1308
1330
  const { getLifecycle, syncMode, collectionId, testHooks } = options
1309
1331
 
1310
1332
  let relationSchema: string | undefined
1311
- let warnedUnverifiableResume = false
1312
1333
 
1313
1334
  const createTagState = () => {
1314
1335
  const tagCache = new Map<MoveTag, ParsedMoveTag>()
@@ -1679,11 +1700,16 @@ function createElectricSync<T extends Row<unknown>>(
1679
1700
  return parseElectricResumeState(persistedResumeState)
1680
1701
  }
1681
1702
 
1682
- const persistedMetadata = metadata as
1683
- | ElectricSyncMetadataWithHydration
1684
- | undefined
1685
- const scanPersisted = persistedMetadata?.row.scanPersisted
1686
- const whenHydrated = persistedMetadata?.row.whenHydrated
1703
+ const persistence =
1704
+ metadata === undefined
1705
+ ? null
1706
+ : validateSyncPersistenceCapability(metadata.persistence)
1707
+ const hydrateBaseline = persistence?.hydrateBaseline
1708
+ const resumeSnapshot = persistence?.resumeSnapshot
1709
+ const certifyResumeSnapshot = resumeSnapshot?.certify
1710
+ const getKeySetEvidence = resumeSnapshot?.getKeySetEvidence
1711
+ const expectCurrentCommit = resumeSnapshot?.expectCurrentCommit
1712
+ const persistedKeySetEvidence = getKeySetEvidence?.()
1687
1713
 
1688
1714
  const persistedResumeState = getNewestElectricResumeState(
1689
1715
  readPersistedResumeState(),
@@ -1696,32 +1722,26 @@ function createElectricSync<T extends Row<unknown>>(
1696
1722
  const hasIncompatiblePersistedResume =
1697
1723
  persistedResumeState?.kind === `resume` &&
1698
1724
  persistedResumeState.shapeId !== shapeIdentity
1699
- const hasUnverifiablePersistedResume =
1700
- shapeOptions.offset === undefined &&
1701
- shapeOptions.handle === undefined &&
1725
+ // A pre-ledger `unknown` baseline cannot justify a non-initial cursor.
1726
+ // One fresh replacement establishes consistent evidence for later resumes.
1727
+ const lacksCompletePersistedKeySet =
1702
1728
  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
- }
1729
+ getKeySetEvidence !== undefined &&
1730
+ persistedKeySetEvidence?.status !== `consistent`
1711
1731
  const needsFullSnapshot =
1712
1732
  shapeOptions.offset === undefined &&
1713
1733
  shapeOptions.handle === undefined &&
1714
1734
  persistedResumeState !== undefined &&
1715
1735
  (persistedResumeState.kind === `reset` ||
1736
+ lacksCompletePersistedKeySet ||
1716
1737
  (!retainsTagState && persistedResumeState.requiresTagState !== false))
1717
1738
  const canUsePersistedResume =
1718
1739
  shapeOptions.offset === undefined &&
1719
1740
  shapeOptions.handle === undefined &&
1720
1741
  persistedResumeState?.kind === `resume` &&
1721
1742
  !hasIncompatiblePersistedResume &&
1722
- !hasUnverifiablePersistedResume &&
1723
1743
  // Cached rows do not contain authoritative tag/active-condition state.
1724
- // Unknown (older) metadata is conservative; untagged shapes still resume.
1744
+ // Only a complete adapter ledger can justify a persisted cursor.
1725
1745
  !needsFullSnapshot
1726
1746
  const hasExplicitResumeOffset =
1727
1747
  shapeOptions.offset !== undefined && shapeOptions.offset !== `-1`
@@ -1729,6 +1749,8 @@ function createElectricSync<T extends Row<unknown>>(
1729
1749
  clearTagTrackingState()
1730
1750
  }
1731
1751
  const receivesCompleteRows = shapeOptions.params?.replica === `full`
1752
+ const requiresKeySetCertification =
1753
+ canUsePersistedResume && certifyResumeSnapshot !== undefined
1732
1754
  // Eager and progressive streams that start after the initial offset can
1733
1755
  // only apply partial updates when the local materialization is complete.
1734
1756
  const requiresCompleteResume =
@@ -1741,7 +1763,7 @@ function createElectricSync<T extends Row<unknown>>(
1741
1763
  (syncMode === `eager` || needsFullSnapshot) &&
1742
1764
  !canUsePersistedResume &&
1743
1765
  !hasExplicitResumeOffset &&
1744
- whenHydrated !== undefined
1766
+ hydrateBaseline !== undefined
1745
1767
 
1746
1768
  // Wrap markReady to wait for test hook in progressive mode
1747
1769
  let progressiveReadyGate: Promise<void> | null = null
@@ -1856,6 +1878,54 @@ function createElectricSync<T extends Row<unknown>>(
1856
1878
  !hasReceivedUpToDate &&
1857
1879
  !isResettingSnapshot
1858
1880
  const bufferedMessages: Array<Message<T>> = [] // Buffer change messages during initial sync
1881
+ // Progressive subset rows are provisional: the initial atomic swap
1882
+ // normally discards them. Retain one only when a later stream update
1883
+ // needs that full row as its baseline.
1884
+ const progressiveSnapshotRows = new Map<string | number, Message<T>>()
1885
+
1886
+ // Presence spans the open source transaction and persisted callbacks
1887
+ // whose FIFO receipts have not applied yet. Later callbacks must see
1888
+ // those staged writes even though persistence has not replayed them into
1889
+ // core. Once the newest receipt applies, syncedData is authoritative.
1890
+ const pendingPresence = new Map<string | number, boolean>()
1891
+ let usesBaseline = true
1892
+ let pendingPresenceRevision = 0
1893
+
1894
+ const recordPendingPresence = (
1895
+ rowId: string | number,
1896
+ present: boolean,
1897
+ ) => {
1898
+ pendingPresenceRevision++
1899
+ pendingPresence.set(rowId, present)
1900
+ }
1901
+
1902
+ const recordPendingTruncate = () => {
1903
+ pendingPresenceRevision++
1904
+ pendingPresence.clear()
1905
+ progressiveSnapshotRows.clear()
1906
+ usesBaseline = false
1907
+ }
1908
+
1909
+ const commitSourceTransaction = (): SyncAppliedReceipt => {
1910
+ const committedPresenceRevision = pendingPresenceRevision
1911
+ const applied = commit()
1912
+ if (metadata?.persistence) {
1913
+ const retireAppliedPresence = () => {
1914
+ if (pendingPresenceRevision === committedPresenceRevision) {
1915
+ pendingPresence.clear()
1916
+ usesBaseline = true
1917
+ }
1918
+ }
1919
+ if (applied === true) retireAppliedPresence()
1920
+ else void applied.then(retireAppliedPresence, () => undefined)
1921
+ }
1922
+ return applied
1923
+ }
1924
+
1925
+ const beginSourceTransaction = () => {
1926
+ if (metadata?.persistence) begin({ immediate: true })
1927
+ else begin()
1928
+ }
1859
1929
 
1860
1930
  // Track keys that have been synced to handle overlapping subset queries.
1861
1931
  // When multiple subset queries return the same row, the server sends `insert`
@@ -1886,7 +1956,9 @@ function createElectricSync<T extends Row<unknown>>(
1886
1956
  metadata?.collection.set(`electric:resume`, resumeState)
1887
1957
  }
1888
1958
 
1889
- const commitResetResumeMetadataImmediately = () => {
1959
+ const commitResetResumeMetadataImmediately = (
1960
+ expectInResumeSnapshot = false,
1961
+ ) => {
1890
1962
  const resetState: ElectricResumeState = {
1891
1963
  kind: `reset`,
1892
1964
  updatedAt: Date.now(),
@@ -1896,16 +1968,21 @@ function createElectricSync<T extends Row<unknown>>(
1896
1968
  if (metadata) {
1897
1969
  begin({ immediate: true })
1898
1970
  metadata.collection.set(`electric:resume`, resetState)
1971
+ if (expectInResumeSnapshot) {
1972
+ expectCurrentCommit?.()
1973
+ }
1899
1974
  commit()
1900
1975
  }
1901
1976
  }
1902
1977
 
1903
1978
  if (
1904
1979
  hasIncompatiblePersistedResume ||
1905
- hasUnverifiablePersistedResume ||
1906
1980
  (needsFullSnapshot && persistedResumeState.kind === `resume`)
1907
1981
  ) {
1908
- commitResetResumeMetadataImmediately()
1982
+ // This reset is part of the current runtime's startup decision. The
1983
+ // persisted wrapper may commit it before loading the atomic baseline,
1984
+ // so carry ownership of exactly this generation into certification.
1985
+ commitResetResumeMetadataImmediately(true)
1909
1986
  }
1910
1987
 
1911
1988
  /**
@@ -1976,6 +2053,10 @@ function createElectricSync<T extends Row<unknown>>(
1976
2053
  commit,
1977
2054
  getCommitCursor: () => commitSequence,
1978
2055
  waitForCommitsAfter,
2056
+ recordSnapshotRow: (row, message) => {
2057
+ recordPendingPresence(collection.getKeyFromItem(row), true)
2058
+ progressiveSnapshotRows.set(collection.getKeyFromItem(row), message)
2059
+ },
1979
2060
  collectionId,
1980
2061
  // Pass the columnMapper's encode function to transform column names
1981
2062
  // (e.g., camelCase to snake_case) when compiling SQL for subset queries
@@ -1986,8 +2067,31 @@ function createElectricSync<T extends Row<unknown>>(
1986
2067
 
1987
2068
  const resumeKeysPromise =
1988
2069
  requiresCompleteResume || freshSnapshotPending
1989
- ? whenHydrated?.()
1990
- : undefined
2070
+ ? hydrateBaseline
2071
+ ? (async () => {
2072
+ await hydrateBaseline()
2073
+ const currentKeySetEvidence = getKeySetEvidence?.()
2074
+ if (
2075
+ canUsePersistedResume &&
2076
+ currentKeySetEvidence?.status !== `consistent`
2077
+ ) {
2078
+ throw new Error(
2079
+ `Electric persisted resume baseline could not be certified during hydration`,
2080
+ )
2081
+ }
2082
+ })()
2083
+ : undefined
2084
+ : requiresKeySetCertification
2085
+ ? (async () => {
2086
+ await certifyResumeSnapshot()
2087
+ const currentKeySetEvidence = getKeySetEvidence?.()
2088
+ if (currentKeySetEvidence?.status !== `consistent`) {
2089
+ throw new Error(
2090
+ `Electric persisted resume baseline could not be certified`,
2091
+ )
2092
+ }
2093
+ })()
2094
+ : undefined
1991
2095
  let areResumeKeysReady = !resumeKeysPromise
1992
2096
  const pendingResumeBatches: Array<Array<Message<T>>> = []
1993
2097
  let unsubscribeStream: () => void = () => {}
@@ -2021,35 +2125,40 @@ function createElectricSync<T extends Row<unknown>>(
2021
2125
 
2022
2126
  if (freshSnapshotPending) {
2023
2127
  freshSnapshotPending = false
2024
- begin()
2128
+ beginSourceTransaction()
2025
2129
  transactionStarted = true
2026
2130
  truncate()
2131
+ recordPendingTruncate()
2027
2132
  syncedKeys.clear()
2028
2133
  clearTagTrackingState()
2029
2134
  isResettingSnapshot = true
2030
2135
  resetGeneration++
2031
2136
  }
2032
2137
 
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`)
2138
+ // Without persistence, core owns pending source transactions and can
2139
+ // rebuild this callback's presence overlay from them. Persistence owns
2140
+ // queued source transactions until their FIFO turn, so retain the
2141
+ // overlay across callbacks instead. A queued truncate still fences off
2142
+ // the previous snapshot below.
2143
+ if (!metadata?.persistence && !transactionStarted) {
2144
+ pendingPresence.clear()
2145
+ usesBaseline = true
2146
+ for (const pending of collection._state.pendingSyncedTransactions) {
2147
+ if (pending.truncate) {
2148
+ pendingPresence.clear()
2149
+ usesBaseline = false
2150
+ }
2151
+ for (const operation of pending.operations) {
2152
+ pendingPresence.set(operation.key, operation.type !== `delete`)
2153
+ }
2045
2154
  }
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
- )
2155
+ for (const message of bufferedMessages) {
2156
+ if (isChangeMessage(message)) {
2157
+ pendingPresence.set(
2158
+ collection.getKeyFromItem(message.value),
2159
+ message.headers.operation !== `delete`,
2160
+ )
2161
+ }
2053
2162
  }
2054
2163
  }
2055
2164
 
@@ -2091,7 +2200,10 @@ function createElectricSync<T extends Row<unknown>>(
2091
2200
  }
2092
2201
  if (!receivesCompleteRows) continue
2093
2202
  }
2094
- pendingPresence.set(rowId, operation !== `delete`)
2203
+ recordPendingPresence(rowId, operation !== `delete`)
2204
+ if (operation !== `update`) {
2205
+ progressiveSnapshotRows.delete(rowId)
2206
+ }
2095
2207
  }
2096
2208
 
2097
2209
  if (isChangeMessage(message)) {
@@ -2106,11 +2218,19 @@ function createElectricSync<T extends Row<unknown>>(
2106
2218
  // EXCEPTION: If a transaction is already started (e.g., from must-refetch), write
2107
2219
  // directly to it instead of buffering. This prevents orphan transactions.
2108
2220
  if (isBufferingInitialSync() && !transactionStarted) {
2221
+ if (message.headers.operation === `update`) {
2222
+ const rowId = collection.getKeyFromItem(message.value)
2223
+ const snapshotRow = progressiveSnapshotRows.get(rowId)
2224
+ if (snapshotRow) {
2225
+ bufferedMessages.push(snapshotRow)
2226
+ progressiveSnapshotRows.delete(rowId)
2227
+ }
2228
+ }
2109
2229
  bufferedMessages.push(message)
2110
2230
  } else {
2111
2231
  // Normal processing: write changes immediately
2112
2232
  if (!transactionStarted) {
2113
- begin()
2233
+ beginSourceTransaction()
2114
2234
  transactionStarted = true
2115
2235
  }
2116
2236
 
@@ -2142,11 +2262,11 @@ function createElectricSync<T extends Row<unknown>>(
2142
2262
  // Normal processing: process move-out immediately
2143
2263
  transactionStarted = processMoveOutEvent(
2144
2264
  message.headers.patterns,
2145
- begin,
2265
+ beginSourceTransaction,
2146
2266
  write,
2147
2267
  transactionStarted,
2148
2268
  (rowId) => {
2149
- pendingPresence.set(rowId, false)
2269
+ recordPendingPresence(rowId, false)
2150
2270
  syncedKeys.delete(rowId)
2151
2271
  },
2152
2272
  )
@@ -2168,7 +2288,7 @@ function createElectricSync<T extends Row<unknown>>(
2168
2288
 
2169
2289
  // Start a transaction and truncate the collection
2170
2290
  if (!transactionStarted) {
2171
- begin()
2291
+ beginSourceTransaction()
2172
2292
  transactionStarted = true
2173
2293
  }
2174
2294
 
@@ -2179,8 +2299,7 @@ function createElectricSync<T extends Row<unknown>>(
2179
2299
 
2180
2300
  // Clear synced keys tracking since we're starting fresh
2181
2301
  syncedKeys.clear()
2182
- pendingPresence.clear()
2183
- usesBaseline = false
2302
+ recordPendingTruncate()
2184
2303
  isResettingSnapshot = true
2185
2304
  resetGeneration++
2186
2305
 
@@ -2192,6 +2311,7 @@ function createElectricSync<T extends Row<unknown>>(
2192
2311
  commitPoint = null
2193
2312
  hasReceivedUpToDate = false // Reset for progressive mode (isBufferingInitialSync will reflect this)
2194
2313
  bufferedMessages.length = 0 // Clear buffered messages
2314
+ progressiveSnapshotRows.clear()
2195
2315
  }
2196
2316
  }
2197
2317
 
@@ -2222,7 +2342,7 @@ function createElectricSync<T extends Row<unknown>>(
2222
2342
  )
2223
2343
 
2224
2344
  // Start atomic swap transaction
2225
- begin()
2345
+ beginSourceTransaction()
2226
2346
 
2227
2347
  // Truncate to clear all snapshot data
2228
2348
  truncate()
@@ -2257,7 +2377,7 @@ function createElectricSync<T extends Row<unknown>>(
2257
2377
  // normal-stream transactionStarted flag is still false.
2258
2378
  true,
2259
2379
  (rowId) => {
2260
- pendingPresence.set(rowId, false)
2380
+ recordPendingPresence(rowId, false)
2261
2381
  syncedKeys.delete(rowId)
2262
2382
  },
2263
2383
  )
@@ -2269,11 +2389,12 @@ function createElectricSync<T extends Row<unknown>>(
2269
2389
 
2270
2390
  // Commit the atomic swap
2271
2391
  stageResumeMetadata()
2272
- applied = commit()
2392
+ applied = commitSourceTransaction()
2273
2393
 
2274
2394
  // Exit buffering phase by marking that we've received up-to-date
2275
2395
  // isBufferingInitialSync() will now return false
2276
2396
  bufferedMessages.length = 0
2397
+ progressiveSnapshotRows.clear()
2277
2398
 
2278
2399
  debug(
2279
2400
  `${collectionId ? `[${collectionId}] ` : ``}Progressive mode: Atomic swap complete, now in normal sync mode`,
@@ -2285,12 +2406,12 @@ function createElectricSync<T extends Row<unknown>>(
2285
2406
  if (!isResettingSnapshot || finishesReset) {
2286
2407
  stageResumeMetadata()
2287
2408
  }
2288
- applied = commit()
2409
+ applied = commitSourceTransaction()
2289
2410
  transactionStarted = false
2290
2411
  } else if (commitPoint === `up-to-date` && metadata) {
2291
- begin()
2412
+ beginSourceTransaction()
2292
2413
  stageResumeMetadata()
2293
- applied = commit()
2414
+ applied = commitSourceTransaction()
2294
2415
  }
2295
2416
  }
2296
2417
  const readyErrorVersion = streamErrorVersion
@@ -2300,7 +2421,15 @@ function createElectricSync<T extends Row<unknown>>(
2300
2421
  void applied.then(
2301
2422
  () =>
2302
2423
  wrappedMarkReady(wasBufferingInitialSync, readyErrorVersion),
2303
- () => undefined,
2424
+ (error: unknown) => {
2425
+ if (!isActiveLifecycle() || abortController.signal.aborted) {
2426
+ return
2427
+ }
2428
+ streamErrorVersion++
2429
+ unsubscribeStream()
2430
+ abortController.abort()
2431
+ if (collection.status !== `error`) markError(error)
2432
+ },
2304
2433
  )
2305
2434
  }
2306
2435