ADR 0002 — Every mutation is a Command; history is a subscriber, not a core feature
Status: accepted · Amends: — · Amended by: —
Context
Undo is the feature users notice only when it is wrong. In an editing tool it is not a convenience — a surveyor who cannot take back a mis-drag will not use the tool at all.
The hard part is not implementing undo. It is implementing undo across plugins that have never heard of each other. The draw plugin adds a polygon; the edit plugin moves a vertex; the topology plugin repairs a self-intersection; a plugin nobody has written yet does something none of us anticipated. All four must land in one coherent stack, in the right order, with one Ctrl+Z per user-visible action — without any of them importing any other.
The obvious implementations fail in specific, well-known ways:
- Snapshot the whole store per action. Simple, and it works right up until the store holds 50 000 parcels, at which point 200 undo steps is 10 million features in memory.
- Diff the store after each action. Now undo depends on a diff algorithm being correct for every geometry type anyone will ever add, and a plugin that adds a new one silently breaks undo for everyone.
- Let each plugin implement its own undo. Then Ctrl+Z means "undo the last thing this plugin did", the global stack is a lie, and undoing a draw after an edit undoes the wrong thing.
Decision
Every state change in Blaeu is a Command, and a Command is the only way state
changes.
interface Command<R = void> {
readonly type: string
readonly label?: string // shown in the undo menu, already localised
readonly transient?: boolean // executes, but is never recorded
readonly gesture?: string // one pointer-down to the matching pointer-up
execute(ctx: CommandContext): R
undo(ctx: CommandContext): void
redo?(ctx: CommandContext): R
coalesceWith?(previous: Command): Command | null
}
The contract is strict and stated plainly: undo(execute(s)) must restore s to deep
equality. Not "close enough", not "visually identical". If your undo cannot restore deep
equality, the command captured too little state — capture more. Every command owes the
round-trip test.
The CommandBus holds no undo stack. It exposes onDidExecute(handler). The history
plugin subscribes to that, keeps two stacks of Commands, and calls undo() on them. It
knows nothing about geometry, drawing, parcels or vertices.
Supporting machinery, each earning its place:
transaction(label, fn)— everything dispatched insidefnbecomes one atomic undo step, and a throw insidefnrestores the store from a snapshot taken up front. It is synchronous and unvalidated, which is what previews and handles want.commitTransaction(label, async (tx) => …)— the same, for durable writes that each have to clear the commit pipeline. A parcel split is remove one, add two; undoing it half-way, or rolling back only half of it, would be worse than having no undo. See ADR 0009 and ADR 0012.coalesceWith(previous)— merges a 200-frame vertex drag into one entry. Without it, a drag costs 200 Ctrl+Zs, which no user will forgive.gesture— names the user gesture a command belongs to, so history need not guess from a wall clock. A surveyor who drags a shared corner, pauses to read the coordinate, then nudges it home is making one gesture and owes exactly one Ctrl+Z.coalesceWithstill has the final say;gestureonly decides whether history asks.transient— a rubber-band preview executes but is never recorded. If it would be maddening to have to press Ctrl+Z past it, it is transient.
Alternatives rejected
A command bus that owns the undo stack. One less plugin, and undo works out of the box. Rejected because it forces every product to pay for undo: a read-only viewer, a kiosk, an embedded thumbnail renderer all carry an undo stack they will never touch. More decisively, it forecloses the interesting variants — a collaborative product needs a history that respects other people's commands (you may not undo my edit), and a server-backed product needs one that reconciles with a remote log. As a subscriber, history is replaceable. As a core feature, it is not.
Event sourcing over a persistent store. Genuinely attractive, and closer to what a
collaborative back end will want. Rejected for now because it imposes an immutable-log
model on every plugin author, including one writing a throwaway internal tool, and because
the in-memory command stack is a strict subset of it: an event-sourced history plugin can be
written later, against the same Command interface, without touching the kernel. That is
the whole point of history being a subscriber.
Undo via inverse-operation inference (auto-derive undo from execute). Requires the
store to be a pure function of the command, which stops being true the moment a command
mints an id or stamps a timestamp. And an inferred inverse that is subtly wrong is far worse
than an explicit one you can test.
Consequences
- Good. A plugin written by a stranger in three years gets Ctrl+Z for free by
implementing
Commandand dispatching through the bus. No registration in history, no import of history, no coupling in either direction. This is the property the whole ADR exists to buy. - Good. The command bus is exactly the seam a CRDT or an operational-transform layer
would attach to (see ROADMAP). That is not luck: a stream of typed, reversible,
serialisable operations with a stable
typefield is what every collaboration protocol wants as input. Designing for undo produced the shape collaboration needs. - Good.
map.debugand telemetry get an honest, semantic action log for free. - Bad. Boilerplate. Every mutation is a class with
executeandundo, and the temptation to reach into the store directly is constant.store._add/_update/_removeare marked@internaland the store freezes reads in development for exactly this reason. - Bad. The deep-equality contract is demanding. A command that captures what it was asked to do can undo approximately; only a command that captures what the store actually did — the minted ids, the stamped meta — can undo exactly. Getting this wrong produces a bug that surfaces three actions later, which is why the round-trip test is mandatory.
- Resolved.
execute()is synchronous while the commit pipeline is async, sodispatch()does not run the pipeline — and this section used to call that an open ergonomic wart, with a validated write having to runawait map.commit.run(ctx)by hand first and dispatch only if it survived. That workaround is gone: a durable write now goes throughcommands.commit(), which runs the pipeline and applies the write in one call, anddispatch()refuses aCommitCommandoutright. See ADR 0009.
docs/adr/0002-commands-for-cross-plugin-undo.md, which is the source of truth.