Take the action now, and keep it reversible for a bounded time afterwards. Effects go into a ledger, come out newest first, and each one is undone by a compare-and-set rather than by writing an old value back over whatever is there.
import { UndoWindow, formatUnresolved } from 'undo-window';
const win = new UndoWindow({ windowMs: 5 * 60_000 });
const before = await store.get('user:42/email');
await store.set('user:42/email', 'ada@new.test');
win.record({
resource: 'user:42/email',
before,
after: 'ada@new.test',
label: 'bulk email fix',
probe: () => store.get('user:42/email'), // read it
restore: () => store.set('user:42/email', before), // put it back
});
// ... minutes later, the operator changes their mind ...
const report = await win.rollback();
if (report.outcome !== 'reverted') {
page(formatUnresolved(report));
}probe reads and restore writes. The comparison between them happens inside this module, which is the only part that is not obvious.
The four line compensator is the one everybody writes first:
const before = await store.get('user:42/email'); // "ada@old.test"
await store.set('user:42/email', 'ada@new.test');
// ... minutes pass ...
await store.set('user:42/email', before); // "undone"In those minutes the support desk corrected the address to ada@corrected.test. The undo writes ada@old.test straight over it, returns without an error, and the ledger gets a row saying the change was cleanly reversed.
The undo is itself a lost update. It is worse than the original mistake, because the original mistake was visible and this one comes with a receipt saying it did not happen. Nobody goes looking.
So the compensator here is a compare-and-set on a fingerprint of the state the forward action wrote:
- Read the resource.
- Fingerprint it.
- If it matches the post state the effect recorded, write the pre state back.
- If it matches the pre state already, do nothing and say so.
- If it matches neither, stop. Somebody else wrote.
That logic is in #drive inside src/window.ts, not in the caller's compensator, and the split of probe and restore exists to enforce it. A compensator handed the old value and trusted to check first is a compensator whose check gets skipped the first time somebody is in a hurry. Here there is nothing to skip: restore is only ever called on a resource this module has just read and confirmed.
The write is confirmed too. An unconditional write that throws no error is not evidence that anything changed, so after restore returns, the resource is read again. If it comes back holding the post state, the restore silently did nothing and the result is failed, not reverted. If it comes back holding a third state, somebody wrote in the gap between the read and the write, and the result is conflict. A store that can do the compare atomically should do it and return 'applied' or 'precondition-failed', which closes a gap this module cannot close from outside.
The compare failing is the easy half. What happens next is where the clobber comes back.
A conflict is a statement about the world: somebody else wrote. Repeating the same comparison against a world that has already moved on cannot turn it into a success. So a retry loop on a conflicting compensator has exactly two endings. It exhausts its budget and gives up, or somebody removes the compare so that it finally "works", which is the original clobber with a longer log and more confidence behind it.
This module refuses the second ending structurally:
- A
conflictverdict is never retried within the attempt loop.maxAttemptsapplies only toTransientUndoFailure, which a caller throws for socket hangups and 503s. A conflict is terminal on attempt one however highmaxAttemptsis set. - The entry moves to
needs-human, and naming it in a laterrollback()throws instead of running. The error explains why rather than just saying no. - The only way out is
resolve(), which records what a person did. It takes akindof'manually-reverted'or'left-applied'and a note, and refuses an empty note, because a ledger row that records only that somebody clicked past the problem is not evidence of anything.
Two effects on one resource stack: the older one's post state is the newer one's pre state. Undo the older one first and you compare against a state that is two writes out of date. Reverse order is the only order in which each compare can match, so rollback walks the ledger newest first.
The same reasoning says what happens after a conflict. If the newest effect on a resource cannot be reverted, the resource is not at that effect's post state, so it is not at the older effect's post state either, and it never will be again. Those older compensators are not attempted at all. They come back blocked, with zero attempts and a pointer at the entry that stopped them. Trying them would produce another conflict at best, and at worst it is the clobber again.
That blocking survives across calls. An entry sitting in needs-human blocks every older effect on its resource on the next rollback() too, and resolve(id, { kind: 'left-applied' }) blocks them permanently, because putting an older state back would write straight over the effect the operator just decided to keep. Only 'manually-reverted' unblocks them, and it unblocks them for a specific reason: the operator says the resource is now at that entry's pre state, which is exactly the post state the next compensator down compares against.
Partial rollback is refused before anything runs when it asks for the impossible. Reverting an older effect while a newer one on the same resource stays applied would discard the newer one, so rollback({ entryIds }) throws nonSuffix and names the entry that has to be included. The compare-and-set would have caught it anyway, but a conflict is a poor way to learn that the request never made sense. Selecting by resources cannot hit this, since it takes every open entry on the resource.
The compare is a fingerprint equality test, so two different states that hash alike are a silently authorised clobber. JSON.stringify plus a hash is not good enough:
JSON.stringify({ tier: undefined }) === '{}' same as {}
JSON.stringify({ balance: NaN }) === '{"balance":null}' same as null
JSON.stringify(new Map([['a', 1]])) === '{}' same as {}
JSON.stringify({ a: 1, b: 2 }) !== JSON.stringify({ b: 2, a: 1 })
The first three let a compare-and-set pass over a state that really did change. The fourth is the opposite failure and is just as bad: the same state read through two code paths fingerprints differently, every compensator reports a conflict, and a checker that cries wolf gets switched off.
So src/fingerprint.ts sorts keys, tags every value with its type, length prefixes every variable length run so that ["ab","c"] cannot encode like ["a","bc"], and refuses anything whose encoding would be ambiguous: undefined, NaN and the infinities, functions, symbols, symbol keyed properties, invalid Dates, cycles, and any object with a prototype other than Object.prototype or null. Date, bigint, and Uint8Array are supported explicitly and tagged apart from the values they would otherwise collide with. The digest is prefixed sha256: so a future algorithm change cannot compare across two.
canonicalEncoding is exported for the same reason. The only useful answer to "why do these two states fingerprint differently" is to read the two encodings side by side, and a hex digest cannot tell you.
This module fingerprints before, after, and everything probe returns, using the same encoder. A caller who hashed at record time and hashed again at rollback time with any drift between the two would get a compensator that conflicts on every entry.
A no-op effect is refused at record(). If before and after fingerprint identically, the resource at rollback time holds a state that is both the expected post state and the target pre state, and there is no way to tell an effect waiting to be reverted from one already reverted. An entry like that is a reversibility promise with nothing behind it.
recordVerified() reads the resource back immediately. The commonest way a compensator turns out to be useless is that before and after describe a whole record while probe returns one field of it. That mismatch is invisible until a rollback, at which point every entry conflicts at once and the window has usually closed on the ones that mattered. The extra read surfaces it at the point of the mistake and discards the entry.
Expiry is measured on a monotonic clock (process.hrtime by default), not Date.now. An NTP correction that steps the wall clock backwards would reopen a window that had already closed and let a compensator run against an effect the caller was told was permanent. A clock reading that goes backwards throws.
A rollback that starts inside the window is allowed to finish even if it crosses the boundary partway through. Aborting halfway would leave the batch in a state that is neither the pre state nor the post state of anything, and being a few milliseconds late is the smaller problem.
rollback() returns an outcome of 'reverted', 'nothing-to-do', or 'needs-human'. It is derived from the per entry results by a pure function, with no way for a caller or a compensator to pass in an opinion, and any single result that is not an observed success forces the whole report to needs-human. There is deliberately no 'partial' value, because the failure being defended against is a ledger row that reads cleaner than reality, and "mostly reverted" is that row with a hedge in it.
report.unresolved is the list somebody has to act on: resource, label, the fingerprint expected, the fingerprint observed, the fingerprint to restore to, and why the module stopped. formatUnresolved(report) turns it into a page. report.outOfWindow lists entries skipped because their window had already closed, so "roll back everything" never quietly means less than it says.
There is a gap between the read and the write. Unless restore performs a conditional write and returns 'applied' or 'precondition-failed', this module reads, compares, writes, and reads again. A write landing in the middle of that is detected by the read back and reported as a conflict, but it is detected rather than prevented. Only the store can prevent it.
already-reverted cannot distinguish "nobody touched this" from "somebody independently wrote a value equal to the pre state". Both fingerprint the same. The final state is the one that was wanted and nothing was overwritten, so this is reported as a success, but it is a success by coincidence in the second case.
Blocking is per resource, and resources are whatever strings you pass. This module does not know that order:9/status and payment:9 are entangled. If a conflict on one should stop the other, either give them the same resource key or use onUnresolved: 'block-all'.
Concurrency is refused, not managed. Overlapping rollback() calls, and record() or commit() during a rollback, throw reentrant. There is no queue and no locking across processes: two UndoWindow instances over the same store will not see each other, and the only thing standing between them is the compare-and-set.
The ledger is in memory and dies with the process. An UndoWindow that is lost mid window takes its compensators with it, and probe and restore are closures that cannot be serialised anyway. Entry ids are scoped to one instance. Durable undo across a restart needs the compensators reconstructed from something persistent.
Fingerprinting is strict, and that will reject values you would rather it accepted. A state containing a Map, a class instance, or an undefined field has to be converted before it can be recorded. That is the intended trade: a fingerprint that quietly loses part of the value is worse than no fingerprint, because it fails by approving a write.
npm install
npm test # 117 tests: fingerprint collisions, conflict handling, LIFO blocking, expiry, retriesThe adversarial tests are the point. test/window.test.ts spells the naive compensator out in full and shows it destroying the write in between, then shows the same scenario through UndoWindow leaving the store untouched with restore never called. Others assert that a conflict is attempted exactly once with maxAttempts: 10, that a blocked entry's probe is never called at all, and that a restore which silently does nothing is reported as failed rather than as a reversal.
MIT