Skip to content

Commit 7f6b643

Browse files
KyleAMathewsSamJB123autofix-ci[bot]
authored
fix(db): preserve mutation reconciliation semantics (#1835)
* fix(db): preserve mutation reconciliation semantics Co-authored-by: Marc MacLeod <marbemac+gh@gmail.com> Co-authored-by: SamJB123 <sambide@gmail.com> * ci: apply automated fixes * fix(db): harden mutation reconciliation Co-authored-by: Marc MacLeod <marbemac+gh@gmail.com> Co-authored-by: SamJB123 <sambide@gmail.com> * refactor(db): reduce reconciliation weight * test(db): cover reconciliation design boundaries --------- Co-authored-by: SamJB123 <sambide@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 1e54c6a commit 7f6b643

10 files changed

Lines changed: 1389 additions & 13 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/db': patch
3+
---
4+
5+
Fix same-key delete-then-insert reduction, preserve whole-row replacements through local adapters, and publish immutable previous values for live-object and replacement-object sync updates. Same-reference live rows still require an immutable provider `previousValue`; stale or partial values on that reused reference remain unsupported.

packages/db/src/collection/changes.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,13 @@ export class CollectionChangesManager<
124124
// Skip batching for user actions (forceEmit=true) to keep UI responsive
125125
if (this.shouldBatchEvents && !forceEmit) {
126126
// Add events to the batch
127-
this.batchedEvents.push(...changes)
127+
this.batchedEvents.push(
128+
...changes.map((change) =>
129+
change.type === `delete`
130+
? this.enrichChangeWithVirtualProps(change)
131+
: change,
132+
),
133+
)
128134
return
129135
}
130136

@@ -146,7 +152,11 @@ export class CollectionChangesManager<
146152
combined.set(
147153
change.key,
148154
pending?.type === `delete` && change.type === `insert`
149-
? { ...change, type: `update`, previousValue: pending.value }
155+
? {
156+
...change,
157+
type: `update`,
158+
previousValue: pending.value,
159+
}
150160
: change,
151161
)
152162
}

packages/db/src/collection/state.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,11 +1016,17 @@ export class CollectionStateManager<
10161016
// First collect all keys that will be affected by sync operations
10171017
const changedKeys = new Set<TKey>()
10181018
const syncedInsertedOrUpdatedKeys = new Set<TKey>()
1019+
const firstSyncOperations = new Map<
1020+
TKey,
1021+
OptimisticChangeMessage<TOutput>
1022+
>()
10191023
for (const transaction of committedSyncedTransactions) {
10201024
for (const operation of transaction.operations) {
1021-
changedKeys.add(operation.key as TKey)
1022-
if (operation.type !== `delete`)
1023-
syncedInsertedOrUpdatedKeys.add(operation.key as TKey)
1025+
const key = operation.key as TKey
1026+
changedKeys.add(key)
1027+
if (!firstSyncOperations.has(key))
1028+
firstSyncOperations.set(key, operation)
1029+
if (operation.type !== `delete`) syncedInsertedOrUpdatedKeys.add(key)
10241030
}
10251031
for (const [key] of transaction.rowMetadataWrites) {
10261032
changedKeys.add(key)
@@ -1148,6 +1154,11 @@ export class CollectionStateManager<
11481154
: 'remote'
11491155
if (origin === `local`) localKeys.add(key)
11501156

1157+
// A sync source may reuse a live-reading row object, making an
1158+
// enriched snapshot cached for an earlier publication stale.
1159+
if (operation.type !== `delete`)
1160+
this.virtualPropsCache.delete(operation.value)
1161+
11511162
// Update synced data
11521163
switch (operation.type) {
11531164
case `insert`:
@@ -1381,7 +1392,21 @@ export class CollectionStateManager<
13811392

13821393
// Now check what actually changed in the final visible state
13831394
for (const key of changedKeys) {
1384-
const previousVisibleValue = currentVisibleState.get(key)
1395+
const firstSyncOperation = firstSyncOperations.get(key)
1396+
// A live-reading source can change a reused row before this commit
1397+
// captures it. Later writes must not substitute an intermediate value.
1398+
const syncPreviousValue =
1399+
firstSyncOperation?.type === `update` &&
1400+
currentVisibleState.get(key) === firstSyncOperation.value
1401+
? firstSyncOperation.previousValue
1402+
: undefined
1403+
const previousVisibleValue =
1404+
!hasTruncateSync &&
1405+
!previousOptimisticUpserts.has(key) &&
1406+
!previousOptimisticDeletes.has(key) &&
1407+
syncPreviousValue !== undefined
1408+
? syncPreviousValue
1409+
: currentVisibleState.get(key)
13851410
const newVisibleValue = this.get(key) // This returns the new derived state
13861411
const previousVirtualProps =
13871412
this.preSyncVirtualState.get(key) ??

packages/db/src/local-only.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@ function createLocalOnlySync<T extends object, TKey extends string | number>(
314314
let collection: Collection<T, TKey, LocalOnlyCollectionUtils> | null = null
315315

316316
const sync: SyncConfig<T, TKey> = {
317+
rowUpdateMode: `full`,
317318
/**
318319
* Sync function that captures sync parameters and applies initial data
319320
* @param params - Sync parameters containing begin, write, and commit functions

packages/db/src/local-storage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,7 @@ function createLocalStorageSync<T extends object>(
737737
manualTrigger?: () => void
738738
collection: any
739739
} = {
740+
rowUpdateMode: `full`,
740741
sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => {
741742
const { begin, write, commit, markReady } = params
742743

packages/db/src/transactions.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createDeferred } from './deferred'
2+
import { deepEquals } from './utils'
23
import { safeRandomUUID } from './utils/uuid'
34
import { normalizeError } from './utils/error.js'
45
import './duplicate-instance-check'
@@ -159,9 +160,11 @@ function getTransactionAmbientScope(transaction: object): TransactionScope {
159160
* - (update, update) → update (replace with latest, union changes)
160161
* - (delete, delete) → delete (replace with latest)
161162
* - (insert, insert) → insert (replace with latest)
163+
* - (delete, insert) → insert without an authoritative row, null if restoring
164+
* the authoritative row, otherwise update
162165
*
163-
* Note: (delete, update) and (delete, insert) should never occur as the collection
164-
* layer prevents operations on deleted items within the same transaction.
166+
* Note: (delete, update) should never occur as the collection layer prevents
167+
* update operations on deleted items within the same transaction.
165168
*
166169
* @param existing - The existing mutation in the transaction
167170
* @param incoming - The new mutation being applied
@@ -199,7 +202,8 @@ function mergePendingMutations<T extends object>(
199202
return null
200203

201204
case `update-delete`:
202-
// Delete after update: delete dominates
205+
case `delete-delete`:
206+
// Delete dominates an update or earlier delete.
203207
return incoming
204208

205209
case `update-update`: {
@@ -216,11 +220,39 @@ function mergePendingMutations<T extends object>(
216220
}
217221
}
218222

219-
case `delete-delete`:
220223
case `insert-insert`:
221224
// Same type: replace with latest
222225
return incoming
223226

227+
case `delete-insert`: {
228+
const original = existing.collection._state.syncedData.get(existing.key)
229+
if (original === undefined) return incoming
230+
if (deepEquals(original, incoming.modified)) {
231+
return null
232+
}
233+
234+
const modified = incoming.modified
235+
const keys = new Set([...Object.keys(original), ...Object.keys(modified)])
236+
const changes: Partial<T> = {}
237+
for (const key of keys) {
238+
if (
239+
Object.hasOwn(original, key) !== Object.hasOwn(modified, key) ||
240+
!deepEquals(original[key as keyof T], modified[key as keyof T])
241+
) {
242+
changes[key as keyof T] = modified[key as keyof T]
243+
}
244+
}
245+
246+
return {
247+
...incoming,
248+
type: `update`,
249+
original,
250+
changes,
251+
metadata: incoming.metadata ?? existing.metadata,
252+
syncMetadata: { ...existing.syncMetadata, ...incoming.syncMetadata },
253+
}
254+
}
255+
224256
default: {
225257
// Exhaustiveness check
226258
const _exhaustive: never = `${existing.type}-${incoming.type}` as never
@@ -450,6 +482,7 @@ class Transaction<T extends object = Record<string, unknown>> {
450482
* - **insert + delete** → removed (mutations cancel each other out)
451483
* - **update + delete** → delete (delete dominates)
452484
* - **update + update** → update (union changes, keep first original)
485+
* - **delete + insert** → removed if restored, otherwise update
453486
* - **same type** → replace with latest
454487
*
455488
* This merging reduces over-the-wire churn and keeps the optimistic local view

0 commit comments

Comments
 (0)