diff --git a/.changeset/fine-planets-wash.md b/.changeset/fine-planets-wash.md new file mode 100644 index 000000000..b0510e567 --- /dev/null +++ b/.changeset/fine-planets-wash.md @@ -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. diff --git a/packages/db/src/transactions.ts b/packages/db/src/transactions.ts index 858772be2..42e9098e8 100644 --- a/packages/db/src/transactions.ts +++ b/packages/db/src/transactions.ts @@ -1,5 +1,6 @@ import { createDeferred } from './deferred' import './duplicate-instance-check' +import { deepEquals } from './utils' import { MissingMutationFunctionError, TransactionAlreadyCompletedRollbackError, @@ -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 @@ -93,6 +95,33 @@ function mergePendingMutations( // 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 + 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 diff --git a/packages/db/tests/transactions.test.ts b/packages/db/tests/transactions.test.ts index a0c716052..ba949b147 100644 --- a/packages/db/tests/transactions.test.ts +++ b/packages/db/tests/transactions.test.ts @@ -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