@tanstack/db-sqlite-persistence-core 0.4.7 → 0.4.8

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/persisted.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  SYNC_PERSISTENCE_VERSION,
7
7
  SyncTransactionAbortedError,
8
8
  compileSingleRowExpression,
9
+ equalPersistedSnapshotValues,
9
10
  getLoadSubsetDemandKey,
10
11
  safeRandomUUID,
11
12
  toBooleanPredicate,
@@ -15,6 +16,7 @@ import {
15
16
  } from '@tanstack/db'
16
17
  import {
17
18
  DuplicateRemoteSubsetOwnerError,
19
+ IndeterminateCommitError,
18
20
  InvalidPersistedCollectionConfigError,
19
21
  InvalidPersistedCollectionCoordinatorError,
20
22
  InvalidPersistedStorageKeyEncodingError,
@@ -115,6 +117,7 @@ export type TxCommitted = {
115
117
  } & (
116
118
  | {
117
119
  requiresFullReload: true
120
+ reconciliationReload?: true
118
121
  }
119
122
  | {
120
123
  requiresFullReload: false
@@ -238,6 +241,38 @@ export type ApplyCommittedTxResponse =
238
241
  path?: string | ReadonlyArray<string | number>
239
242
  }
240
243
 
244
+ /** The durable version read before a source transaction enters coordination. */
245
+ export type CommittedTxAnchor = {
246
+ latestRowVersion: number
247
+ resetEpoch: number
248
+ }
249
+
250
+ export type ReconcileCommittedTxResponse =
251
+ | {
252
+ type: `rpc:reconcileCommittedTx:res`
253
+ rpcId: string
254
+ ok: true
255
+ alreadyApplied: boolean
256
+ committed: { term: number; seq: number; rowVersion: number }
257
+ }
258
+ | {
259
+ type: `rpc:reconcileCommittedTx:res`
260
+ rpcId: string
261
+ ok: false
262
+ code: `NOT_LEADER` | `UNDETERMINED` | `PERSISTENCE_ERROR`
263
+ error: string
264
+ sourceCode?: string | number
265
+ path?: string | ReadonlyArray<string | number>
266
+ }
267
+
268
+ export type ReconciledCommittedTx =
269
+ | {
270
+ kind: `already-applied` | `applied-now`
271
+ committed: { term: number; seq: number; rowVersion: number }
272
+ latestRowVersion: number
273
+ }
274
+ | { kind: `unknown` }
275
+
241
276
  export type PullSinceRequest = {
242
277
  type: `rpc:pullSince:req`
243
278
  rpcId: string
@@ -418,6 +453,12 @@ export interface PersistenceAdapter {
418
453
  resetEpoch: number
419
454
  }>
420
455
  applyCommittedTx: (collectionId: string, tx: PersistedTx) => Promise<void>
456
+ /** Atomically certify or apply one source transaction after a lost response. */
457
+ reconcileCommittedTx?: (
458
+ collectionId: string,
459
+ tx: PersistedTx,
460
+ anchor: CommittedTxAnchor,
461
+ ) => Promise<ReconciledCommittedTx>
421
462
  loadCollectionMetadata?: (
422
463
  collectionId: string,
423
464
  ) => Promise<Array<{ key: string; value: unknown }>>
@@ -436,6 +477,15 @@ export interface PersistenceAdapter {
436
477
  latestSeq: number
437
478
  latestRowVersion: number
438
479
  }>
480
+ /** Reserve a new durable election term before announcing Browser leadership. */
481
+ reserveLeadershipTerm?: (
482
+ collectionId: string,
483
+ observedTerm: number,
484
+ ) => Promise<{
485
+ latestTerm: number
486
+ latestSeq: number
487
+ latestRowVersion: number
488
+ }>
439
489
  /**
440
490
  * Runs one complete logical hydrate as a non-preemptible scheduler unit.
441
491
  * The callback must use the supplied unscheduled adapter for all nested
@@ -555,6 +605,13 @@ export interface PersistedCollectionCoordinator {
555
605
  tx: PersistedTx,
556
606
  scopedAdapter?: HydrationPersistenceAdapter,
557
607
  ) => Promise<ApplyCommittedTxResponse>
608
+ /** Available only when the elected adapter can certify the durable outcome. */
609
+ reconcileCommittedTx?: (
610
+ collectionId: string,
611
+ tx: PersistedTx,
612
+ anchor: CommittedTxAnchor,
613
+ scopedAdapter?: HydrationPersistenceAdapter,
614
+ ) => Promise<ReconcileCommittedTxResponse>
558
615
  /** The scoped adapter is leader-local and is never serialized to a follower. */
559
616
  pullSince?: (
560
617
  collectionId: string,
@@ -1252,7 +1309,10 @@ function isTxCommittedPayload(payload: unknown): payload is TxCommitted {
1252
1309
  }
1253
1310
 
1254
1311
  if (payload.requiresFullReload) {
1255
- return true
1312
+ return (
1313
+ payload.reconciliationReload === undefined ||
1314
+ payload.reconciliationReload === true
1315
+ )
1256
1316
  }
1257
1317
 
1258
1318
  return (
@@ -1358,6 +1418,12 @@ class PersistedCollectionRuntime<
1358
1418
  private latestTerm = 0
1359
1419
  private latestSeq = 0
1360
1420
  private latestRowVersion = 0
1421
+ // A metadata-only resume snapshot can advance latestRowVersion without
1422
+ // publishing rows. Gap checks must use the last publicly applied version.
1423
+ private publicRowVersion = 0
1424
+ // A reset does not label later tx notifications with their reset epoch.
1425
+ // Old-term payloads must be checked against durable rows even after a new term.
1426
+ private resetTermFence: number | undefined
1361
1427
  private localTerm = 1
1362
1428
  private localSeq = 0
1363
1429
  private localRowVersion = 0
@@ -2283,6 +2349,8 @@ class PersistedCollectionRuntime<
2283
2349
  this.lifecycleGeneration++
2284
2350
  this.persistedReadiness?.set({ status: `loading` })
2285
2351
  this.startupSettled = false
2352
+ this.publicRowVersion = 0
2353
+ this.resetTermFence = undefined
2286
2354
  this.hydratedDemands.clear()
2287
2355
  this.startupMetadataPromise = null
2288
2356
  this.startPromise = null
@@ -2353,6 +2421,7 @@ class PersistedCollectionRuntime<
2353
2421
  adapter: HydrationPersistenceAdapter,
2354
2422
  ): Promise<void> {
2355
2423
  let rowsLoaded = false
2424
+ let boundRowVersion: number | undefined
2356
2425
  let replayFailure: { reason: unknown } | undefined
2357
2426
  const resetSequence = this.resetSequence
2358
2427
  this.hydrationSequence++
@@ -2376,6 +2445,7 @@ class PersistedCollectionRuntime<
2376
2445
  if (config.lifecycleGeneration !== this.lifecycleGeneration) return
2377
2446
  if (resetSequence !== this.resetSequence) return
2378
2447
  this.bindResumeSnapshotEvidence(snapshot)
2448
+ boundRowVersion = snapshot.latestRowVersion
2379
2449
  } else {
2380
2450
  rows = await this.loadSubsetRowsUnsafe(options, adapter)
2381
2451
  }
@@ -2392,6 +2462,12 @@ class PersistedCollectionRuntime<
2392
2462
  }
2393
2463
  const applied = this.applyRowsToCollection(rows)
2394
2464
  await whenSyncAccepted(applied)
2465
+ if (boundRowVersion !== undefined) {
2466
+ this.publicRowVersion = Math.max(
2467
+ this.publicRowVersion,
2468
+ boundRowVersion,
2469
+ )
2470
+ }
2395
2471
  }
2396
2472
  } finally {
2397
2473
  if (
@@ -2620,10 +2696,14 @@ class PersistedCollectionRuntime<
2620
2696
  const expected = this.persistedResumeGeneration
2621
2697
  return (
2622
2698
  expected !== undefined &&
2623
- expected.latestTerm === generation.latestTerm &&
2624
- expected.latestSeq === generation.latestSeq &&
2625
2699
  expected.latestRowVersion === generation.latestRowVersion &&
2626
- expected.resetEpoch === generation.resetEpoch
2700
+ expected.resetEpoch === generation.resetEpoch &&
2701
+ ((expected.latestTerm === generation.latestTerm &&
2702
+ expected.latestSeq === generation.latestSeq) ||
2703
+ // A no-write election reserves a new term at sequence zero. It does
2704
+ // not change the persisted rows or the source resume cursor.
2705
+ (generation.latestTerm > expected.latestTerm &&
2706
+ generation.latestSeq === 0))
2627
2707
  )
2628
2708
  }
2629
2709
 
@@ -2706,6 +2786,37 @@ class PersistedCollectionRuntime<
2706
2786
  })
2707
2787
  }
2708
2788
 
2789
+ private matchesCollectionSnapshot(
2790
+ rows: Map<TKey, { value: T; metadata?: unknown }>,
2791
+ collectionMetadata: Array<{ key: string; value: unknown }>,
2792
+ ): boolean {
2793
+ if (!this.collection || !this.syncControls.metadata) return false
2794
+ const base = this.collection.base
2795
+ if (base.size !== rows.size) return false
2796
+ for (const [key, row] of rows) {
2797
+ if (
2798
+ !base.has(key) ||
2799
+ !equalPersistedSnapshotValues(base.get(key), row.value) ||
2800
+ !equalPersistedSnapshotValues(
2801
+ this.syncControls.metadata.row.get(key),
2802
+ row.metadata,
2803
+ )
2804
+ ) {
2805
+ return false
2806
+ }
2807
+ }
2808
+ const currentMetadata = this.syncControls.metadata.collection.list()
2809
+ if (currentMetadata.length !== collectionMetadata.length) return false
2810
+ const expectedMetadata = new Map(
2811
+ collectionMetadata.map(({ key, value }) => [key, value]),
2812
+ )
2813
+ return currentMetadata.every(
2814
+ ({ key, value }) =>
2815
+ expectedMetadata.has(key) &&
2816
+ equalPersistedSnapshotValues(expectedMetadata.get(key), value),
2817
+ )
2818
+ }
2819
+
2709
2820
  private async flushQueuedHydrationTransactionsUnsafe(
2710
2821
  adapter: HydrationPersistenceAdapter,
2711
2822
  ): Promise<{ reason: unknown } | undefined> {
@@ -2834,16 +2945,59 @@ class PersistedCollectionRuntime<
2834
2945
  return
2835
2946
  }
2836
2947
 
2948
+ const anchor = this.persistedResumeGeneration && {
2949
+ latestRowVersion: this.latestRowVersion,
2950
+ resetEpoch: this.persistedResumeGeneration.resetEpoch,
2951
+ }
2837
2952
  const streamPosition = this.nextLocalStreamPosition()
2838
2953
  const tx = this.createPersistedTxFromOperations(transaction, streamPosition)
2839
- const response = await this.persistence.coordinator.requestApplyCommittedTx(
2840
- this.collectionId,
2841
- tx,
2842
- // A hydration-scoped adapter is already inside the shared scheduler.
2843
- // Ordinary source commits pass no scoped adapter so the coordinator
2844
- // enters that scheduler before taking the database-wide writer lock.
2845
- scopedAdapter,
2846
- )
2954
+ let response: ApplyCommittedTxResponse
2955
+ try {
2956
+ response = await this.persistence.coordinator.requestApplyCommittedTx(
2957
+ this.collectionId,
2958
+ tx,
2959
+ // A hydration-scoped adapter is already inside the shared scheduler.
2960
+ // Ordinary source commits enter it before taking the writer lock.
2961
+ scopedAdapter,
2962
+ )
2963
+ } catch (error) {
2964
+ if (
2965
+ !(error instanceof IndeterminateCommitError) ||
2966
+ !anchor ||
2967
+ !this.persistence.coordinator.reconcileCommittedTx
2968
+ ) {
2969
+ throw error
2970
+ }
2971
+ let reconciled: ReconcileCommittedTxResponse
2972
+ try {
2973
+ reconciled = await this.persistence.coordinator.reconcileCommittedTx(
2974
+ this.collectionId,
2975
+ tx,
2976
+ anchor,
2977
+ scopedAdapter,
2978
+ )
2979
+ } catch (reconcileError) {
2980
+ if (reconcileError instanceof PersistedCollectionDurabilityError) {
2981
+ throw reconcileError
2982
+ }
2983
+ throw error
2984
+ }
2985
+ if (!reconciled.ok) {
2986
+ if (reconciled.code === `PERSISTENCE_ERROR`) {
2987
+ throw new PersistedCollectionDurabilityError(
2988
+ `Failed to reconcile collection "${this.collectionId}": ${reconciled.error}`,
2989
+ {
2990
+ cause: reconciled,
2991
+ code: reconciled.sourceCode ?? reconciled.code,
2992
+ path: reconciled.path,
2993
+ },
2994
+ )
2995
+ }
2996
+ throw error
2997
+ }
2998
+ this.recordDurableSourceCommit(transaction, reconciled.committed)
2999
+ return
3000
+ }
2847
3001
  if (!response.ok) {
2848
3002
  if (response.code === `PERSISTENCE_ERROR`) {
2849
3003
  const error = new PersistedCollectionDurabilityError(
@@ -2860,12 +3014,22 @@ class PersistedCollectionRuntime<
2860
3014
  `failed to apply external sync transaction through coordinator: ${response.error}`,
2861
3015
  )
2862
3016
  }
3017
+ this.recordDurableSourceCommit(transaction, {
3018
+ term: response.term,
3019
+ seq: response.seq,
3020
+ rowVersion: response.latestRowVersion,
3021
+ })
3022
+ }
3023
+
3024
+ private recordDurableSourceCommit(
3025
+ transaction: BufferedSyncTransaction<T, TKey>,
3026
+ position: { term: number; seq: number; rowVersion: number },
3027
+ ): void {
2863
3028
  if (transaction.lifecycleGeneration !== this.lifecycleGeneration) return
2864
- this.observeStreamPosition(
2865
- response.term,
2866
- response.seq,
2867
- response.latestRowVersion,
2868
- )
3029
+ this.observeStreamPosition(position.term, position.seq, position.rowVersion)
3030
+ if (position.rowVersion === this.publicRowVersion + 1) {
3031
+ this.publicRowVersion = position.rowVersion
3032
+ }
2869
3033
  if (
2870
3034
  transaction.expectedResumeGenerationOwner ===
2871
3035
  this.resumeGenerationOwner &&
@@ -2873,9 +3037,9 @@ class PersistedCollectionRuntime<
2873
3037
  ) {
2874
3038
  this.persistedResumeGeneration = {
2875
3039
  ...this.persistedResumeGeneration,
2876
- latestTerm: response.term,
2877
- latestSeq: response.seq,
2878
- latestRowVersion: response.latestRowVersion,
3040
+ latestTerm: position.term,
3041
+ latestSeq: position.seq,
3042
+ latestRowVersion: position.rowVersion,
2879
3043
  }
2880
3044
  }
2881
3045
  }
@@ -3402,9 +3566,19 @@ class PersistedCollectionRuntime<
3402
3566
  if (isCollectionResetPayload(payload)) {
3403
3567
  void this.applyMutex
3404
3568
  .run(async () => {
3405
- await this.runInHydrationScope((adapter) =>
3406
- this.truncateAndReloadUnsafe(adapter, lifecycleGeneration),
3407
- )
3569
+ await this.runInHydrationScope(async (adapter) => {
3570
+ await this.truncateAndReloadUnsafe(adapter, lifecycleGeneration)
3571
+ await this.rebaseDurableStreamFromSnapshotUnsafe(
3572
+ adapter,
3573
+ lifecycleGeneration,
3574
+ )
3575
+ if (lifecycleGeneration === this.lifecycleGeneration) {
3576
+ // Reloaded rows and the later position-only read are separate
3577
+ // cuts. Zero is the conservative public version in the new epoch.
3578
+ this.publicRowVersion = 0
3579
+ this.resetTermFence = this.latestTerm
3580
+ }
3581
+ })
3408
3582
  if (lifecycleGeneration === this.lifecycleGeneration) {
3409
3583
  await this.flushQueuedTxCommittedUnsafe()
3410
3584
  }
@@ -3433,34 +3607,58 @@ class PersistedCollectionRuntime<
3433
3607
  lifecycleGeneration = this.lifecycleGeneration,
3434
3608
  ): Promise<void> {
3435
3609
  if (lifecycleGeneration !== this.lifecycleGeneration) return
3436
- if (txCommitted.term < this.latestTerm) {
3437
- return
3610
+ if (this.resetTermFence !== undefined) {
3611
+ if (txCommitted.term <= this.resetTermFence) {
3612
+ // This payload could have been committed before the schema reset.
3613
+ // The current durable snapshot is the only safe row authority.
3614
+ await this.runInHydrationScope((adapter) =>
3615
+ this.reloadActiveSubsetsUnsafe(adapter),
3616
+ )
3617
+ return
3618
+ }
3438
3619
  }
3439
-
3440
- if (
3441
- txCommitted.term === this.latestTerm &&
3442
- txCommitted.seq <= this.latestSeq
3443
- ) {
3620
+ // An exact-ID reconciliation can discover a commit whose original
3621
+ // notification was lost. A peer may already be at this stream position
3622
+ // without having loaded that commit's rows.
3623
+ if (txCommitted.requiresFullReload && txCommitted.reconciliationReload) {
3624
+ await this.invalidateFromCommittedTxUnsafe(
3625
+ txCommitted,
3626
+ this.persistence.adapter,
3627
+ )
3628
+ if (lifecycleGeneration === this.lifecycleGeneration) {
3629
+ this.observeStreamPosition(
3630
+ txCommitted.term,
3631
+ txCommitted.seq,
3632
+ txCommitted.latestRowVersion,
3633
+ )
3634
+ this.publicRowVersion = Math.max(
3635
+ this.publicRowVersion,
3636
+ txCommitted.latestRowVersion,
3637
+ )
3638
+ await this.flushQueuedTxCommittedUnsafe()
3639
+ }
3444
3640
  return
3445
3641
  }
3446
-
3642
+ if (txCommitted.latestRowVersion <= this.publicRowVersion) return
3643
+ const observedButUnpublished =
3644
+ txCommitted.term < this.latestTerm ||
3645
+ (txCommitted.term === this.latestTerm &&
3646
+ txCommitted.seq <= this.latestSeq)
3447
3647
  const hasGapInCurrentTerm =
3448
3648
  txCommitted.term === this.latestTerm &&
3449
3649
  txCommitted.seq > this.latestSeq + 1
3450
3650
  const hasGapAcrossTerms =
3451
3651
  txCommitted.term > this.latestTerm && txCommitted.seq > 1
3452
- const hasGap = hasGapInCurrentTerm || hasGapAcrossTerms
3652
+ const hasGap =
3653
+ observedButUnpublished ||
3654
+ txCommitted.latestRowVersion > this.publicRowVersion + 1 ||
3655
+ hasGapInCurrentTerm ||
3656
+ hasGapAcrossTerms
3453
3657
 
3454
3658
  if (hasGap) {
3455
3659
  await this.recoverFromSeqGapUnsafe(lifecycleGeneration)
3456
3660
  if (lifecycleGeneration !== this.lifecycleGeneration) return
3457
- if (
3458
- txCommitted.term < this.latestTerm ||
3459
- (txCommitted.term === this.latestTerm &&
3460
- txCommitted.seq <= this.latestSeq)
3461
- ) {
3462
- return
3463
- }
3661
+ if (txCommitted.latestRowVersion <= this.publicRowVersion) return
3464
3662
  }
3465
3663
 
3466
3664
  this.observeStreamPosition(
@@ -3474,10 +3672,34 @@ class PersistedCollectionRuntime<
3474
3672
  this.persistence.adapter,
3475
3673
  )
3476
3674
  if (lifecycleGeneration === this.lifecycleGeneration) {
3675
+ this.publicRowVersion = Math.max(
3676
+ this.publicRowVersion,
3677
+ txCommitted.latestRowVersion,
3678
+ )
3477
3679
  await this.flushQueuedTxCommittedUnsafe()
3478
3680
  }
3479
3681
  }
3480
3682
 
3683
+ private async rebaseDurableStreamFromSnapshotUnsafe(
3684
+ adapter: HydrationPersistenceAdapter,
3685
+ lifecycleGeneration: number,
3686
+ ): Promise<void> {
3687
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
3688
+ const snapshot = await adapter.loadResumeSnapshot(this.collectionId, {
3689
+ includeRows: false,
3690
+ })
3691
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
3692
+ // The reset epoch can restart row versions at zero. Keep the term/seq
3693
+ // watermark to fence old notifications, but rebase version counters.
3694
+ this.latestRowVersion = snapshot.latestRowVersion
3695
+ this.localRowVersion = snapshot.latestRowVersion
3696
+ this.observeStreamPosition(
3697
+ snapshot.latestTerm,
3698
+ snapshot.latestSeq,
3699
+ snapshot.latestRowVersion,
3700
+ )
3701
+ }
3702
+
3481
3703
  private async recoverFromSeqGapUnsafe(
3482
3704
  lifecycleGeneration: number,
3483
3705
  ): Promise<void> {
@@ -3487,7 +3709,7 @@ class PersistedCollectionRuntime<
3487
3709
  try {
3488
3710
  pullResponse = await this.persistence.coordinator.pullSince(
3489
3711
  this.collectionId,
3490
- this.latestRowVersion,
3712
+ this.publicRowVersion,
3491
3713
  )
3492
3714
  } catch (error) {
3493
3715
  console.warn(`Failed pullSince recovery attempt:`, error)
@@ -3506,6 +3728,10 @@ class PersistedCollectionRuntime<
3506
3728
  await this.runInHydrationScope((adapter) =>
3507
3729
  this.reloadActiveSubsetsUnsafe(adapter),
3508
3730
  )
3731
+ this.publicRowVersion = Math.max(
3732
+ this.publicRowVersion,
3733
+ pullResponse.latestRowVersion,
3734
+ )
3509
3735
  return
3510
3736
  }
3511
3737
  const deltas = pullResponse.deltas
@@ -3513,6 +3739,10 @@ class PersistedCollectionRuntime<
3513
3739
  await this.runInHydrationScope((adapter) =>
3514
3740
  this.reloadActiveSubsetsUnsafe(adapter),
3515
3741
  )
3742
+ this.publicRowVersion = Math.max(
3743
+ this.publicRowVersion,
3744
+ pullResponse.latestRowVersion,
3745
+ )
3516
3746
  return
3517
3747
  }
3518
3748
 
@@ -3538,14 +3768,24 @@ class PersistedCollectionRuntime<
3538
3768
  if (lifecycleGeneration !== this.lifecycleGeneration) return
3539
3769
  }
3540
3770
  })
3771
+ this.publicRowVersion = Math.max(
3772
+ this.publicRowVersion,
3773
+ pullResponse.latestRowVersion,
3774
+ )
3541
3775
  return
3542
3776
  }
3543
3777
  }
3544
3778
  }
3545
3779
 
3546
3780
  if (lifecycleGeneration !== this.lifecycleGeneration) return
3781
+ // A gap is not a schema reset. Replace the durable snapshot in one
3782
+ // publication so readers never see an empty intermediate Collection.
3547
3783
  await this.runInHydrationScope((adapter) =>
3548
- this.truncateAndReloadUnsafe(adapter, lifecycleGeneration),
3784
+ this.reloadActiveSubsetsUnsafe(adapter),
3785
+ )
3786
+ this.publicRowVersion = Math.max(
3787
+ this.publicRowVersion,
3788
+ this.latestRowVersion,
3549
3789
  )
3550
3790
 
3551
3791
  if (this.mode === `sync-present`) {
@@ -3580,14 +3820,15 @@ class PersistedCollectionRuntime<
3580
3820
  txCommitted: TxCommitted,
3581
3821
  adapter: HydrationPersistenceAdapter,
3582
3822
  ): Promise<void> {
3583
- const reloadActiveSubsets = () =>
3823
+ const reloadActiveSubsets = (skipUnchangedReplace = false) =>
3584
3824
  this.runInHydrationScope(
3585
- (scopedAdapter) => this.reloadActiveSubsetsUnsafe(scopedAdapter),
3825
+ (scopedAdapter) =>
3826
+ this.reloadActiveSubsetsUnsafe(scopedAdapter, skipUnchangedReplace),
3586
3827
  adapter,
3587
3828
  )
3588
3829
 
3589
3830
  if (txCommitted.requiresFullReload) {
3590
- await reloadActiveSubsets()
3831
+ await reloadActiveSubsets(txCommitted.reconciliationReload === true)
3591
3832
  return
3592
3833
  }
3593
3834
 
@@ -3671,6 +3912,7 @@ class PersistedCollectionRuntime<
3671
3912
 
3672
3913
  private async reloadActiveSubsetsUnsafe(
3673
3914
  adapter: HydrationPersistenceAdapter,
3915
+ skipUnchangedReplace = false,
3674
3916
  ): Promise<void> {
3675
3917
  const lifecycleGeneration = this.lifecycleGeneration
3676
3918
  const truncateGeneration = this.sourceTruncateGeneration
@@ -3704,15 +3946,35 @@ class PersistedCollectionRuntime<
3704
3946
  hydrationContext.suppliedRowKeys.add(key)
3705
3947
  }
3706
3948
 
3707
- const applied = this.replaceCollectionSnapshot(
3708
- Array.from(mergedRows.entries()).map(([key, row]) => ({
3709
- key,
3710
- value: row.value,
3711
- metadata: row.metadata,
3712
- })),
3713
- collectionMetadata,
3714
- )
3715
- await whenSyncAccepted(applied)
3949
+ if (
3950
+ skipUnchangedReplace &&
3951
+ this.matchesCollectionSnapshot(mergedRows, collectionMetadata)
3952
+ ) {
3953
+ // A full replacement would also clear metadata for missing rows.
3954
+ // Clear only those entries so equal public rows do not republish.
3955
+ const orphanKeys =
3956
+ this.collection?._syncedRowMetadataKeysOutsideBase() ?? []
3957
+ if (orphanKeys.length > 0) {
3958
+ const applied = this.withInternalApply(() => {
3959
+ this.syncControls.begin?.()
3960
+ for (const key of orphanKeys) {
3961
+ this.syncControls.metadata?.row.delete(key)
3962
+ }
3963
+ return this.syncControls.commit?.() ?? true
3964
+ })
3965
+ await whenSyncAccepted(applied)
3966
+ }
3967
+ } else {
3968
+ const applied = this.replaceCollectionSnapshot(
3969
+ Array.from(mergedRows.entries()).map(([key, row]) => ({
3970
+ key,
3971
+ value: row.value,
3972
+ metadata: row.metadata,
3973
+ })),
3974
+ collectionMetadata,
3975
+ )
3976
+ await whenSyncAccepted(applied)
3977
+ }
3716
3978
  } finally {
3717
3979
  if (
3718
3980
  this.activeHydrationContext === hydrationContext &&