Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-mutation-reconciliation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': patch
---

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.
14 changes: 12 additions & 2 deletions packages/db/src/collection/changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,13 @@ export class CollectionChangesManager<
// Skip batching for user actions (forceEmit=true) to keep UI responsive
if (this.shouldBatchEvents && !forceEmit) {
// Add events to the batch
this.batchedEvents.push(...changes)
this.batchedEvents.push(
...changes.map((change) =>
change.type === `delete`
? this.enrichChangeWithVirtualProps(change)
: change,
),
)
return
}

Expand All @@ -146,7 +152,11 @@ export class CollectionChangesManager<
combined.set(
change.key,
pending?.type === `delete` && change.type === `insert`
? { ...change, type: `update`, previousValue: pending.value }
? {
...change,
type: `update`,
previousValue: pending.value,
}
: change,
)
}
Expand Down
33 changes: 29 additions & 4 deletions packages/db/src/collection/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1016,11 +1016,17 @@ export class CollectionStateManager<
// First collect all keys that will be affected by sync operations
const changedKeys = new Set<TKey>()
const syncedInsertedOrUpdatedKeys = new Set<TKey>()
const firstSyncOperations = new Map<
TKey,
OptimisticChangeMessage<TOutput>
>()
for (const transaction of committedSyncedTransactions) {
for (const operation of transaction.operations) {
changedKeys.add(operation.key as TKey)
if (operation.type !== `delete`)
syncedInsertedOrUpdatedKeys.add(operation.key as TKey)
const key = operation.key as TKey
changedKeys.add(key)
if (!firstSyncOperations.has(key))
firstSyncOperations.set(key, operation)
if (operation.type !== `delete`) syncedInsertedOrUpdatedKeys.add(key)
}
for (const [key] of transaction.rowMetadataWrites) {
changedKeys.add(key)
Expand Down Expand Up @@ -1148,6 +1154,11 @@ export class CollectionStateManager<
: 'remote'
if (origin === `local`) localKeys.add(key)

// A sync source may reuse a live-reading row object, making an
// enriched snapshot cached for an earlier publication stale.
if (operation.type !== `delete`)
this.virtualPropsCache.delete(operation.value)

// Update synced data
switch (operation.type) {
case `insert`:
Expand Down Expand Up @@ -1381,7 +1392,21 @@ export class CollectionStateManager<

// Now check what actually changed in the final visible state
for (const key of changedKeys) {
const previousVisibleValue = currentVisibleState.get(key)
const firstSyncOperation = firstSyncOperations.get(key)
// A live-reading source can change a reused row before this commit
// captures it. Later writes must not substitute an intermediate value.
const syncPreviousValue =
firstSyncOperation?.type === `update` &&
currentVisibleState.get(key) === firstSyncOperation.value
? firstSyncOperation.previousValue
: undefined
const previousVisibleValue =
!hasTruncateSync &&
!previousOptimisticUpserts.has(key) &&
!previousOptimisticDeletes.has(key) &&
syncPreviousValue !== undefined
? syncPreviousValue
: currentVisibleState.get(key)
const newVisibleValue = this.get(key) // This returns the new derived state
const previousVirtualProps =
this.preSyncVirtualState.get(key) ??
Expand Down
1 change: 1 addition & 0 deletions packages/db/src/local-only.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ function createLocalOnlySync<T extends object, TKey extends string | number>(
let collection: Collection<T, TKey, LocalOnlyCollectionUtils> | null = null

const sync: SyncConfig<T, TKey> = {
rowUpdateMode: `full`,
/**
* Sync function that captures sync parameters and applies initial data
* @param params - Sync parameters containing begin, write, and commit functions
Expand Down
1 change: 1 addition & 0 deletions packages/db/src/local-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,7 @@ function createLocalStorageSync<T extends object>(
manualTrigger?: () => void
collection: any
} = {
rowUpdateMode: `full`,
sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => {
const { begin, write, commit, markReady } = params

Expand Down
41 changes: 37 additions & 4 deletions packages/db/src/transactions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createDeferred } from './deferred'
import { deepEquals } from './utils'
import { safeRandomUUID } from './utils/uuid'
import { normalizeError } from './utils/error.js'
import './duplicate-instance-check'
Expand Down Expand Up @@ -159,9 +160,11 @@ function getTransactionAmbientScope(transaction: object): TransactionScope {
* - (update, update) → update (replace with latest, union changes)
* - (delete, delete) → delete (replace with latest)
* - (insert, insert) → insert (replace with latest)
* - (delete, insert) → insert without an authoritative row, null if restoring
* the authoritative row, otherwise update
*
* Note: (delete, update) and (delete, insert) should never occur as the collection
* layer prevents operations on deleted items within the same transaction.
* Note: (delete, update) should never occur as the collection layer prevents
* update operations on deleted items within the same transaction.
*
* @param existing - The existing mutation in the transaction
* @param incoming - The new mutation being applied
Expand Down Expand Up @@ -199,7 +202,8 @@ function mergePendingMutations<T extends object>(
return null

case `update-delete`:
// Delete after update: delete dominates
case `delete-delete`:
// Delete dominates an update or earlier delete.
return incoming

case `update-update`: {
Expand All @@ -216,11 +220,39 @@ function mergePendingMutations<T extends object>(
}
}

case `delete-delete`:
case `insert-insert`:
// Same type: replace with latest
return incoming

case `delete-insert`: {
const original = existing.collection._state.syncedData.get(existing.key)
if (original === undefined) return incoming
if (deepEquals(original, incoming.modified)) {
return null
}

const modified = incoming.modified
const keys = new Set([...Object.keys(original), ...Object.keys(modified)])
const changes: Partial<T> = {}
for (const key of keys) {
if (
Object.hasOwn(original, key) !== Object.hasOwn(modified, key) ||
!deepEquals(original[key as keyof T], modified[key as keyof T])
) {
changes[key as keyof T] = modified[key as keyof T]
}
}

return {
...incoming,
type: `update`,
original,
changes,
metadata: incoming.metadata ?? existing.metadata,
syncMetadata: { ...existing.syncMetadata, ...incoming.syncMetadata },
}
}

default: {
// Exhaustiveness check
const _exhaustive: never = `${existing.type}-${incoming.type}` as never
Expand Down Expand Up @@ -450,6 +482,7 @@ class Transaction<T extends object = Record<string, unknown>> {
* - **insert + delete** → removed (mutations cancel each other out)
* - **update + delete** → delete (delete dominates)
* - **update + update** → update (union changes, keep first original)
* - **delete + insert** → removed if restored, otherwise update
* - **same type** → replace with latest
*
* This merging reduces over-the-wire churn and keeps the optimistic local view
Expand Down
Loading
Loading