first commit
This commit is contained in:
97
docs/foundation/flow/undo/README.md
Normal file
97
docs/foundation/flow/undo/README.md
Normal file
File diff suppressed because one or more lines are too long
107
docs/foundation/flow/undo/src/async/async-command.md
Normal file
107
docs/foundation/flow/undo/src/async/async-command.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# async-command
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides factory functions and execution helpers for asynchronous commands in `@suaveplan/undo`. Async commands follow the same `execute`/`undo` contract as synchronous commands but return `Promise<T>`, enabling integration with async side-effects such as API calls, file system operations, or database transactions that must participate in an undo/redo history.
|
||||
|
||||
All errors from user-supplied `execute` and `undo` closures are caught and re-thrown as `UndoError(UNDO.COMMAND_FAILED)` with the original error as `cause`, ensuring callers always receive a structured, discriminatable error type.
|
||||
|
||||
## Features
|
||||
|
||||
- `createAsyncCommand<T>` — factory for Promise-based commands with error wrapping
|
||||
- `composeAsyncCommands<T>` — sequential composition of async commands; execute in order, undo in reverse
|
||||
- `executeAsyncCommand<T>` — applies an async command with `COMMAND_FAILED` wrapping
|
||||
- `undoAsyncCommand<T>` — reverses an async command with `COMMAND_FAILED` wrapping
|
||||
- Injectable `Clock` and `IdGenerator` for deterministic `id` and `timestamp`
|
||||
- Already-wrapped `UndoError` instances pass through without double-wrapping
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createAsyncCommand,
|
||||
executeAsyncCommand,
|
||||
undoAsyncCommand,
|
||||
} from "@suaveplan/undo/async/async-command";
|
||||
|
||||
interface Counter { value: number }
|
||||
|
||||
const asyncInc = createAsyncCommand<Counter>(
|
||||
"AsyncIncrement",
|
||||
async (s) => ({ value: s.value + 1 }),
|
||||
async (s) => ({ value: s.value - 1 }),
|
||||
);
|
||||
|
||||
let state: Counter = { value: 0 };
|
||||
state = await executeAsyncCommand(state, asyncInc); // { value: 1 }
|
||||
state = await undoAsyncCommand(state, asyncInc); // { value: 0 }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
composeAsyncCommands,
|
||||
createAsyncCommand,
|
||||
} from "@suaveplan/undo/async/async-command";
|
||||
import { UNDO_CODES, UndoError } from "@suaveplan/undo/errors/undo-error";
|
||||
|
||||
interface Doc { title: string; saved: boolean }
|
||||
|
||||
const updateTitle = createAsyncCommand<Doc>(
|
||||
"UpdateTitle",
|
||||
async (d) => { await api.updateTitle(d.title); return { ...d, saved: true }; },
|
||||
async (d) => { await api.revertTitle(); return { ...d, saved: false }; },
|
||||
{ source: "user" },
|
||||
{ clock: Date.now, idGenerator: () => crypto.randomUUID() }
|
||||
);
|
||||
|
||||
// Compose: updateTitle then mark done
|
||||
const markDone = createAsyncCommand<Doc>(
|
||||
"MarkDone",
|
||||
async (d) => ({ ...d, title: d.title + " [done]" }),
|
||||
async (d) => ({ ...d, title: d.title.replace(" [done]", "") }),
|
||||
);
|
||||
|
||||
const composed = composeAsyncCommands("Full save", [updateTitle, markDone]);
|
||||
|
||||
// Error handling
|
||||
try {
|
||||
await executeAsyncCommand({ title: "Test", saved: false }, updateTitle);
|
||||
} catch (err) {
|
||||
if (err instanceof UndoError && err.code === UNDO_CODES.COMMAND_FAILED) {
|
||||
console.error("Command failed:", err.cause);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createAsyncCommand<T>(label, executeFn, undoFn, metadata?, opts?)`
|
||||
|
||||
Returns `AsyncCommand<T>`. Errors from `executeFn`/`undoFn` are wrapped in `UndoError(UNDO.COMMAND_FAILED)`.
|
||||
|
||||
### `composeAsyncCommands<T>(label, commands, opts?)`
|
||||
|
||||
Awaits each command sequentially. Returns `AsyncCommand<T>`.
|
||||
|
||||
### `executeAsyncCommand<T>(state, command)` → `Promise<T>`
|
||||
|
||||
Calls `command.execute(state)`. Already-wrapped `UndoError` instances pass through unchanged.
|
||||
|
||||
### `undoAsyncCommand<T>(state, command)` → `Promise<T>`
|
||||
|
||||
Calls `command.undo(state)`. Already-wrapped `UndoError` instances pass through unchanged.
|
||||
|
||||
### `AsyncCommandFactoryOptions`
|
||||
|
||||
```ts
|
||||
interface AsyncCommandFactoryOptions {
|
||||
clock?: Clock; // () => number
|
||||
idGenerator?: IdGenerator; // () => string
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Error wrapping checks `err instanceof UndoError` before wrapping to prevent double-wrapping when composed commands re-throw. The `composeAsyncCommands` helper uses sequential `await` rather than `Promise.all` to preserve the transactional ordering guarantee — each step must complete before the next begins. Non-`Error` rejections (e.g., thrown strings) are converted to `Error` instances before setting as `cause`.
|
||||
110
docs/foundation/flow/undo/src/async/async-stack.md
Normal file
110
docs/foundation/flow/undo/src/async/async-stack.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# async-stack
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `AsyncUndoStack<T>`, the asynchronous counterpart to `UndoStack<T>`. All mutation methods (`push`, `undo`, `redo`) return `Promise<T>`, enabling undo history management for operations with async side-effects — such as API mutations, database writes, or filesystem changes that must be reversible.
|
||||
|
||||
Command failures during `push`, `undo`, and `redo` are caught and re-thrown as `UndoError(UNDO.COMMAND_FAILED)`, ensuring callers always receive a structured error type regardless of what the underlying async command throws. The transaction API is also asynchronous (`commit()` returns `Promise<void>`).
|
||||
|
||||
## Features
|
||||
|
||||
- `push(command)` → `Promise<T>` — executes async command, pushes to stack, clears redo
|
||||
- `undo()` → `Promise<T>` — reverses most recent command; throws `UndoError(UNDO.EMPTY_STACK)` if empty
|
||||
- `redo()` → `Promise<T>` — re-applies most recently undone command; throws `UndoError(UNDO.EMPTY_STACK)` if empty
|
||||
- `beginTransaction()` → `AsyncTransactionHandle<T>` — atomic async batch
|
||||
- `clear()` / `reset(newInitial)` — history management
|
||||
- `snapshot()` — serializable `UndoStackState<T>`
|
||||
- Getters: `current`, `canUndo`, `canRedo`, `undoSize`, `redoSize`, `activeBranchId`
|
||||
- `EventBusLike` integration — emits `undo:push`, `undo:undo`, `undo:redo`, `undo:clear`
|
||||
- OTel counters and histogram with `stack_type: 'async'` attribute
|
||||
- `maxEntries` and `maxMemoryBytes` limits with `onTruncate` callback
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AsyncUndoStack } from "@suaveplan/undo/async/async-stack";
|
||||
import { createAsyncCommand } from "@suaveplan/undo/async/async-command";
|
||||
|
||||
interface Counter { value: number }
|
||||
|
||||
const stack = new AsyncUndoStack<Counter>({ value: 0 });
|
||||
|
||||
const inc = createAsyncCommand(
|
||||
"Increment",
|
||||
async (s) => ({ value: s.value + 1 }),
|
||||
async (s) => ({ value: s.value - 1 }),
|
||||
);
|
||||
|
||||
await stack.push(inc);
|
||||
console.log(stack.current.value); // 1
|
||||
|
||||
await stack.undo();
|
||||
console.log(stack.current.value); // 0
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { AsyncUndoStack } from "@suaveplan/undo/async/async-stack";
|
||||
import { createAsyncCommand } from "@suaveplan/undo/async/async-command";
|
||||
import { UNDO_CODES, UndoError } from "@suaveplan/undo/errors/undo-error";
|
||||
|
||||
interface Doc { title: string }
|
||||
|
||||
const stack = new AsyncUndoStack<Doc>({ title: "Draft" }, { maxEntries: 50 });
|
||||
|
||||
// Transaction: commit two commands atomically
|
||||
const tx = stack.beginTransaction();
|
||||
tx.add(createAsyncCommand("Title", async (d) => ({ ...d, title: "Final" }), async (d) => ({ ...d, title: "Draft" })));
|
||||
tx.add(createAsyncCommand("Bang", async (d) => ({ ...d, title: d.title + "!" }), async (d) => ({ ...d, title: d.title.replace("!", "") })));
|
||||
await tx.commit();
|
||||
console.log(stack.current.title); // "Final!"
|
||||
|
||||
// Error handling
|
||||
const failCmd = createAsyncCommand<Doc>("Fail", async () => { throw new Error("network"); }, async (d) => d);
|
||||
try {
|
||||
await stack.push(failCmd);
|
||||
} catch (err) {
|
||||
if (err instanceof UndoError && err.code === UNDO_CODES.COMMAND_FAILED) {
|
||||
console.error("Push failed:", err.cause?.message); // "network"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new AsyncUndoStack<T>(initial, options?, bus?)`
|
||||
|
||||
Options validated with `UndoStackOptionsSchema`. Throws `ZodError` on invalid options.
|
||||
|
||||
### `push(command)` → `Promise<T>`
|
||||
|
||||
Executes `AsyncCommand`, stores entry. Command failures wrapped in `UndoError(UNDO.COMMAND_FAILED)`.
|
||||
|
||||
### `undo()` → `Promise<T>`
|
||||
|
||||
Restores `stateBefore` directly (O(1)). Throws `UndoError(UNDO.EMPTY_STACK)` if empty.
|
||||
|
||||
### `redo()` → `Promise<T>`
|
||||
|
||||
Re-executes stored execute closure. Throws `UndoError(UNDO.EMPTY_STACK)` if empty.
|
||||
|
||||
### `beginTransaction()` → `AsyncTransactionHandle<T>`
|
||||
|
||||
Throws `UndoError(UNDO.TRANSACTION_ACTIVE)` if already open.
|
||||
|
||||
```ts
|
||||
interface AsyncTransactionHandle<T> {
|
||||
add(command: AsyncCommand<T>): void;
|
||||
commit(): Promise<void>;
|
||||
rollback(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### `snapshot()` → `UndoStackState<T>`
|
||||
|
||||
Returns `{ current, undoStack, redoStack, undoSize, redoSize }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `AsyncUndoStack` stores `stateBefore` in each entry (same pattern as `UndoStack`) so that `undo()` can restore state in O(1) without re-awaiting the original async command's undo path. The stored `command.execute` closure returns the post-push current state captured at push time, enabling `redo()` to restore the state without re-running external side-effects. This is intentional: redo restores the recorded state, not re-executes the network call.
|
||||
124
docs/foundation/flow/undo/src/branch/branch.md
Normal file
124
docs/foundation/flow/undo/src/branch/branch.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# branch
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements `BranchingHistory<T>`, a multi-timeline history manager that preserves alternate execution paths when a push diverges from a non-empty redo stack. Instead of discarding "future" redo entries when the user takes a new action, the manager forks those entries into a named branch — enabling non-destructive exploration of alternate histories, undo-tree navigation, and collaborative editing conflict visualization.
|
||||
|
||||
Each branch maintains an independent undo/redo stack and current state. Switching branches replays no additional commands; the branch stores its own `current` pointer. Injectable `Clock` and `IdGenerator` make branch creation deterministic in tests.
|
||||
|
||||
## Features
|
||||
|
||||
- `push(command)` — executes on the active branch; forks redo entries to a new branch if `branchOnDiverge` is true
|
||||
- `pushState(newState)` — pushes a state snapshot directly
|
||||
- `undo()` / `redo()` — operate on the active branch's stacks; throw `UndoError(UNDO.EMPTY_STACK)` when empty
|
||||
- `switchBranch(id)` — changes the active branch; throws `UndoError(UNDO.AT_BRANCH_ROOT)` for unknown branch
|
||||
- `getBranch(id)` — returns a branch or `undefined`
|
||||
- `deleteBranch(id)` — removes a branch; throws when attempting to delete the active branch
|
||||
- `beginTransaction()` — atomic multi-command batch on the active branch
|
||||
- `snapshot()` — serializable `BranchingState<T>` with all branch data
|
||||
- Getters: `current`, `canUndo`, `canRedo`, `activeBranchId`, `branches`
|
||||
- OTel counters and histogram instrumentation
|
||||
- Injectable `Clock` and `IdGenerator` for deterministic `createdAt` and `id` fields
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { BranchingHistory } from "@suaveplan/undo/branch/branch";
|
||||
import { createCommand } from "@suaveplan/undo/command/command";
|
||||
|
||||
interface Doc { title: string }
|
||||
|
||||
const h = new BranchingHistory<Doc>({ title: "Draft" });
|
||||
|
||||
const set = (t: string) =>
|
||||
createCommand(`Set ${t}`, (_) => ({ title: t }), (d) => d);
|
||||
|
||||
h.push(set("Version A"));
|
||||
h.undo();
|
||||
h.push(set("Version B")); // forks "Version A" to a new branch
|
||||
console.log(h.branches.length); // 2
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { BranchingHistory } from "@suaveplan/undo/branch/branch";
|
||||
import { createCommand } from "@suaveplan/undo/command/command";
|
||||
|
||||
interface Counter { value: number }
|
||||
|
||||
let time = 0;
|
||||
const clock = () => ++time;
|
||||
const idGen = (() => { let n = 0; return () => `b-${++n}`; })();
|
||||
|
||||
const h = new BranchingHistory<Counter>(
|
||||
{ value: 0 },
|
||||
{ branchOnDiverge: true, initialBranchLabel: "main" },
|
||||
clock,
|
||||
idGen
|
||||
);
|
||||
|
||||
const inc = (n: number) =>
|
||||
createCommand(`+${n}`, (s) => ({ value: s.value + n }), (s) => ({ value: s.value - n }));
|
||||
|
||||
h.push(inc(1)); // main: [+1]
|
||||
h.push(inc(2)); // main: [+1, +2]
|
||||
h.undo(); // main: [+1], redo: [+2]
|
||||
h.push(inc(3)); // forks +2, main: [+1, +3]
|
||||
|
||||
const forkId = h.branches.find((b) => b.label.startsWith("fork-"))?.id;
|
||||
if (forkId) {
|
||||
h.switchBranch(forkId);
|
||||
console.log(h.current.value); // 3 (from +1+2)
|
||||
}
|
||||
|
||||
// Snapshot all branches
|
||||
const snap = h.snapshot();
|
||||
console.log(Object.keys(snap.branches).length); // >= 2
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new BranchingHistory<T>(initial, options?, clock?, idGenerator?)`
|
||||
|
||||
| Param | Type | Description |
|
||||
|---|---|---|
|
||||
| `initial` | `T` | Initial state on the default branch |
|
||||
| `options` | `BranchingOptions` | `branchOnDiverge`, `initialBranchLabel`, `maxEntries`, `clock`, `idGenerator` |
|
||||
| `clock` | `Clock` | Overrides `options.clock` |
|
||||
| `idGenerator` | `IdGenerator` | Overrides `options.idGenerator` |
|
||||
|
||||
### `push(command)` → `T`
|
||||
|
||||
Applies command, forks redo entries if diverging and `branchOnDiverge !== false`.
|
||||
|
||||
### `undo()` → `T`
|
||||
|
||||
Throws `UndoError(UNDO.EMPTY_STACK)` when the active branch has nothing to undo.
|
||||
|
||||
### `redo()` → `T`
|
||||
|
||||
Throws `UndoError(UNDO.EMPTY_STACK)` when the active branch has nothing to redo.
|
||||
|
||||
### `switchBranch(branchId)` → `T`
|
||||
|
||||
Throws `UndoError(UNDO.AT_BRANCH_ROOT, { branchId })` if unknown.
|
||||
|
||||
### `deleteBranch(branchId)` → `void`
|
||||
|
||||
Throws `UndoError(UNDO.AT_BRANCH_ROOT, { branchId })` if active branch.
|
||||
|
||||
### `beginTransaction()` → `TransactionHandle<T>`
|
||||
|
||||
Throws `UndoError(UNDO.TRANSACTION_ACTIVE)` if already open.
|
||||
|
||||
### `snapshot()` → `BranchingState<T>`
|
||||
|
||||
Returns `{ current, activeBranchId, branches }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `_forkRedoEntries` method creates a snapshot copy of the undo and redo stacks at the fork point, including the current state pointer. Switching branches is O(1) — no replay required. `_trimBranch` only applies to the undo stack; the redo stack is unbounded per branch. The `branchOnDiverge` option defaults to `true` when unset (checked with `!== false` to handle the `undefined` case).
|
||||
143
docs/foundation/flow/undo/src/command/command.md
Normal file
143
docs/foundation/flow/undo/src/command/command.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# command
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the foundational command primitives for `@suaveplan/undo`: factory functions for creating synchronous commands, composing multiple commands into a single atomic unit, and executing or undoing them against a state. The patch command variant integrates with `@suaveplan/diff` to represent state transitions as RFC 6902 JSON Patch pairs rather than closure pairs, enabling structural diffing, conflict detection, and binary-format serialization.
|
||||
|
||||
All factory functions accept an optional `{ clock?, idGenerator? }` options bag for deterministic `id` and `timestamp` fields in tests — no bare `Date.now()` or `crypto.randomUUID()` calls at module scope.
|
||||
|
||||
## Features
|
||||
|
||||
- `createCommand<T>` — creates a closure-based command with `execute`/`undo` functions
|
||||
- `composeCommands<T>` — combines multiple commands into a single composite (execute in order, undo in reverse)
|
||||
- `executeCommand<T>` — applies a command to a state, using `applyPatch` for `PatchCommand` instances
|
||||
- `undoCommand<T>` — reverses a command, using `applyPatch` with `inversePatch` for `PatchCommand` instances
|
||||
- `createPatchCommand<T>` — creates a `PatchCommand` by computing `diff(oldState, newState)` via `@suaveplan/diff`
|
||||
- `estimateCommandSize<T>` — estimates command memory footprint via JSON serialization
|
||||
- Injectable `Clock` and `IdGenerator` for deterministic testing
|
||||
- Zero re-implementation of JSON Patch logic — fully delegates to `@suaveplan/diff`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createCommand,
|
||||
executeCommand,
|
||||
undoCommand,
|
||||
} from "@suaveplan/undo/command/command";
|
||||
|
||||
interface Counter { value: number }
|
||||
|
||||
const increment = createCommand<Counter>(
|
||||
"Increment",
|
||||
(s) => ({ value: s.value + 1 }),
|
||||
(s) => ({ value: s.value - 1 }),
|
||||
);
|
||||
|
||||
let state: Counter = { value: 0 };
|
||||
state = executeCommand(state, increment); // { value: 1 }
|
||||
state = undoCommand(state, increment); // { value: 0 }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
composeCommands,
|
||||
createCommand,
|
||||
createPatchCommand,
|
||||
estimateCommandSize,
|
||||
executeCommand,
|
||||
undoCommand,
|
||||
} from "@suaveplan/undo/command/command";
|
||||
|
||||
interface Doc { title: string; body: string }
|
||||
|
||||
// Patch command — structural diff
|
||||
const before: Doc = { title: "Draft", body: "" };
|
||||
const after: Doc = { title: "Final", body: "Hello" };
|
||||
|
||||
let time = 1000;
|
||||
const clock = () => ++time;
|
||||
const idGen = () => `cmd-${time}`;
|
||||
|
||||
const patch = createPatchCommand("Edit doc", before, after, undefined, { clock, idGen });
|
||||
let doc = before;
|
||||
doc = executeCommand(doc, patch); // { title: "Final", body: "Hello" }
|
||||
doc = undoCommand(doc, patch); // { title: "Draft", body: "" }
|
||||
|
||||
// Compose multiple commands
|
||||
const setTitle = createCommand<Doc>(
|
||||
"Set title",
|
||||
(d) => ({ ...d, title: "Updated" }),
|
||||
(d) => ({ ...d, title: "Draft" }),
|
||||
{ source: "user" },
|
||||
{ clock, idGen }
|
||||
);
|
||||
|
||||
const setBody = createCommand<Doc>(
|
||||
"Set body",
|
||||
(d) => ({ ...d, body: "World" }),
|
||||
(d) => ({ ...d, body: "" }),
|
||||
undefined,
|
||||
{ clock, idGen }
|
||||
);
|
||||
|
||||
const composed = composeCommands("Edit all", [setTitle, setBody], { clock, idGen });
|
||||
doc = executeCommand(doc, composed);
|
||||
// { title: "Updated", body: "World" }
|
||||
doc = undoCommand(doc, composed);
|
||||
// { title: "Draft", body: "" }
|
||||
|
||||
console.log(estimateCommandSize(patch)); // ~bytes
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createCommand<T>(label, executeFn, undoFn, metadata?, opts?)`
|
||||
|
||||
| Param | Type | Description |
|
||||
|---|---|---|
|
||||
| `label` | `string` | Human-readable name |
|
||||
| `executeFn` | `(state: T) => T` | State transformation |
|
||||
| `undoFn` | `(state: T) => T` | Reverse transformation |
|
||||
| `metadata` | `Record<string, unknown>` | Optional context |
|
||||
| `opts` | `CommandFactoryOptions` | `clock`, `idGenerator` |
|
||||
|
||||
Returns `Command<T>`.
|
||||
|
||||
### `composeCommands<T>(label, commands, opts?)`
|
||||
|
||||
Executes commands left-to-right; undoes right-to-left. Returns `Command<T>`.
|
||||
|
||||
### `executeCommand<T>(state, command)`
|
||||
|
||||
For `PatchCommand`: uses `applyPatch(state, command.patch)`. For closure commands: calls `command.execute(state)`. Returns new state `T`.
|
||||
|
||||
### `undoCommand<T>(state, command)`
|
||||
|
||||
For `PatchCommand`: uses `applyPatch(state, command.inversePatch)`. For closure commands: calls `command.undo(state)`. Returns new state `T`.
|
||||
|
||||
### `createPatchCommand<T>(label, oldState, newState, metadata?, opts?)`
|
||||
|
||||
Calls `diff(oldState, newState)` and `invertPatch(oldState, patch)` from `@suaveplan/diff`. Returns `PatchCommand<T>`.
|
||||
|
||||
### `estimateCommandSize<T>(command)`
|
||||
|
||||
Returns a `number` approximating serialized byte size. Includes `id`, `label`, `timestamp`, `metadata`, and for patch commands the `patch` and `inversePatch` arrays.
|
||||
|
||||
### `CommandFactoryOptions`
|
||||
|
||||
```ts
|
||||
interface CommandFactoryOptions {
|
||||
clock?: Clock; // () => number, defaults to Date.now
|
||||
idGenerator?: IdGenerator; // () => string, defaults to crypto.randomUUID
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`executeCommand` and `undoCommand` call `isPatchCommand` (from `undo.types`) before dispatching to `applyPatch`, avoiding any runtime overhead for closure commands. The `applyPatch` call uses `result.doc` and casts through `JsonValue` — callers are responsible for ensuring their state types are JSON-serializable when using patch commands. `estimateCommandSize` catches serialization errors (e.g., circular references) and falls back to a conservative estimate based on string lengths.
|
||||
107
docs/foundation/flow/undo/src/compression/history-compressor.md
Normal file
107
docs/foundation/flow/undo/src/compression/history-compressor.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# history-compressor
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides `HistoryCompressor<T>` and the standalone `compressHistory` function for reducing memory usage in long-running undo histories. Old entries beyond a configurable `compressionDepth` are squashed by label using `composeCommands`, grouping consecutive same-label commands into a single composite command. An optional `maxHistorySize` further limits the total retained entries by dropping the oldest compressed entries.
|
||||
|
||||
Compression is non-destructive with respect to the current application state — the rebuilt stack always reflects the same `current` value as the original stack. The `CompressionResult` reports how many entries were squashed and dropped, enabling callers to decide whether re-compression is worthwhile.
|
||||
|
||||
## Features
|
||||
|
||||
- `HistoryCompressor<T>` class with configurable `compressionDepth` and `maxHistorySize`
|
||||
- `compress(stack)` → `CompressionResult & { stack: UndoStack<T> }` — squashes and returns a new stack
|
||||
- `compressHistory<T>(stack, options?)` — standalone function equivalent
|
||||
- Groups consecutive same-label entries in the old region into composite commands
|
||||
- Respects `maxHistorySize` by dropping the oldest entries beyond the limit
|
||||
- OTel: increments `undo.compress.total`; records `undo.compress.ratio` histogram
|
||||
- Validates options with `HistoryCompressorOptionsSchema`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { compressHistory } from "@suaveplan/undo/compression/history-compressor";
|
||||
import { UndoStack } from "@suaveplan/undo/stack/stack";
|
||||
import { createCommand } from "@suaveplan/undo/command/command";
|
||||
|
||||
interface Counter { value: number }
|
||||
const stack = new UndoStack<Counter>({ value: 0 });
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
stack.push(createCommand("Inc", (s) => ({ value: s.value + 1 }), (s) => ({ value: s.value - 1 })));
|
||||
}
|
||||
|
||||
const { stack: compressed, squashedCount, newSize } = compressHistory(stack, {
|
||||
compressionDepth: 20,
|
||||
maxHistorySize: 30,
|
||||
});
|
||||
|
||||
console.log(squashedCount); // number of entries squashed into composites
|
||||
console.log(newSize); // entries in the compressed stack
|
||||
console.log(compressed.current.value); // same as original stack
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { HistoryCompressor } from "@suaveplan/undo/compression/history-compressor";
|
||||
import { UndoStack } from "@suaveplan/undo/stack/stack";
|
||||
import { createCommand } from "@suaveplan/undo/command/command";
|
||||
|
||||
interface Doc { version: number }
|
||||
|
||||
const compressor = new HistoryCompressor<Doc>({
|
||||
compressionDepth: 50,
|
||||
maxHistorySize: 100,
|
||||
});
|
||||
|
||||
const stack = new UndoStack<Doc>({ version: 0 });
|
||||
for (let i = 0; i < 200; i++) {
|
||||
stack.push(createCommand("Bump", (d) => ({ version: d.version + 1 }), (d) => ({ version: d.version - 1 })));
|
||||
}
|
||||
|
||||
const result = compressor.compress(stack);
|
||||
console.log(result.squashedCount); // entries merged
|
||||
console.log(result.droppedCount); // entries removed for maxHistorySize
|
||||
console.log(result.newSize); // final stack depth
|
||||
console.log(result.stack.current.version); // 200 — state preserved
|
||||
|
||||
// Compression is idempotent within depth:
|
||||
const second = compressor.compress(result.stack);
|
||||
console.log(second.squashedCount); // 0 (nothing to squash)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new HistoryCompressor<T>(options?)`
|
||||
|
||||
Options validated with `HistoryCompressorOptionsSchema`.
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `compressionDepth` | `number` | `50` | Entries to keep uncompressed at the top |
|
||||
| `maxHistorySize` | `number` | `undefined` | Maximum entries after compression |
|
||||
|
||||
### `compress(stack)` → `CompressionResult & { stack: UndoStack<T> }`
|
||||
|
||||
Returns a new `UndoStack` with compressed history. Does not mutate the original stack.
|
||||
|
||||
### `compressHistory<T>(stack, options?)` → `CompressionResult & { stack: UndoStack<T> }`
|
||||
|
||||
Standalone version. Same semantics as `compress`.
|
||||
|
||||
### `CompressionResult`
|
||||
|
||||
```ts
|
||||
interface CompressionResult {
|
||||
squashedCount: number; // original entries merged into fewer composites
|
||||
droppedCount: number; // entries removed by maxHistorySize
|
||||
newSize: number; // total entries in the result stack
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The squash pass groups consecutive same-label entries — only adjacent runs with matching labels are merged. Non-adjacent groups of the same label are treated independently. The rebuilt stack directly inserts `StackEntry` objects without re-executing commands, so the `current` pointer is set from the last entry's post-execute state. This avoids re-running potentially expensive execute closures at compression time.
|
||||
111
docs/foundation/flow/undo/src/errors/undo-error.md
Normal file
111
docs/foundation/flow/undo/src/errors/undo-error.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# undo-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the structured error class `UndoError` and its stable code registry `UNDO_CODES` for `@suaveplan/undo`. All errors thrown by this package are instances of `UndoError`, enabling callers to discriminate failures precisely via `error.code` without relying on string-matching error messages.
|
||||
|
||||
The codes are registered via `defineCodes` from `@suaveplan/error`, which guarantees namespace-scoped uniqueness, prevents accidental collisions with other packages, and produces stable string identifiers (e.g., `"UNDO.EMPTY_STACK"`) that survive minification and serialization.
|
||||
|
||||
## Features
|
||||
|
||||
- Six error codes covering every failure scenario in the package
|
||||
- Typed `UndoErrorContext` carrying optional `commandId`, `branchId`, and `conflictIds`
|
||||
- `instanceof UndoError` discrimination across module boundaries
|
||||
- `error.code` discrimination without `instanceof` (safe for cross-realm use)
|
||||
- Extends `SuaveplanError` for full cause-chain traversal and OTel span-event adapter support
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { UndoError, UNDO_CODES } from "@suaveplan/undo/errors/undo-error";
|
||||
import { UndoStack } from "@suaveplan/undo/stack/stack";
|
||||
|
||||
const stack = new UndoStack(0);
|
||||
|
||||
try {
|
||||
stack.undo(); // nothing to undo
|
||||
} catch (err) {
|
||||
if (err instanceof UndoError && err.code === UNDO_CODES.EMPTY_STACK) {
|
||||
console.warn("Nothing to undo");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { UndoError, UNDO_CODES } from "@suaveplan/undo/errors/undo-error";
|
||||
import { SelectiveUndoStack } from "@suaveplan/undo/selective/selective-undo";
|
||||
|
||||
const stack = new SelectiveUndoStack({ x: 0 }, { throwOnConflict: true });
|
||||
|
||||
// Simulate two dependent commands
|
||||
const cmd1 = { id: "c1", label: "Set x", /* ... */ };
|
||||
const cmd2 = { id: "c2", label: "Double x", metadata: { dependsOn: "c1" } /* ... */ };
|
||||
|
||||
try {
|
||||
stack.undoById("c1");
|
||||
} catch (err) {
|
||||
if (err instanceof UndoError && err.code === UNDO_CODES.DEPENDENCY_CONFLICT) {
|
||||
const ctx = err.context as { conflictIds?: string[] };
|
||||
console.error("Conflict with:", ctx.conflictIds?.join(", "));
|
||||
}
|
||||
}
|
||||
|
||||
// Transaction guard
|
||||
try {
|
||||
stack.beginTransaction();
|
||||
stack.beginTransaction(); // second call throws
|
||||
} catch (err) {
|
||||
if (err instanceof UndoError && err.code === UNDO_CODES.TRANSACTION_ACTIVE) {
|
||||
console.error("Transaction already in progress");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `UNDO_CODES`
|
||||
|
||||
| Code | Value | Thrown By |
|
||||
|---|---|---|
|
||||
| `UNDO_CODES.EMPTY_STACK` | `"UNDO.EMPTY_STACK"` | `undo()` / `redo()` when nothing available |
|
||||
| `UNDO_CODES.AT_BRANCH_ROOT` | `"UNDO.AT_BRANCH_ROOT"` | `BranchingHistory.switchBranch` for unknown branch |
|
||||
| `UNDO_CODES.DEPENDENCY_CONFLICT` | `"UNDO.DEPENDENCY_CONFLICT"` | `SelectiveUndoStack.undoById` when `throwOnConflict` is set |
|
||||
| `UNDO_CODES.COMMAND_FAILED` | `"UNDO.COMMAND_FAILED"` | `AsyncUndoStack` when async command rejects |
|
||||
| `UNDO_CODES.TRANSACTION_ACTIVE` | `"UNDO.TRANSACTION_ACTIVE"` | `beginTransaction()` when one is already open |
|
||||
| `UNDO_CODES.TRANSACTION_INACTIVE` | `"UNDO.TRANSACTION_INACTIVE"` | `handle.add/commit/rollback` after close |
|
||||
|
||||
### `UndoError`
|
||||
|
||||
```ts
|
||||
new UndoError(code: UndoCode, message: string, options?: {
|
||||
cause?: Error;
|
||||
context?: UndoErrorContext;
|
||||
})
|
||||
```
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `code` | `UndoCode` | Stable string code |
|
||||
| `message` | `string` | Human-readable description |
|
||||
| `context` | `UndoErrorContext \| undefined` | Structured context data |
|
||||
| `cause` | `Error \| undefined` | Original error (for wrapping) |
|
||||
| `name` | `"UndoError"` | Always set |
|
||||
|
||||
### `UndoErrorContext`
|
||||
|
||||
```ts
|
||||
interface UndoErrorContext {
|
||||
commandId?: string;
|
||||
branchId?: string;
|
||||
conflictIds?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` ensures each code string is globally unique per prefix within the process lifetime. The `UNDO_CODES` object is frozen and the union type `UndoCode` is derived via `typeof UNDO_CODES[keyof typeof UNDO_CODES]`, making exhaustive switch-case type-safe. Context fields are typed as `UndoErrorContext` but passed to the base class as `Record<string, unknown>` to satisfy the generic base signature without a breaking cast.
|
||||
107
docs/foundation/flow/undo/src/merge/command-merger.md
Normal file
107
docs/foundation/flow/undo/src/merge/command-merger.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# command-merger
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements `CommandMerger<T>`, a utility for coalescing high-frequency user actions — such as rapid keystroke edits, slider movements, or drag operations — into a single undoable step. Commands are accumulated in a pending buffer and flushed into a single composite command when a count or time window closes, or when two consecutive commands fail the merge predicate.
|
||||
|
||||
The injectable `Clock` makes time-window behavior deterministic in tests without needing real timers or `setTimeout`. The default merge predicate merges consecutive commands with identical labels; callers can supply any custom predicate.
|
||||
|
||||
## Features
|
||||
|
||||
- `add(command)` — adds to buffer; returns merged command if window closes, else `undefined`
|
||||
- `flush()` — immediately flushes buffer; returns merged command or `undefined` if empty
|
||||
- `reset()` — discards pending commands without returning them
|
||||
- `canMerge(a, b)` — checks predicate without mutating state
|
||||
- Getters: `hasPending`, `pendingCount`
|
||||
- `maxCount` window — flushes after accumulating N commands
|
||||
- `timeWindowMs` window — flushes when elapsed time exceeds threshold
|
||||
- Default predicate: merge consecutive commands with the same `label`
|
||||
- Injectable `Clock` for deterministic tests
|
||||
- Options validated with `CommandMergerOptionsSchema`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CommandMerger } from "@suaveplan/undo/merge/command-merger";
|
||||
import { createCommand } from "@suaveplan/undo/command/command";
|
||||
import { UndoStack } from "@suaveplan/undo/stack/stack";
|
||||
|
||||
interface Counter { value: number }
|
||||
const stack = new UndoStack<Counter>({ value: 0 });
|
||||
const merger = new CommandMerger<Counter>({ maxCount: 3 });
|
||||
|
||||
for (const delta of [1, 1, 1, 2]) {
|
||||
const cmd = createCommand("Inc", (s) => ({ value: s.value + delta }), (s) => ({ value: s.value - delta }));
|
||||
const flushed = merger.add(cmd);
|
||||
if (flushed) stack.push(flushed);
|
||||
}
|
||||
|
||||
// Flush remaining
|
||||
const last = merger.flush();
|
||||
if (last) stack.push(last);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CommandMerger } from "@suaveplan/undo/merge/command-merger";
|
||||
import { createCommand } from "@suaveplan/undo/command/command";
|
||||
|
||||
interface Doc { text: string }
|
||||
|
||||
let time = 0;
|
||||
const clock = () => ++time;
|
||||
|
||||
// Custom predicate: only merge "Type" commands
|
||||
const merger = new CommandMerger<Doc>(
|
||||
{ timeWindowMs: 500, clock },
|
||||
(a, b) => a.label === "Type" && b.label === "Type"
|
||||
);
|
||||
|
||||
const typeChar = (ch: string) =>
|
||||
createCommand<Doc>("Type", (d) => ({ text: d.text + ch }), (d) => ({ text: d.text.slice(0, -1) }));
|
||||
|
||||
merger.add(typeChar("H")); // pending
|
||||
merger.add(typeChar("i")); // pending
|
||||
time = 600; // advance clock past window
|
||||
const flushed = merger.add(typeChar("!")); // triggers time-window flush
|
||||
// flushed = merged "Hi" command; "!" starts new pending
|
||||
const remaining = merger.flush(); // flushes "!"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new CommandMerger<T>(options?, predicate?)`
|
||||
|
||||
| Param | Type | Description |
|
||||
|---|---|---|
|
||||
| `options` | `CommandMergerOptions` | `maxCount`, `timeWindowMs`, `clock` |
|
||||
| `predicate` | `MergePredicate<T>` | `(a, b) => boolean` — default: same label |
|
||||
|
||||
### `add(command)` → `Command<T> | undefined`
|
||||
|
||||
Returns a flushed composite when: time window closes, count limit reached, or predicate fails for the new command against the last pending. Returns `undefined` when the command is accumulated.
|
||||
|
||||
### `flush()` → `Command<T> | undefined`
|
||||
|
||||
Returns the merged command for all pending items, or `undefined` if nothing is pending.
|
||||
|
||||
### `reset()` → `void`
|
||||
|
||||
Discards all pending commands.
|
||||
|
||||
### `canMerge(a, b)` → `boolean`
|
||||
|
||||
Calls the merge predicate. Non-mutating.
|
||||
|
||||
### Getters
|
||||
|
||||
- `hasPending` — `true` when there are accumulated commands
|
||||
- `pendingCount` — number of commands in the buffer
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `_doFlush` method returns the single command directly (without wrapping in `composeCommands`) when the buffer contains only one entry, avoiding unnecessary composite overhead. The time window is measured from the first `add` call after a flush. Non-mergeable commands trigger an implicit flush of the existing buffer before the new command starts a fresh buffer — so `add` can return the old buffer's merged command in this case.
|
||||
118
docs/foundation/flow/undo/src/schemas/undo.schemas.md
Normal file
118
docs/foundation/flow/undo/src/schemas/undo.schemas.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# undo.schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides Zod schemas for all option objects accepted by `@suaveplan/undo` constructors and factory functions. Each schema validates user-supplied configuration at runtime, producing clear error messages for invalid inputs before any internal state is created.
|
||||
|
||||
The schemas use `z.strict()` where applicable to prevent unrecognized keys from silently leaking into option objects — a common source of configuration bugs in JavaScript applications. Callback options like `clock`, `idGenerator`, `onTruncate`, and `dependencyCheck` are validated as `z.function()` to confirm they are callable without inspecting their signatures at runtime.
|
||||
|
||||
## Features
|
||||
|
||||
- `UndoStackOptionsSchema` — validates `maxEntries`, `maxMemoryBytes`, `onTruncate`, `clock`, `idGenerator`
|
||||
- `BranchingOptionsSchema` — validates `initialBranchLabel`, `branchOnDiverge`, `maxEntries`, `clock`, `idGenerator`
|
||||
- `HistoryCompressorOptionsSchema` — validates `compressionDepth`, `maxHistorySize` as positive integers
|
||||
- `CommandMergerOptionsSchema` — validates `maxCount`, `timeWindowMs`, `clock`
|
||||
- `SelectiveUndoOptionsSchema` — extends `UndoStackOptionsSchema` with `throwOnConflict`, `dependencyCheck`
|
||||
- All numeric limits validated as positive integers to prevent nonsensical values
|
||||
- Exported `z.infer`-compatible input types for each schema
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { UndoStackOptionsSchema } from "@suaveplan/undo/schemas/undo.schemas";
|
||||
|
||||
const result = UndoStackOptionsSchema.safeParse({
|
||||
maxEntries: 100,
|
||||
maxMemoryBytes: 1_048_576,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(result.error.flatten());
|
||||
} else {
|
||||
const opts = result.data;
|
||||
console.log(opts.maxEntries); // 100
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
BranchingOptionsSchema,
|
||||
CommandMergerOptionsSchema,
|
||||
HistoryCompressorOptionsSchema,
|
||||
SelectiveUndoOptionsSchema,
|
||||
} from "@suaveplan/undo/schemas/undo.schemas";
|
||||
|
||||
// Branching with custom clock
|
||||
BranchingOptionsSchema.parse({
|
||||
branchOnDiverge: true,
|
||||
initialBranchLabel: "main",
|
||||
clock: Date.now,
|
||||
idGenerator: () => crypto.randomUUID(),
|
||||
});
|
||||
|
||||
// Compressor with depth + size limit
|
||||
HistoryCompressorOptionsSchema.parse({
|
||||
compressionDepth: 50,
|
||||
maxHistorySize: 200,
|
||||
});
|
||||
|
||||
// Merger with time window
|
||||
CommandMergerOptionsSchema.parse({
|
||||
maxCount: 20,
|
||||
timeWindowMs: 500,
|
||||
clock: Date.now,
|
||||
});
|
||||
|
||||
// Selective undo with dependency checker
|
||||
SelectiveUndoOptionsSchema.parse({
|
||||
throwOnConflict: true,
|
||||
dependencyCheck: (a: unknown, b: unknown) => false,
|
||||
maxEntries: 50,
|
||||
});
|
||||
|
||||
// Invalid input — throws ZodError
|
||||
try {
|
||||
UndoStackOptionsSchema.parse({ maxEntries: -1 });
|
||||
} catch (e) {
|
||||
// ZodError: maxEntries must be a positive integer
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `UndoStackOptionsSchema`
|
||||
|
||||
Validates `UndoStackOptions`. All fields optional.
|
||||
|
||||
| Field | Validation |
|
||||
|---|---|
|
||||
| `maxEntries` | `z.number().int().positive()` |
|
||||
| `maxMemoryBytes` | `z.number().int().positive()` |
|
||||
| `onTruncate` | `z.function()` |
|
||||
| `clock` | `z.function()` |
|
||||
| `idGenerator` | `z.function()` |
|
||||
|
||||
### `BranchingOptionsSchema`
|
||||
|
||||
Same as `UndoStackOptionsSchema` minus `onTruncate`/`maxMemoryBytes`, plus `initialBranchLabel` (non-empty string) and `branchOnDiverge` (boolean).
|
||||
|
||||
### `HistoryCompressorOptionsSchema`
|
||||
|
||||
`compressionDepth` and `maxHistorySize` — both positive integers.
|
||||
|
||||
### `CommandMergerOptionsSchema`
|
||||
|
||||
`maxCount` (positive integer), `timeWindowMs` (positive number), `clock` (function).
|
||||
|
||||
### `SelectiveUndoOptionsSchema`
|
||||
|
||||
Extends `UndoStackOptionsSchema` with `throwOnConflict` (boolean) and `dependencyCheck` (function).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SelectiveUndoOptionsSchema` extends rather than duplicates `UndoStackOptionsSchema` to keep them in sync — any change to the base automatically propagates. The `z.function()` validator accepts any callable but does not validate arity or return types; this is intentional because TypeScript's type system already enforces those contracts at call sites. `z.strict()` on base schemas prevents typo'd option names from silently being ignored.
|
||||
115
docs/foundation/flow/undo/src/selective/selective-undo.md
Normal file
115
docs/foundation/flow/undo/src/selective/selective-undo.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# selective-undo
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements `SelectiveUndoStack<T>`, which extends `UndoStack<T>` with the ability to undo any command in the history by its ID — not just the most recent one. This is essential for collaborative editing, document editors with per-user undo, and any scenario where a user needs to reverse a specific past action without reversing everything that came after it.
|
||||
|
||||
The dependency-detection algorithm checks each later command against the target using a configurable `DependencyChecker<T>` predicate. If no dependencies are found, the target command is removed and all subsequent commands are re-applied to rebuild a consistent state.
|
||||
|
||||
## Features
|
||||
|
||||
- `undoById(commandId)` — selectively undoes a command; re-applies later commands
|
||||
- `getDependents(commandId)` — returns IDs of commands that depend on the target
|
||||
- `canUndoById(commandId)` — non-throwing check; returns `SelectiveUndoResult`
|
||||
- Configurable `DependencyChecker<T>` — defaults to no dependencies
|
||||
- `throwOnConflict` option — throws `UndoError(UNDO.DEPENDENCY_CONFLICT)` instead of returning `success: false`
|
||||
- Unknown ID returns `{ success: false, conflictIds: [] }` without throwing
|
||||
- OTel: increments `undo.undo.total` with `stack_type: 'selective'` on success
|
||||
- Inherits all `UndoStack<T>` functionality: `push`, `undo`, `redo`, transactions, limits
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SelectiveUndoStack } from "@suaveplan/undo/selective/selective-undo";
|
||||
import { createCommand } from "@suaveplan/undo/command/command";
|
||||
|
||||
interface Counter { value: number }
|
||||
const stack = new SelectiveUndoStack<Counter>({ value: 0 });
|
||||
|
||||
const a = createCommand("A", (s) => ({ value: s.value + 1 }), (s) => ({ value: s.value - 1 }));
|
||||
const b = createCommand("B", (s) => ({ value: s.value * 2 }), (s) => ({ value: Math.floor(s.value / 2) }));
|
||||
|
||||
stack.push(a); // value: 1
|
||||
stack.push(b); // value: 2
|
||||
|
||||
// Undo command A (earlier), re-apply B
|
||||
const result = stack.undoById(a.id);
|
||||
if (result.success) {
|
||||
console.log(stack.current.value); // 0 * 2 = 0
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SelectiveUndoStack } from "@suaveplan/undo/selective/selective-undo";
|
||||
import { createCommand } from "@suaveplan/undo/command/command";
|
||||
import { UNDO_CODES, UndoError } from "@suaveplan/undo/errors/undo-error";
|
||||
import type { DependencyChecker } from "@suaveplan/undo/types/undo.types";
|
||||
|
||||
interface Doc { fields: Record<string, string> }
|
||||
|
||||
// Commands that declare dependencies via metadata
|
||||
const depCheck: DependencyChecker<Doc> = (earlier, later) =>
|
||||
(later.metadata?.dependsOn as string[] | undefined)?.includes(earlier.id) ?? false;
|
||||
|
||||
const stack = new SelectiveUndoStack<Doc>(
|
||||
{ fields: {} },
|
||||
{ throwOnConflict: true, dependencyCheck: depCheck as DependencyChecker<unknown> }
|
||||
);
|
||||
|
||||
const setName = createCommand("SetName", (d) => ({ ...d, fields: { ...d.fields, name: "Alice" } }), (d) => {
|
||||
const { name: _, ...rest } = d.fields;
|
||||
return { ...d, fields: rest };
|
||||
});
|
||||
const greet = createCommand("Greet", (d) => ({ ...d, fields: { ...d.fields, greeting: `Hi, ${d.fields["name"]}` } }), (d) => {
|
||||
const { greeting: _, ...rest } = d.fields;
|
||||
return { ...d, fields: rest };
|
||||
}, { dependsOn: [setName.id] });
|
||||
|
||||
stack.push(setName);
|
||||
stack.push(greet);
|
||||
|
||||
try {
|
||||
stack.undoById(setName.id);
|
||||
} catch (err) {
|
||||
if (err instanceof UndoError && err.code === UNDO_CODES.DEPENDENCY_CONFLICT) {
|
||||
console.error("Conflict:", (err.context as Record<string, unknown>)?.["conflictIds"]);
|
||||
}
|
||||
}
|
||||
|
||||
// Without throwOnConflict:
|
||||
const check = stack.canUndoById(setName.id);
|
||||
console.log(check.success); // false
|
||||
console.log(check.conflictIds); // [greet.id]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new SelectiveUndoStack<T>(initial, options?, bus?)`
|
||||
|
||||
Options extend `UndoStackOptions` with:
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `throwOnConflict` | `boolean` | `false` | Throw instead of returning `success: false` |
|
||||
| `dependencyCheck` | `DependencyChecker<T>` | `() => false` | Returns `true` if `later` depends on `earlier` |
|
||||
|
||||
### `undoById(commandId)` → `SelectiveUndoResult<T>`
|
||||
|
||||
Finds the command, checks for conflicts, re-applies subsequent commands. Returns `{ success: true, state, conflictIds: [] }` or `{ success: false, conflictIds }`.
|
||||
|
||||
### `getDependents(commandId)` → `string[]`
|
||||
|
||||
Returns IDs of later commands that depend on the given command. Non-throwing.
|
||||
|
||||
### `canUndoById(commandId)` → `SelectiveUndoResult<T>`
|
||||
|
||||
Checks conflicts without modifying state. Non-throwing.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The re-application loop rebuilds `stateBefore` for each subsequent entry by tracking state as it would be after removing the target command. This ensures that `undo()` on any of those entries still works correctly after a selective undo. The `_throwOnConflict` and `_dependencyCheck` fields use `protected` visibility, consistent with the base class pattern, to allow further subclassing.
|
||||
120
docs/foundation/flow/undo/src/stack/stack.md
Normal file
120
docs/foundation/flow/undo/src/stack/stack.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# stack
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements `UndoStack<T>`, a synchronous linear undo/redo stack with configurable memory limits, atomic transaction support, and `EventBusLike` integration for reactive applications. It is the core data structure used by most higher-level modules in `@suaveplan/undo`.
|
||||
|
||||
Commands are stored as `StackEntry<T>` pairs capturing both the command and the state immediately before execution, enabling exact rollback without re-replaying history. All mutations respect `maxEntries` and `maxMemoryBytes` limits, trimming the oldest entries and calling an `onTruncate` callback when limits are exceeded.
|
||||
|
||||
## Features
|
||||
|
||||
- `push(command)` — executes command, pushes to undo stack, clears redo stack
|
||||
- `pushState(newState)` — pushes a state snapshot without an explicit command
|
||||
- `undo()` — reverses the most recent command; throws `UndoError(UNDO.EMPTY_STACK)` when empty
|
||||
- `redo()` — re-applies the most recently undone command; throws `UndoError(UNDO.EMPTY_STACK)` when empty
|
||||
- `beginTransaction()` — returns a `TransactionHandle` for atomic multi-command batching
|
||||
- `clear()` — discards all history, keeps current state; emits `undo:clear`
|
||||
- `reset(newInitial)` — resets state and clears history
|
||||
- `snapshot()` — returns a serializable `UndoStackState<T>`
|
||||
- `getUndoStack()` / `getRedoStack()` — copies of internal stacks
|
||||
- Getters: `current`, `canUndo`, `canRedo`, `undoSize`, `redoSize`, `activeBranchId`
|
||||
- `EventBusLike` integration — emits `undo:push`, `undo:undo`, `undo:redo`, `undo:clear`
|
||||
- OTel counters (`undo.push.total`, `undo.undo.total`, `undo.redo.total`) and `undo.stack.depth` histogram
|
||||
- Injectable `clock` and `idGenerator` via `UndoStackOptions`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { UndoStack } from "@suaveplan/undo/stack/stack";
|
||||
import { createCommand } from "@suaveplan/undo/command/command";
|
||||
|
||||
interface Counter { value: number }
|
||||
|
||||
const stack = new UndoStack<Counter>({ value: 0 });
|
||||
|
||||
const inc = createCommand("Inc", (s) => ({ value: s.value + 1 }), (s) => ({ value: s.value - 1 }));
|
||||
stack.push(inc);
|
||||
console.log(stack.current.value); // 1
|
||||
|
||||
stack.undo();
|
||||
console.log(stack.current.value); // 0
|
||||
|
||||
stack.redo();
|
||||
console.log(stack.current.value); // 1
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { UndoStack } from "@suaveplan/undo/stack/stack";
|
||||
import { createCommand } from "@suaveplan/undo/command/command";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import type { UndoEventMap } from "@suaveplan/undo/types/undo.types";
|
||||
|
||||
interface Doc { title: string }
|
||||
|
||||
const bus = new EventBus<UndoEventMap>();
|
||||
bus.on("undo:push", ({ commandId, undoSize }) => {
|
||||
console.log(`Pushed ${commandId}, depth=${undoSize}`);
|
||||
});
|
||||
|
||||
const stack = new UndoStack<Doc>(
|
||||
{ title: "Draft" },
|
||||
{ maxEntries: 10, onTruncate: (removed) => console.log("Dropped", removed.length) },
|
||||
bus
|
||||
);
|
||||
|
||||
// Transaction: batch two commands into one atomic undo unit
|
||||
const tx = stack.beginTransaction();
|
||||
tx.add(createCommand("Title", (d) => ({ ...d, title: "Final" }), (d) => ({ ...d, title: "Draft" })));
|
||||
tx.add(createCommand("Suffix", (d) => ({ ...d, title: d.title + "!" }), (d) => ({ ...d, title: d.title.slice(0, -1) })));
|
||||
tx.commit(); // pushes both atomically
|
||||
|
||||
console.log(stack.current.title); // "Final!"
|
||||
stack.undo(); // undoes "Suffix"
|
||||
stack.undo(); // undoes "Title"
|
||||
console.log(stack.current.title); // "Draft"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new UndoStack<T>(initial, options?, bus?)`
|
||||
|
||||
| Param | Type | Description |
|
||||
|---|---|---|
|
||||
| `initial` | `T` | Initial state |
|
||||
| `options` | `UndoStackOptions` | Optional limits and injections |
|
||||
| `bus` | `EventBusLike<UndoEventMap>` | Optional event bus |
|
||||
|
||||
Options are validated with `UndoStackOptionsSchema`. Throws `ZodError` on invalid options.
|
||||
|
||||
### `push(command)` → `T`
|
||||
|
||||
Executes command and stores it. Clears redo stack.
|
||||
|
||||
### `pushState(newState)` → `T`
|
||||
|
||||
Pushes a synthetic command wrapping the state change.
|
||||
|
||||
### `undo()` → `T`
|
||||
|
||||
Pops from undo stack, applies inverse. Throws `UndoError(UNDO.EMPTY_STACK)` if empty.
|
||||
|
||||
### `redo()` → `T`
|
||||
|
||||
Pops from redo stack, re-applies command. Throws `UndoError(UNDO.EMPTY_STACK)` if empty.
|
||||
|
||||
### `beginTransaction()` → `TransactionHandle<T>`
|
||||
|
||||
Opens a transaction. Throws `UndoError(UNDO.TRANSACTION_ACTIVE)` if one is already open. Handle methods throw `UndoError(UNDO.TRANSACTION_INACTIVE)` after commit/rollback.
|
||||
|
||||
### `snapshot()` → `UndoStackState<T>`
|
||||
|
||||
Returns `{ current, undoStack, redoStack, undoSize, redoSize }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The undo stack stores `stateBefore` in each entry rather than re-executing the full history chain, making `undo()` O(1). Trimming removes the oldest entries (front of array) to preserve the most recent history. Transactions use a simple "pending commands" buffer flushed to `push()` on commit. All class members use `protected` rather than `private` to enable `SelectiveUndoStack` and other subclasses to access internals without re-implementing the whole class.
|
||||
165
docs/foundation/flow/undo/src/types/undo.types.md
Normal file
165
docs/foundation/flow/undo/src/types/undo.types.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# undo.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines all shared TypeScript interfaces, type aliases, and type guards used throughout `@suaveplan/undo`. Having a single canonical types module prevents circular imports, ensures every sub-module speaks the same type language, and makes the public API surface easy to explore in one place.
|
||||
|
||||
The types cover the complete lifecycle of undo/redo history management: individual command representation, stack and branch structures, constructor option bundles, serializable state snapshots, transaction handles, compression results, and event payloads for `EventBusLike` integration.
|
||||
|
||||
## Features
|
||||
|
||||
- `Command<T>` — synchronous command with `execute`, `undo`, `id`, `label`, `timestamp`, and optional `metadata`
|
||||
- `PatchCommand<T>` — extends `Command<T>` with a JSON Patch pair (`patch` / `inversePatch`) for structural state transitions
|
||||
- `AsyncCommand<T>` — asynchronous variant with `Promise`-returning `execute` / `undo`
|
||||
- `StackEntry<T>` — pairs a command with the state captured just before execution (enables exact rollback)
|
||||
- `Branch<T>` — named timeline with independent undo/redo stacks and a `createdAt` timestamp
|
||||
- `UndoStackOptions` — constructor options for `UndoStack`: `maxEntries`, `maxMemoryBytes`, `onTruncate`, injectable `clock` and `idGenerator`
|
||||
- `BranchingOptions` — constructor options for `BranchingHistory`: `branchOnDiverge`, `initialBranchLabel`, `maxEntries`, injectable `clock` and `idGenerator`
|
||||
- `HistoryCompressorOptions` — `compressionDepth` (entries to keep uncompressed) and `maxHistorySize`
|
||||
- `SelectiveUndoOptions` — extends `UndoStackOptions` with `throwOnConflict` and custom `dependencyCheck`
|
||||
- `CommandMergerOptions` — `maxCount`, `timeWindowMs`, injectable `clock`
|
||||
- `UndoStackState<T>` / `BranchingState<T>` — serializable snapshots for persistence
|
||||
- `TransactionHandle<T>` / `AsyncTransactionHandle<T>` — atomic batch commit or rollback
|
||||
- `CompressionResult` — counts of squashed, dropped, and total entries
|
||||
- `SelectiveUndoResult<T>` — outcome of a targeted undo with conflict list
|
||||
- `DependencyChecker<T>` / `MergePredicate<T>` — callback type aliases for extensibility
|
||||
- `UndoEventMap` — typed payload map for `EventBusLike` events emitted by `UndoStack`
|
||||
- `isPatchCommand<T>` / `isAsyncCommand<T>` — runtime type guards
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
Command,
|
||||
PatchCommand,
|
||||
StackEntry,
|
||||
} from "@suaveplan/undo/types/undo.types";
|
||||
import { isPatchCommand } from "@suaveplan/undo/types/undo.types";
|
||||
|
||||
interface Counter { value: number }
|
||||
|
||||
const cmd: Command<Counter> = {
|
||||
id: "cmd-1",
|
||||
label: "Increment",
|
||||
timestamp: Date.now(),
|
||||
execute: (s) => ({ value: s.value + 1 }),
|
||||
undo: (s) => ({ value: s.value - 1 }),
|
||||
};
|
||||
|
||||
// Type guard usage
|
||||
function describe<T>(entry: StackEntry<T>): string {
|
||||
if (isPatchCommand(entry.command)) {
|
||||
return `Patch command: ${entry.command.label} (${entry.command.patch.length} ops)`;
|
||||
}
|
||||
return `Closure command: ${entry.command.label}`;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
BranchingOptions,
|
||||
CompressionResult,
|
||||
DependencyChecker,
|
||||
SelectiveUndoOptions,
|
||||
UndoEventMap,
|
||||
} from "@suaveplan/undo/types/undo.types";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { BranchingHistory } from "@suaveplan/undo/branch/branch";
|
||||
import { SelectiveUndoStack } from "@suaveplan/undo/selective/selective-undo";
|
||||
|
||||
// Deterministic clock for testing
|
||||
let time = 1000;
|
||||
const clock = () => ++time;
|
||||
|
||||
const opts: BranchingOptions = {
|
||||
branchOnDiverge: true,
|
||||
initialBranchLabel: "main",
|
||||
clock,
|
||||
idGenerator: () => `branch-${time}`,
|
||||
};
|
||||
|
||||
const history = new BranchingHistory({ value: 0 }, opts);
|
||||
|
||||
// Typed EventBus for undo events
|
||||
const bus = new EventBus<UndoEventMap>();
|
||||
bus.on("undo:push", ({ commandId, undoSize }) => {
|
||||
console.log(`Pushed ${commandId}, depth=${undoSize}`);
|
||||
});
|
||||
|
||||
// Selective undo with custom dependency checker
|
||||
const depCheck: DependencyChecker<Record<string, number>> = (earlier, later) =>
|
||||
later.metadata?.dependsOn === earlier.id;
|
||||
|
||||
const selectiveOpts: SelectiveUndoOptions = {
|
||||
throwOnConflict: false,
|
||||
dependencyCheck: depCheck as DependencyChecker<unknown>,
|
||||
};
|
||||
|
||||
const stack = new SelectiveUndoStack({ x: 0 }, selectiveOpts, bus);
|
||||
|
||||
// CompressionResult type usage
|
||||
const result: CompressionResult = { squashedCount: 5, droppedCount: 2, newSize: 8 };
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `Command<T>`
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `string` | Unique identifier |
|
||||
| `label` | `string` | Human-readable name |
|
||||
| `timestamp` | `number` | Creation time (ms since epoch) |
|
||||
| `execute` | `(state: T) => T` | Apply the command |
|
||||
| `undo` | `(state: T) => T` | Reverse the command |
|
||||
| `metadata` | `Record<string, unknown> \| undefined` | Arbitrary context |
|
||||
|
||||
### `PatchCommand<T>` extends `Command<T>`
|
||||
|
||||
Additional properties: `type: "patch"`, `patch: Patch`, `inversePatch: Patch`.
|
||||
|
||||
### `AsyncCommand<T>`
|
||||
|
||||
Same shape as `Command<T>` but `execute` and `undo` return `Promise<T>`.
|
||||
|
||||
### `isPatchCommand<T>(cmd)`
|
||||
|
||||
Returns `cmd is PatchCommand<T>`. Checks `cmd.type === "patch"`.
|
||||
|
||||
### `isAsyncCommand<T>(cmd)`
|
||||
|
||||
Returns `cmd is AsyncCommand<T>`. Checks that `execute` is an `AsyncFunction`.
|
||||
|
||||
### `StackEntry<T>`
|
||||
|
||||
`{ command: Command<T>, stateBefore: T }` — the state before the command executed.
|
||||
|
||||
### `Branch<T>`
|
||||
|
||||
`{ id, label, createdAt, undoStack, redoStack, current }` — an independent timeline.
|
||||
|
||||
### `UndoStackOptions`
|
||||
|
||||
`maxEntries?`, `maxMemoryBytes?`, `onTruncate?`, `clock?`, `idGenerator?`.
|
||||
|
||||
### `BranchingOptions`
|
||||
|
||||
`initialBranchLabel?`, `branchOnDiverge?`, `maxEntries?`, `clock?`, `idGenerator?`.
|
||||
|
||||
### `CompressionResult`
|
||||
|
||||
`{ squashedCount, droppedCount, newSize }` — all readonly numbers.
|
||||
|
||||
### `SelectiveUndoResult<T>`
|
||||
|
||||
`{ success, state?, conflictIds }` — `state` is present only when `success` is `true`.
|
||||
|
||||
### `UndoEventMap`
|
||||
|
||||
Typed event map for use with `EventBusLike<UndoEventMap>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All interfaces are `readonly`-first: mutable properties are the exception rather than the rule. The `StackEntry.stateBefore` pattern mirrors the Command pattern's pre-condition capture, allowing exact state restoration without re-executing prior commands. `PatchCommand` and `AsyncCommand` are additive subtypes (no breaking changes to `Command<T>`). Type guards use structural checks rather than `instanceof` to remain runtime-portable across module boundaries.
|
||||
Reference in New Issue
Block a user