Most undo systems mutate history. They delete rows, revert fields, rewrite the past. Ours doesn't touch a single byte. Undo creates new operations that reverse effects. The past is immutable. The future is just more operations.
The Problem with Traditional Undo
Traditional applications implement undo by mutating state:
- "Undo create" deletes the row
- "Undo update" overwrites with previous values
- "Undo delete" resurrects from a backup table
This works, until you try to sync. Two devices. Both undo. Which history is real? The append-only operation log solves this, but only if undo follows the rules.
Compensating Operations
Instead of mutating history, we create compensating operationsβnew operations that reverse the effects of previous ones:
// Traditional undo (mutates history) DELETE FROM transactions WHERE id = 'tx_123' // Compensating operation (preserves history) transaction.revert β Sets deletedAt, links to original operation
The original transaction.create operation still exists. The transaction.revert operation is a new entry that marks the transaction as deleted. Both operations are preserved.
What We Built
Undo Creates
transaction.revert soft-deletes the transaction by setting deletedAt. The row stays.
Undo Updates
transaction.revert.update restores previous field values. We track every changed field.
Undo Deletes
transaction.restore clears deletedAt. The row was never really gone.
Undo Transfers
transfer.revert soft-deletes both sides atomically. Transfer integrity preserved.
The Undo Operation Structure
Every undo operation carries a reference to what it undoes:
{
"opType": "transaction.revert",
"entityId": "tx_abc123",
"payload": {
"transactionId": "tx_abc123",
"accountId": "acc_456",
"originalOperationId": "op_789" // β Links to the operation being undone
},
"metadata": {
"undoOf": "op_789", // β Also tracked in metadata
"undoType": "transaction.create"
}
}This linking is crucial. It prevents double-undo, enables redo, and makes the audit trail complete.
Read-Only Time Travel
With an immutable operation log, time travel becomes trivial. Want to see your finances on December 1st?
// Time travel is read-only computation const historicalState = getStateAt(workspaceId, dec1Timestamp); // We replay operations up to that timestamp operations.filter(op => op.timestamp <= dec1Timestamp) .forEach(op => applyOperationToState(state, op)); // Result: transactions and balances at that moment historicalState.transactions // What existed then historicalState.balances // Account balances at that point historicalState.netWorth // Net worth on Dec 1st
No snapshots stored. No separate backup tables. The operation log is the complete history. We just replay to any point.
How Replay Works
The operation replay service can reconstruct state from operations:
- Chronological order β Operations replayed in timestamp order
- Through LedgerService β Every replay enforces invariants
- Idempotent β Replaying the same operation twice is safe
- Deterministic β Same operations always produce same state
Undo + Sync = No Conflict
This is where compensating operations shine. Consider two devices:
// Device A transaction.create (op_1, ts: 100) transaction.revert (op_2, ts: 200) // Undo // Device B (offline, doesn't know about op_2) transaction.update (op_3, ts: 150) // Updates the same tx // After sync merge: op_1: transaction.create (ts: 100) op_3: transaction.update (ts: 150) // Applied to existing tx op_2: transaction.revert (ts: 200) // Soft-delete wins (later timestamp) // Final state: transaction exists but is soft-deleted // All operations preserved, chronologically correct
Traditional undo would have deleted the row. Device B's update would conflict. With compensating operations, everything merges cleanly because nothing is ever deletedβonly marked.
Safety Guarantees
| Guarantee | How We Enforce It |
|---|---|
| No double-undo | Check if operation already has an undo operation |
| Undo is auditable | Undo operations link to their targets via metadata |
| Redo is possible | Undo operations themselves can be undone |
| Balances stay correct | syncAccountBalanceCache() after every undo |
The Validation Step
Before any undo executes, we validate:
validateUndo(operationId) βββ Does operation exist? βββ Is operation type undoable? βββ Has it already been undone? βββ Does target entity exist? βββ Is entity in expected state? Returns: { canUndo: boolean, reason: string }
Clear error messages. No silent failures. If undo can't proceed, you know exactly why.
Undo used to mean "rewrite history." Now it means "add to history." Every action, every undo, every redoβall preserved, all auditable, all sync-safe.
You literally can't break your financial data. Every change is reversible. The past is forever.
β The Accelerate Finance Team