first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

File diff suppressed because one or more lines are too long

View 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`.

View 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.

View 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).

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.