Collaborative undo
Several people edit the same document and each undoes only their own edits. An undo writes back the values a step replaced, so it must never erase a newer peer edit. When it would, the step is refused: undo passes over it and reports why, and nothing of it is undone.
The lifecycle diagrams are in ARCHITECTURE.md.
Steps and scopes
A scope is a named history with its own undo and redo stacks, usually one per editor tab or tool. Undo in one scope never walks into another. Scopes can also follow what is open, such as one per clip: a scope starts with its first step and removeScope(scope) drops it with its steps.
A step is what one undo takes back: every local change made by one user action, across every registered document. Each document gets its own part of the step, guarded and confirmed on its own.
record(scope, label, edit)files the changes made duringeditas one step.open(scope, label)keeps a step open across calls, for a drag over pointer events.- A step opened inside another joins the outer one.
- A local change made outside any step becomes its own step in the scope its registration's
resolveScope(change)returns.nullmeans it makes no step. - A change with an empty
inversejoins no step. - Filing a step clears the redo stack of its scope.
Undo is not a special operation: it applies the step's inverse commands as new local edits through applyStep(command, basis), files them as a step on the redo stack, and the sync client sends them like any edit. Redo is the same with the stacks swapped.
Origins
Every change carries an origin:
localis this person's edit, undo and redo included. Only local changes make steps.remoteis a peer's command. Only remote changes refuse steps.replayis this client's own pending command applied again after a peer's.
A document also emits reset when its state is replaced. A "load" replaces it from a snapshot and can refuse steps. A "rewind" rolls pending commands back so a peer's command applies first; they replay right after, so the history ignores it.
Guards
Each part of a step guards the values its undo would write back, with one HistoryGuard built by the registration's keys.guard(commands).
- A change touches the guard when
guard.touches(keys.written(change))is true. A peer change that touches it refuses the step. - The guard is captured (
capture()) when the step is filed, and captured again after each of this client's ownlocalorreplaywrites to it. - Undo, redo and a
"load"reset compare the document with the capture (same(captured)). This catches writes no change reported, such as a voxel layer moved under the step's cells. - The guard comes from the document registered under the part's id at that moment, so a document closed and reopened under the same id keeps guarding its old steps.
KeyedGuard covers documents with a few string keys. Documents with many small keys use their own TWritten, such as packed pixels or voxel cells, and their own TCapture, such as typed arrays.
Refusals
A refused step stays refused. Undo and redo skip it, emit skipped, and state(scope).refused lists it with its reason.
peer: a peer change touched the guard.server: the server refused the step's edit.closed: found on undo or redo, one of the step's documents is no longer registered.changed: found on undo, redo or a"load"reset, a guarded value no longer matches its capture.gone: the documents refused every command of the undo or redo.
When the server refuses an undo or redo, the replayed step goes away and the original step comes back to its stack, refused.
Three things never refuse a step: a peer change arriving while the step still waits for its receipts (the server ordered the peer change first), this client's own changes, and a "rewind".
Receipts and basis
A part is pending while its changes wait for the server's answers. The sync client writes those answers to the document's ChangeReceipts; without an attached sync client, a step is settled as soon as it is filed.
confirm(change, version)settles the change. The part'sbasisbecomes the highest version confirmed so far.refuse(change)refuses the step asserver.
An undo is sent with the part's basis, and the server refuses it when a peer wrote the same values after that version. A part with no confirmed version replays with basis 0, which the server accepts only while this client is still the last writer.
Custom history sources
CommandHistory accepts any HistorySource, not only a CommandDocument. A custom source must:
- emit
changewith aCommandChangebuilt byCommandChange.local(),remote()orreplay(); - emit
resetwith"load"or"rewind"when its state is replaced; - apply undo and redo commands in
applyStep(command, basis)as local changes carryingbasis; - expose
receiptsfor its sync client.