Files
2026-05-27 23:29:53 +01:00

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() or redo() is called with nothing to undo / redo
  • THEN UndoError(UNDO.EMPTY_STACK) is thrown

Scenario: maxEntries and maxMemoryBytes trim the history

  • WHEN more than maxEntries are pushed (or memory exceeds maxMemoryBytes)
  • THEN the oldest entries are trimmed AND onTruncate is 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() or add() 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 undefined is 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 with branchId in 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 compress is called
  • THEN the resulting current state equals the input current state
  • AND running compress again 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 throwOnConflict is false
  • THEN the result is { success: false, conflictIds: [...] }
  • WHEN dependents exist and throwOnConflict is true
  • THEN UndoError(UNDO.DEPENDENCY_CONFLICT) is thrown with conflictIds in context

Scenario: Unknown command id is reported, not crashed

  • WHEN undoById is 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 add is called up to maxCount times with compatible commands
  • THEN flush returns 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 MergePredicate is 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 estimateCommandSize is 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_CODES is inspected
  • THEN there are exactly six codes, all values are unique, all start with UNDO.

Scenario: UndoError carries SuaveplanError shape and context

  • WHEN an UndoError is constructed
  • THEN it is instanceof UndoError AND instanceof SuaveplanError
  • AND name is UndoError
  • 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, timeWindowMs are 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 clock is not a function OR initialBranchLabel is 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-check covering round-trips and invariants.
  • All class members protected (not private) for extensibility.
  • Docs coverage 100% (sibling .md for every implementation .ts).