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

155 lines
8.2 KiB
Markdown

# 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)