Skip to content
Open
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/fine-planets-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': patch
---

Add support for delete-then-insert on the same record within a transaction, enabling undo workflows by either canceling both mutations (if data matches) or converting to an update.
33 changes: 31 additions & 2 deletions packages/db/src/transactions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createDeferred } from './deferred'
import './duplicate-instance-check'
import { deepEquals } from './utils'
import {
MissingMutationFunctionError,
TransactionAlreadyCompletedRollbackError,
Expand Down Expand Up @@ -31,9 +32,10 @@ let sequenceNumber = 0
* - (update, update) → update (replace with latest, union changes)
* - (delete, delete) → delete (replace with latest)
* - (insert, insert) → insert (replace with latest)
* - (delete, insert) → null if restoring original, 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 @@ -93,6 +95,33 @@ function mergePendingMutations<T extends object>(
// Same type: replace with latest
return incoming

case `delete-insert`: {
// Insert after delete: check if restoring to original state
if (deepEquals(existing.original, incoming.modified)) {
// Exact restore - cancel both mutations (like insert-delete)
return null
}
// Different data - treat as update from original to new state
// Compute actual diff for changes (only properties that differ)
// Cast existing.original to T since delete mutations always have the full original
const originalData = existing.original as T
const changes = Object.fromEntries(
Object.entries(incoming.modified).filter(
([key, value]) =>
!deepEquals(originalData[key as keyof T], value as T[keyof T]),
),
) as Partial<T>
return {
...incoming,
type: `update` as const,
original: existing.original,
modified: incoming.modified,
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
85 changes: 85 additions & 0 deletions packages/db/tests/transactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,91 @@ describe(`Transactions`, () => {
expect(transaction3.state).toBe(`failed`)
})

describe(`delete-insert mutation merging`, () => {
it(`should cancel both mutations when re-inserting the same data after delete`, () => {
const transaction = createTransaction({
mutationFn: async () => Promise.resolve(),
autoCommit: false,
})
const collection = createCollection<{
id: number
value: string
}>({
id: `delete-insert-same`,
getKey: (item) => item.id,
sync: {
sync: () => {},
},
})

// Seed synced data
const originalItem = { id: 1, value: `original` }
collection._state.syncedData.set(1, originalItem)

// Delete then re-insert with same data
transaction.mutate(() => {
collection.delete(1)
})
expect(transaction.mutations).toHaveLength(1)
expect(transaction.mutations[0]!.type).toBe(`delete`)

transaction.mutate(() => {
collection.insert({ id: 1, value: `original` })
})

// Should cancel both mutations since data is identical
expect(transaction.mutations).toHaveLength(0)
})

it(`should convert to update when re-inserting different data after delete`, () => {
const transaction = createTransaction({
mutationFn: async () => Promise.resolve(),
autoCommit: false,
})
const collection = createCollection<{
id: number
value: string
}>({
id: `delete-insert-different`,
getKey: (item) => item.id,
sync: {
sync: () => {},
},
})

// Seed synced data
const originalItem = { id: 1, value: `original` }
collection._state.syncedData.set(1, originalItem)

// Delete then re-insert with different data
transaction.mutate(() => {
collection.delete(1)
})
expect(transaction.mutations).toHaveLength(1)
expect(transaction.mutations[0]!.type).toBe(`delete`)

transaction.mutate(() => {
collection.insert({ id: 1, value: `modified` })
})

// Should become an update mutation
expect(transaction.mutations).toHaveLength(1)
expect(transaction.mutations[0]!.type).toBe(`update`)
expect(transaction.mutations[0]!.original).toEqual({
id: 1,
value: `original`,
})
expect(transaction.mutations[0]!.modified).toEqual({
id: 1,
value: `modified`,
})
// Changes should only contain the properties that actually differ
expect(transaction.mutations[0]!.changes).toEqual({
value: `modified`,
})
})
})

describe(`duplicate instance detection`, () => {
it(`sets a global marker in dev mode when in browser top window`, () => {
// The duplicate instance marker should be set when the module loads in dev mode
Expand Down
Loading