26 KiB
@suaveplan/undo — Specification
Package path: control-flow/undo
Version: 0.1.0
Status: Shipped 2026-04-24. Spec heading conformance: undo-spec-conformance (2026-04-26).
Purpose
Enterprise-grade undo/redo engine for state management. Supports synchronous linear stacks, asynchronous stacks, branching history (alternate timelines), selective undo by command ID, history compression, and command merging.
Requirements
Requirement: Synchronous UndoStack with bounded history and transactions
The package SHALL export UndoStack<T> providing synchronous linear undo/redo with optional maxEntries and maxMemoryBytes trimming, EventBus integration, and transaction support (beginTransaction / commit / rollback). Operations on an empty stack MUST throw UndoError(UNDO.EMPTY_STACK). Nested transactions MUST throw UndoError(UNDO.TRANSACTION_ACTIVE); operations on a closed transaction MUST throw UndoError(UNDO.TRANSACTION_INACTIVE).
Scenario: Push / undo / redo round-trips state
- WHEN commands are pushed, then undone, then redone
- THEN state matches the pre-undo / post-redo expectation at each step
- AND push clears the redo stack; redo size equals the redo-stack depth
Scenario: Empty-stack operations throw UNDO.EMPTY_STACK
- WHEN
undo()orredo()is called with nothing to undo / redo - THEN
UndoError(UNDO.EMPTY_STACK)is thrown
Scenario: maxEntries and maxMemoryBytes trim the history
- WHEN more than
maxEntriesare pushed (or memory exceedsmaxMemoryBytes) - THEN the oldest entries are trimmed AND
onTruncateis invoked with the removed entries
Scenario: Transactions are atomic and disallow nesting
- WHEN
beginTransaction()is called and commands are added then committed - THEN all commands apply atomically; rollback discards them
- WHEN
beginTransaction()is called twice without commit/rollback - THEN
UndoError(UNDO.TRANSACTION_ACTIVE)is thrown - WHEN
commit()oradd()is called after the transaction is closed - THEN
UndoError(UNDO.TRANSACTION_INACTIVE)is thrown
Scenario: clear() preserves current state but drops history
- WHEN
clear()is called - THEN undoSize and redoSize are 0 AND the current state is unchanged
Verified by: src/stack/stack.test.ts::"starts with initial state" AND src/stack/stack.test.ts::"push increments undoSize" AND src/stack/stack.test.ts::"push clears redo stack" AND src/stack/stack.test.ts::"undo reverses command" AND src/stack/stack.test.ts::"redo re-applies command" AND src/stack/stack.test.ts::"undo then redo round-trip" AND src/stack/stack.test.ts::"throws EMPTY_STACK on undo when empty" AND src/stack/stack.test.ts::"throws EMPTY_STACK on redo when empty" AND src/stack/stack.test.ts::"truncates to maxEntries" AND src/stack/stack.test.ts::"calls onTruncate with removed entries" AND src/stack/stack.test.ts::"trims entries that exceed memory limit" AND src/stack/stack.test.ts::"calls onTruncate when over memory limit" AND src/stack/stack.test.ts::"stops trimming once under memory limit (break branch)" AND src/stack/stack.test.ts::"commit applies all commands in order" AND src/stack/stack.test.ts::"rollback discards all pending commands" AND src/stack/stack.test.ts::"throws TRANSACTION_ACTIVE on nested beginTransaction" AND src/stack/stack.test.ts::"throws TRANSACTION_INACTIVE on add after commit" AND src/stack/stack.test.ts::"throws TRANSACTION_INACTIVE on commit after rollback" AND src/stack/stack.test.ts::"allows new transaction after commit" AND src/stack/stack.test.ts::"clear removes history but keeps current"
Requirement: BranchingHistory for alternate timelines
The package SHALL export BranchingHistory<T>. With branchOnDiverge: true (default), pushing a command after one or more undo operations MUST fork a new branch preserving the redo entries on the old branch. With branchOnDiverge: false the redo entries are discarded as in UndoStack. Switching to a non-existent branch MUST throw UndoError(UNDO.AT_BRANCH_ROOT). Deleting the active branch MUST throw UndoError(UNDO.AT_BRANCH_ROOT).
Scenario: Diverging push forks a new branch
- WHEN a push happens after one or more undos with
branchOnDiverge: true - THEN a new branch is created AND the original branch's redo entries are preserved on the prior branch
Scenario: branchOnDiverge: false discards redo entries
- WHEN a push happens after undo with
branchOnDiverge: false - THEN the redo entries are dropped (no fork)
Scenario: Branch lookup and deletion
- WHEN
getBranch(id)is called with a known id - THEN the branch is returned
- WHEN called with an unknown id
- THEN
undefinedis returned - WHEN
deleteBranch(id)is called for a non-active branch - THEN the branch is removed
- WHEN
deleteBranch(id)is called for the active branch OR with an unknown id while switching - THEN
UndoError(UNDO.AT_BRANCH_ROOT)is thrown withbranchIdin context
Verified by: src/branch/branch.test.ts::"creates a new branch when pushing with redo entries" AND src/branch/branch.test.ts::"does NOT fork when branchOnDiverge is false" AND src/branch/branch.test.ts::"forked branch preserves redo entries" AND src/branch/branch.test.ts::"switches to another branch and returns its current" AND src/branch/branch.test.ts::"throws AT_BRANCH_ROOT for unknown branchId" AND src/branch/branch.test.ts::"includes branchId in error context" AND src/branch/branch.test.ts::"returns the branch for a valid id" AND src/branch/branch.test.ts::"returns undefined for unknown id" AND src/branch/branch.test.ts::"removes a non-active branch" AND src/branch/branch.test.ts::"throws AT_BRANCH_ROOT when deleting active branch" AND src/branch/branch.test.ts::"uses injected clock for createdAt"
Requirement: AsyncUndoStack mirrors the synchronous surface
The package SHALL export AsyncUndoStack<T> providing async push / undo / redo / transactions with the same semantics as UndoStack. Command execution failures MUST be wrapped in UndoError(UNDO.COMMAND_FAILED). The same EMPTY_STACK, TRANSACTION_ACTIVE, TRANSACTION_INACTIVE codes apply. Lifecycle events undo:push, undo:undo, undo:redo, undo:clear MUST be emitted on the configured EventBus.
Scenario: Async push / undo / redo round-trip
- WHEN async commands are pushed, undone, and redone
- THEN state and stack sizes match the synchronous semantics
Scenario: COMMAND_FAILED wraps async execution failures
- WHEN an async command throws during execute
- THEN the rejection is wrapped in
UndoError(UNDO.COMMAND_FAILED)
Scenario: Transactions and EMPTY_STACK error codes
- WHEN transaction lifecycle is misused (nested open, commit after rollback)
- THEN the corresponding
TRANSACTION_*error codes are thrown
Scenario: EventBus emissions
- WHEN push / undo / redo / clear / reset happens
- THEN the corresponding
undo:*event is emitted
Verified by: src/async/async-stack.test.ts::"starts with initial state" AND src/async/async-stack.test.ts::"push increments undoSize" AND src/async/async-stack.test.ts::"push clears redo stack" AND src/async/async-stack.test.ts::"undo reverses state" AND src/async/async-stack.test.ts::"redo re-applies command" AND src/async/async-stack.test.ts::"throws EMPTY_STACK on undo when empty" AND src/async/async-stack.test.ts::"throws EMPTY_STACK on redo when empty" AND src/async/async-stack.test.ts::"round-trips push/undo/redo" AND src/async/async-stack.test.ts::"redoSize reflects redo stack depth" AND src/async/async-stack.test.ts::"activeBranchId is always main" AND src/async/async-stack.test.ts::"canUndo is false initially and true after push" AND src/async/async-stack.test.ts::"wraps push failure in COMMAND_FAILED" AND src/async/async-stack.test.ts::"commit applies all commands" AND src/async/async-stack.test.ts::"rollback discards pending" AND src/async/async-stack.test.ts::"throws TRANSACTION_ACTIVE on nested open" AND src/async/async-stack.test.ts::"throws TRANSACTION_INACTIVE after commit" AND src/async/async-stack.test.ts::"throws TRANSACTION_INACTIVE on commit after rollback" AND src/async/async-stack.test.ts::"emits undo:push" AND src/async/async-stack.test.ts::"emits undo:undo" AND src/async/async-stack.test.ts::"emits undo:redo" AND src/async/async-stack.test.ts::"emits undo:clear on clear" AND src/async/async-stack.test.ts::"emits undo:clear on reset" AND src/async/async-stack.test.ts::"clear keeps current but clears history" AND src/async/async-stack.test.ts::"reset changes current and clears history" AND src/async/async-stack.test.ts::"snapshot returns correct shape"
Requirement: HistoryCompressor squashes old entries
The package SHALL export HistoryCompressor<T> and compressHistory which squash entries older than compressionDepth (default 50) by label into composite commands and respect maxHistorySize by dropping the oldest entries. compressionDepth: 0 is valid and means "compress all entries". Re-compressing an already-compressed stack MUST be idempotent (no further state change).
Scenario: Within-depth entries are not squashed
- WHEN all entries are within
compressionDepth - THEN the stack is unchanged
Scenario: Old same-label entries are squashed
- WHEN older entries share a label
- THEN they are combined into a single composite command
- WHEN older entries have different labels
- THEN they are not squashed
Scenario: maxHistorySize drops oldest entries
- WHEN the resulting history exceeds
maxHistorySize - THEN the oldest entries are dropped
Scenario: Compression preserves current state and is idempotent
- WHEN
compressis called - THEN the resulting current state equals the input current state
- AND running
compressagain does not change the result
Verified by: src/compression/history-compressor.test.ts::"returns compressionDepth getter" AND src/compression/history-compressor.test.ts::"returns maxHistorySize getter" AND src/compression/history-compressor.test.ts::"defaults compressionDepth to 50" AND src/compression/history-compressor.test.ts::"compress delegates to compressHistory" AND src/compression/history-compressor.test.ts::"compress with maxHistorySize drops old entries" AND src/compression/history-compressor.test.ts::"is a no-op when all entries within depth" AND src/compression/history-compressor.test.ts::"squashes old entries with same label" AND src/compression/history-compressor.test.ts::"preserves current state after compression" AND src/compression/history-compressor.test.ts::"respects maxHistorySize by dropping old entries" AND src/compression/history-compressor.test.ts::"handles empty stack" AND src/compression/history-compressor.test.ts::"does not squash entries with different labels" AND src/compression/history-compressor.test.ts::"CompressionResult has correct shape" AND src/compression/history-compressor.test.ts::"returns stack as UndoStack instance" AND src/compression/history-compressor.test.ts::"re-compressing does not change state" AND src/compression/history-compressor.test.ts::"compressed stack has same or fewer entries than original" AND src/schemas/undo.schemas.test.ts::"accepts zero compressionDepth (means compress everything)"
Requirement: SelectiveUndoStack with dependency-aware undo by ID
The package SHALL export SelectiveUndoStack<T> extending UndoStack<T> with undoById(commandId) (re-applies later commands after the targeted undo), dependency checking, and conflict detection. With throwOnConflict: false (default), conflicts return { success: false, conflictIds }; with throwOnConflict: true, conflicts throw UndoError(UNDO.DEPENDENCY_CONFLICT) with conflictIds in context.
Scenario: Selective undo of a non-conflicting command preserves later commands
- WHEN
undoById(id)is called on a command with no dependents - THEN the targeted command is undone AND later commands are re-applied
Scenario: Conflicts surface as result or thrown error
- WHEN dependents exist and
throwOnConflictisfalse - THEN the result is
{ success: false, conflictIds: [...] } - WHEN dependents exist and
throwOnConflictistrue - THEN
UndoError(UNDO.DEPENDENCY_CONFLICT)is thrown withconflictIdsin context
Scenario: Unknown command id is reported, not crashed
- WHEN
undoByIdis called with an unknown id - THEN the result is
{ success: false }(no throw)
Verified by: src/selective/selective-undo.test.ts::"undoes first command and re-applies later ones" AND src/selective/selective-undo.test.ts::"returns success: false for unknown id" AND src/selective/selective-undo.test.ts::"returns conflictIds when dependency exists (no throw)" AND src/selective/selective-undo.test.ts::"throws DEPENDENCY_CONFLICT when throwOnConflict is true" AND src/selective/selective-undo.test.ts::"includes conflictIds in error context" AND src/selective/selective-undo.test.ts::"preserves later commands after selective undo" AND src/selective/selective-undo.test.ts::"undoes the most recent command" AND src/selective/selective-undo.test.ts::"returns empty for no dependents" AND src/selective/selective-undo.test.ts::"returns dependent IDs" AND src/selective/selective-undo.test.ts::"returns empty for unknown id" AND src/selective/selective-undo.test.ts::"returns success: true when no conflicts" AND src/selective/selective-undo.test.ts::"returns success: false with conflictIds when conflicts exist"
Requirement: CommandMerger coalesces compatible consecutive commands
The package SHALL export CommandMerger<T> that buffers consecutive commands matching a MergePredicate (default: same label) and flushes them as one composite command when the count limit is reached, the time window expires, or a non-compatible command arrives. Manual flush() MUST also work; reset() discards pending without flushing. The merged command MUST produce the same final state as executing the constituents individually.
Scenario: Buffer accumulates and flushes on count limit
- WHEN
addis called up tomaxCounttimes with compatible commands - THEN
flushreturns one composite command
Scenario: Buffer flushes on time-window expiry
- WHEN the time window expires after compatible commands are added
- THEN they are flushed automatically
Scenario: Buffer flushes when an incompatible command arrives
- WHEN a command with a different label is added
- THEN the existing buffer is flushed first
Scenario: Custom predicate overrides default
- WHEN a custom
MergePredicateis supplied - THEN the buffer respects that rule rather than the same-label default
Scenario: Merged command equals individual execution
- WHEN the merged command is executed
- THEN the resulting state equals the state from executing the constituents individually
Verified by: src/merge/command-merger.test.ts::"hasPending is false initially" AND src/merge/command-merger.test.ts::"pendingCount is 0 initially" AND src/merge/command-merger.test.ts::"add accumulates without flushing" AND src/merge/command-merger.test.ts::"flush returns merged command" AND src/merge/command-merger.test.ts::"flush returns undefined when nothing pending" AND src/merge/command-merger.test.ts::"flush of single command returns it directly" AND src/merge/command-merger.test.ts::"reset discards pending" AND src/merge/command-merger.test.ts::"flushes when count limit reached" AND src/merge/command-merger.test.ts::"merged command executes correctly" AND src/merge/command-merger.test.ts::"flushes when time window expires" AND src/merge/command-merger.test.ts::"returns true for same-label commands (default predicate)" AND src/merge/command-merger.test.ts::"returns false for different labels" AND src/merge/command-merger.test.ts::"uses custom predicate" AND src/merge/command-merger.test.ts::"flushes buffer when new command has different label" AND src/merge/command-merger.test.ts::"merged command produces same state as executing individually"
Requirement: Command primitives — sync and async factories, composition, patches, size estimation
The package SHALL export createCommand<T> (sync closure factory with injectable clock / idGenerator), createAsyncCommand<T> (async factory; rejections wrapped in UndoError(UNDO.COMMAND_FAILED)), createPatchCommand<T> (diff old/new state via @suaveplan/diff to produce a reversible patch command), composeCommands<T> and composeAsyncCommands<T> (atomic composition with reverse-order undo), executeCommand<T> / undoCommand<T> (delegate to command.execute(state) / command.undo(state)), and estimateCommandSize<T> (JSON-serialization based, with fallback to id+label length for non-serializable metadata).
Scenario: Sync factory honours clock and idGenerator
- WHEN
createCommand({ clock, idGenerator, label, metadata })is called - THEN the command has a unique id, the timestamp from the clock, and the label/metadata preserved
- AND the default clock is
Date.now
Scenario: Async factory wraps execute/undo errors
- WHEN an async command's execute or undo rejects
- THEN the rejection is wrapped in
UndoError(UNDO.COMMAND_FAILED)(Error or non-Error rejection) - WHEN an async command rejects with an existing
UndoError - THEN it is passed through without double-wrapping
Scenario: Composition runs in order forward, reverse on undo
- WHEN
composeCommands([a, b, c])is executed - THEN commands run in
[a, b, c]order - WHEN undone
- THEN commands undo in
[c, b, a]order - WHEN the input array is empty
- THEN the composite command is a no-op
Scenario: Patch command round-trips through diff
- WHEN
createPatchCommand(oldState, newState)is built and executed - THEN the new state is produced; undoing restores the old state
- AND the round-trip works for arbitrary JSON-serializable state
Scenario: Size estimator returns positive bytes with fallback
- WHEN
estimateCommandSizeis called for a closure or patch command - THEN a positive number is returned
- WHEN metadata is non-serializable
- THEN the fallback uses id + label length
Verified by: src/command/command.test.ts::"sets a unique id" AND src/command/command.test.ts::"sets timestamp from clock" AND src/command/command.test.ts::"preserves label" AND src/command/command.test.ts::"preserves metadata" AND src/command/command.test.ts::"defaults clock to Date.now" AND src/command/command.test.ts::"executes commands in order" AND src/command/command.test.ts::"undoes commands in reverse order" AND src/command/command.test.ts::"handles empty command array" AND src/command/command.test.ts::"creates a new unique id" AND src/command/command.test.ts::"applies execute to state" AND src/command/command.test.ts::"applies undo to state" AND src/command/command.test.ts::"round-trips execute then undo" AND src/command/command.test.ts::"creates a PatchCommand with type=patch" AND src/command/command.test.ts::"executes to produce new state" AND src/command/command.test.ts::"undoes to restore old state" AND src/command/command.test.ts::"round-trips for complex objects" AND src/command/command.test.ts::"accepts metadata" AND src/command/command.test.ts::"uses injected clock and idGenerator" AND src/command/command.test.ts::"returns a positive number for closure command" AND src/command/command.test.ts::"returns a positive number for patch command" AND src/command/command.test.ts::"patch command size >= closure command size" AND src/command/command.test.ts::"falls back to id+label length when metadata is not serializable" AND src/command/command.test.ts::"round-trip for any JSON-serializable state" AND src/async/async-command.test.ts::"sets id and label" AND src/async/async-command.test.ts::"uses injected clock and idGenerator" AND src/async/async-command.test.ts::"preserves metadata" AND src/async/async-command.test.ts::"wraps execute errors in UndoError COMMAND_FAILED" AND src/async/async-command.test.ts::"wraps undo errors in UndoError COMMAND_FAILED" AND src/async/async-command.test.ts::"wraps non-Error rejections" AND src/async/async-command.test.ts::"executes in order" AND src/async/async-command.test.ts::"undoes in reverse order" AND src/async/async-command.test.ts::"handles empty commands" AND src/async/async-command.test.ts::"applies command" AND src/async/async-command.test.ts::"wraps non-UndoError rejection" AND src/async/async-command.test.ts::"passes through UndoError without double-wrapping" AND src/async/async-command.test.ts::"reverses command" AND src/async/async-command.test.ts::"passes through UndoError from undo without re-wrapping" AND src/async/async-command.test.ts::"wraps non-UndoError from undo" AND src/async/async-command.test.ts::"wraps a raw non-UndoError rejection" AND src/async/async-command.test.ts::"wraps a non-Error (string) rejection from executeAsyncCommand" AND src/async/async-command.test.ts::"wraps a non-Error (string) rejection from undoAsyncCommand" AND src/async/async-command.test.ts::"wraps non-Error thrown from undo"
Requirement: Stable error-code registry and structured UndoError
The package SHALL export UNDO_CODES registered via defineCodes exposing the six codes EMPTY_STACK, AT_BRANCH_ROOT, DEPENDENCY_CONFLICT, COMMAND_FAILED, TRANSACTION_ACTIVE, TRANSACTION_INACTIVE, all under the UNDO.* namespace. UndoError SHALL extend SuaveplanError<UndoCode> with structured UndoErrorContext (commandId, branchId, conflictIds).
Scenario: Six codes, all unique, all UNDO.*
- WHEN
UNDO_CODESis inspected - THEN there are exactly six codes, all values are unique, all start with
UNDO.
Scenario: UndoError carries SuaveplanError shape and context
- WHEN an
UndoErroris constructed - THEN it is
instanceof UndoErrorANDinstanceof SuaveplanError - AND
nameisUndoError - AND
code,message,cause, and context fields (commandId,branchId,conflictIds) are preserved - AND instances can be discriminated by code across all six codes
Verified by: src/errors/undo-error.test.ts::"has all six codes defined" AND src/errors/undo-error.test.ts::"all code values are unique" AND src/errors/undo-error.test.ts::"all codes start with UNDO. prefix" AND src/errors/undo-error.test.ts::"is an instance of SuaveplanError" AND src/errors/undo-error.test.ts::"is an instance of UndoError" AND src/errors/undo-error.test.ts::"sets name to UndoError" AND src/errors/undo-error.test.ts::"preserves code on the error instance" AND src/errors/undo-error.test.ts::"preserves message" AND src/errors/undo-error.test.ts::"preserves cause" AND src/errors/undo-error.test.ts::"carries commandId context" AND src/errors/undo-error.test.ts::"carries branchId context" AND src/errors/undo-error.test.ts::"carries conflictIds context" AND src/errors/undo-error.test.ts::"can be constructed with no options" AND src/errors/undo-error.test.ts::"discriminates by code across all codes"
Requirement: Strict Zod option schemas at every boundary
The package SHALL export strict Zod schemas for every option type — UndoStackOptionsSchema, BranchingOptionsSchema, HistoryCompressorOptionsSchema, CommandMergerOptionsSchema, SelectiveUndoOptionsSchema — that reject unknown keys, negative or non-integer counts, zero counts where positive integers are required, and non-function clock values, while accepting valid configurations.
Scenario: Each schema accepts an empty object and a fully populated valid object
- WHEN any of the schemas is parsed against
{}or a valid configuration - THEN the parse succeeds
Scenario: Negative / zero / non-integer numeric fields are rejected
- WHEN numeric fields like
maxEntries,maxMemoryBytes,compressionDepth,maxHistorySize,maxCount,timeWindowMsare negative, zero (where positive is required), or non-integer - THEN the parse fails
Scenario: Unknown keys are rejected (strict)
- WHEN an unknown key is added to any options object
- THEN the parse fails
Scenario: Non-function clock and empty initialBranchLabel are rejected
- WHEN
clockis not a function ORinitialBranchLabelis empty - THEN the parse fails
Verified by: src/schemas/undo.schemas.test.ts::"accepts empty object" AND src/schemas/undo.schemas.test.ts::"accepts valid options" AND src/schemas/undo.schemas.test.ts::"rejects negative maxEntries" AND src/schemas/undo.schemas.test.ts::"rejects zero maxEntries" AND src/schemas/undo.schemas.test.ts::"rejects non-integer maxEntries" AND src/schemas/undo.schemas.test.ts::"rejects negative maxMemoryBytes" AND src/schemas/undo.schemas.test.ts::"rejects unknown keys (strict)" AND src/schemas/undo.schemas.test.ts::"rejects non-function clock" AND src/schemas/undo.schemas.test.ts::"rejects empty initialBranchLabel" AND src/schemas/undo.schemas.test.ts::"rejects unknown keys" AND src/schemas/undo.schemas.test.ts::"accepts zero compressionDepth (means compress everything)" AND src/schemas/undo.schemas.test.ts::"rejects negative compressionDepth" AND src/schemas/undo.schemas.test.ts::"rejects negative maxHistorySize" AND src/schemas/undo.schemas.test.ts::"rejects non-integer values" AND src/schemas/undo.schemas.test.ts::"rejects zero maxCount" AND src/schemas/undo.schemas.test.ts::"accepts fractional timeWindowMs" AND src/schemas/undo.schemas.test.ts::"rejects zero timeWindowMs" AND src/schemas/undo.schemas.test.ts::"accepts valid options including base fields"
Quality gates
- 100% Istanbul coverage on all source files (statements / branches / functions / lines).
- Property-based tests via
fast-checkcovering round-trips and invariants. - All class members
protected(notprivate) for extensibility. - Docs coverage 100% (sibling
.mdfor every implementation.ts).