155 lines
8.2 KiB
Markdown
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)
|