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)→TundoCommand<T>(state, command)→TcreatePatchCommand<T>(label, state, newState, metadata?)→PatchCommand<T>— uses@suaveplan/diff'sdiff+invertPatch; no local reimplementationestimateCommandSize<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>— asyncpush,undo,redo,beginTransaction(returnsAsyncTransactionHandle<T>),clear,reset,snapshot; same getters asUndoStack
History compression
class HistoryCompressor<T>(options)—compress(stack)→CompressionResult & { stack: UndoStack<T> }; getters:compressionDepth,maxHistorySizecompressHistory<T>(stack, options)→CompressionResult & { stack: UndoStack<T> }
Selective undo
class SelectiveUndoStack<T>extendsUndoStack<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 labelsundo.redo.total— same labelsundo.compress.total
Histograms:
undo.stack.depth— recorded on every push/undo/redoundo.compress.ratio—originalCount / compressedCountrecorded 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 + redoSizeequals total pushed (minus truncated). compressHistoryidempotency: re-compressing an already-compressed stack returnssquashedCount === 0for entries within depth.CommandMergermerge associativity: merging[a, b, c]produces the same result regardless of flush timing.BranchingHistorybranch 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.compressHistoryon a 1 000-entry stack withcompressionDepth: 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)