Files
suaveplan/openspec/archive/2026-04-24-enterprise-undo-2/proposal.md
2026-05-27 23:29:53 +01:00

8.2 KiB

OpenSpec Change: enterprise-undo

  • Change ID: enterprise-undo
  • Package: @suaveplan/undo
  • Category: universal
  • Target path: universal/control-flow/undo
  • Status: IN PROGRESS
  • Date: 2026-04-23
  • Reference spec: /mnt/s/web/suaveplan/enterprise/undo.md

Summary

Greenfield implementation of @suaveplan/undo for the genesis branch. Ships a full undo/redo engine with branching history, async commands, history compression, selective undo, and command merging. All public API from the main-branch reference implementation is preserved. Genesis adds structured errors (UNDO.* codes via @suaveplan/error), Zod option schemas, EventBusLike integration for UndoStack change notifications, Clock injection for BranchingHistory timestamps, OTel counters/histograms, property-based tests (fast-check), and mitata benchmarks. The package depends on @suaveplan/diff for invertPatch/applyPatch inside createPatchCommand — no reimplementation of JSON Patch operations.

Current-state audit (genesis)

The package does not exist on genesis yet — this is a fresh build.

Available workspace deps on genesis

Package Catalog / version Role
@suaveplan/error workspace:^ Structured error base class + defineCodes
@suaveplan/types workspace:^ Clock (timestamps in BranchingHistory), EventBusLike (UndoStack events), IdGenerator (command IDs)
@suaveplan/events workspace:^ Concrete EventBus implementation; injected into UndoStack
@suaveplan/diff workspace:^ diff, applyPatch, invertPatch — powers createPatchCommand
zod catalog:forms Zod schemas for all option types
@opentelemetry/api catalog:observability Counters, histograms, spans
tslib catalog:build TypeScript helpers

Dev deps (all catalog:testing): vitest, @vitest/coverage-istanbul, fast-check, mitata.

Preserved public API surface (from main branch)

Command primitives

  • createCommand<T>(label, execute, undo, metadata?) → Command<T>
  • composeCommands<T>(label, commands) → Command<T>
  • executeCommand<T>(state, command) → T
  • undoCommand<T>(state, command) → T
  • createPatchCommand<T>(label, state, newState, metadata?) → PatchCommand<T> — uses @suaveplan/diff's diff + invertPatch; no local reimplementation
  • estimateCommandSize<T>(command) → number

Type guards

  • isPatchCommand<T>(cmd) → cmd is PatchCommand<T>
  • isAsyncCommand<T>(cmd) → cmd is AsyncCommand<T>

Sync linear stack

  • class UndoStack<T> — push, pushState, undo, redo, beginTransaction, clear, reset, snapshot, getUndoStack, getRedoStack; getters: current, canUndo, canRedo, undoSize, redoSize, activeBranchId

Branching history

  • class BranchingHistory<T> — push, pushState, undo, redo, switchBranch, getBranch, deleteBranch, beginTransaction, snapshot; getters: current, canUndo, canRedo, activeBranchId, branches

Async command API

  • createAsyncCommand<T>(label, execute, undo, metadata?) → AsyncCommand<T>
  • composeAsyncCommands<T>(label, commands) → AsyncCommand<T>
  • executeAsyncCommand<T>(state, command) → Promise<T>
  • undoAsyncCommand<T>(state, command) → Promise<T>
  • class AsyncUndoStack<T> — async push, undo, redo, beginTransaction (returns AsyncTransactionHandle<T>), clear, reset, snapshot; same getters as UndoStack

History compression

  • class HistoryCompressor<T>(options) — compress(stack) → CompressionResult & { stack: UndoStack<T> }; getters: compressionDepth, maxHistorySize
  • compressHistory<T>(stack, options) → CompressionResult & { stack: UndoStack<T> }

Selective undo

  • class SelectiveUndoStack<T> extends UndoStack<T> — undoById(commandId), getDependents(commandId), canUndoById(commandId) → SelectiveUndoResult<T>

Command merging

  • class CommandMerger<T>(options?, predicate?) — canMerge(a, b), add(command), flush(), reset(); getters: hasPending, pendingCount

Types (all re-exported from src/types/undo.types.ts)

Command<T>, PatchCommand<T>, AsyncCommand<T>, StackEntry<T>, Branch<T>, UndoStackOptions, BranchingOptions, UndoStackState<T>, BranchingState<T>, TransactionHandle<T>, AsyncTransactionHandle<T>, CommandMergerOptions, SelectiveUndoOptions, SelectiveUndoResult<T>, DependencyChecker<T>, HistoryCompressorOptions, CompressionResult, MergePredicate<T>

New additions for genesis

Structured errors

UndoError extends SuaveplanError; error codes registered via defineCodes under the UNDO namespace:

Code Thrown by
UNDO.EMPTY_STACK undo() / redo() when nothing to undo/redo (optional — currently silent; add when callers need to distinguish)
UNDO.AT_BRANCH_ROOT BranchingHistory.switchBranch when target branch is not found
UNDO.DEPENDENCY_CONFLICT SelectiveUndoStack.undoById when conflicts exist and throwOnConflict option is set
UNDO.COMMAND_FAILED AsyncUndoStack.push/undo/redo when the async command rejects
UNDO.TRANSACTION_ACTIVE UndoStack.beginTransaction when a transaction is already open
UNDO.TRANSACTION_INACTIVE TransactionHandle.add/commit/rollback when the transaction was already closed

Zod schemas (sibling *.schemas.ts files)

UndoStackOptionsSchema, BranchingOptionsSchema, HistoryCompressorOptionsSchema, CommandMergerOptionsSchema, SelectiveUndoOptionsSchema — validated at construction time in their respective classes.

EventBusLike integration

UndoStack constructor accepts an optional EventBusLike (from @suaveplan/types). When provided it emits typed events:

Event Payload
undo:push { commandId, label, undoSize }
undo:undo { commandId, label, undoSize }
undo:redo { commandId, label, redoSize }
undo:clear {}

Clock injection

BranchingHistory and createCommand / createPatchCommand / createAsyncCommand accept an optional Clock (from @suaveplan/types) so branch createdAt and command timestamp fields are deterministic in tests (no bare Date.now() calls).

IdGenerator injection

createCommand, createPatchCommand, createAsyncCommand, and BranchingHistory._forkRedoEntries accept an optional IdGenerator (from @suaveplan/types) so id fields are deterministic in tests (no bare crypto.randomUUID() calls at module scope).

OTel instrumentation

Counters (via @opentelemetry/api meter undo):

  • undo.push.total — labels: stack_type (linear | branching | async | selective)
  • undo.undo.total — same labels
  • undo.redo.total — same labels
  • undo.compress.total

Histograms:

  • undo.stack.depth — recorded on every push/undo/redo
  • undo.compress.ratio — originalCount / compressedCount recorded on each compression

Property-based tests (fast-check)

  • Push → undo → redo round-trip: stack.undo(); stack.redo() restores exact state for any sequence of commands.
  • Stack size invariant: undoSize + redoSize equals total pushed (minus truncated).
  • compressHistory idempotency: re-compressing an already-compressed stack returns squashedCount === 0 for entries within depth.
  • CommandMerger merge associativity: merging [a, b, c] produces the same result regardless of flush timing.
  • BranchingHistory branch isolation: switching branches never mutates other branches.

Benchmarks (mitata)

  • UndoStack.push / undo / redo — 10 000 cycles with no-op closure commands.
  • UndoStack.push / undo / redo — 10 000 cycles with patch commands on a medium-sized JSON object.
  • compressHistory on a 1 000-entry stack with compressionDepth: 100.
  • BranchingHistory.push + switchBranch — 100 branches of 10 commands each.

Impact

  • Adds one new package: universal/control-flow/undo
  • Adds one new spec capability: undo
  • Depends on: @suaveplan/error, @suaveplan/types, @suaveplan/events, @suaveplan/diff
  • Unblocks: any package requiring undo/redo history (editor tooling, collaborative editing, UI form revert)