Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,104 @@
{
"name": "@suaveplan/undo",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Enterprise-grade undo/redo engine with branching history, async commands, history compression, selective undo, and command merging",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./command/command": {
"import": { "types": "./dist/command/command.d.ts", "default": "./dist/command/command.js" },
"require": { "types": "./dist/command/command.d.ts", "default": "./dist/command/command.js" }
},
"./stack/stack": {
"import": { "types": "./dist/stack/stack.d.ts", "default": "./dist/stack/stack.js" },
"require": { "types": "./dist/stack/stack.d.ts", "default": "./dist/stack/stack.js" }
},
"./branch/branch": {
"import": { "types": "./dist/branch/branch.d.ts", "default": "./dist/branch/branch.js" },
"require": { "types": "./dist/branch/branch.d.ts", "default": "./dist/branch/branch.js" }
},
"./async/async-command": {
"import": { "types": "./dist/async/async-command.d.ts", "default": "./dist/async/async-command.js" },
"require": { "types": "./dist/async/async-command.d.ts", "default": "./dist/async/async-command.js" }
},
"./async/async-stack": {
"import": { "types": "./dist/async/async-stack.d.ts", "default": "./dist/async/async-stack.js" },
"require": { "types": "./dist/async/async-stack.d.ts", "default": "./dist/async/async-stack.js" }
},
"./compression/history-compressor": {
"import": { "types": "./dist/compression/history-compressor.d.ts", "default": "./dist/compression/history-compressor.js" },
"require": { "types": "./dist/compression/history-compressor.d.ts", "default": "./dist/compression/history-compressor.js" }
},
"./selective/selective-undo": {
"import": { "types": "./dist/selective/selective-undo.d.ts", "default": "./dist/selective/selective-undo.js" },
"require": { "types": "./dist/selective/selective-undo.d.ts", "default": "./dist/selective/selective-undo.js" }
},
"./merge/command-merger": {
"import": { "types": "./dist/merge/command-merger.d.ts", "default": "./dist/merge/command-merger.js" },
"require": { "types": "./dist/merge/command-merger.d.ts", "default": "./dist/merge/command-merger.js" }
},
"./types/undo.types": {
"import": { "types": "./dist/types/undo.types.d.ts", "default": "./dist/types/undo.types.js" },
"require": { "types": "./dist/types/undo.types.d.ts", "default": "./dist/types/undo.types.js" }
},
"./schemas/undo.schemas": {
"import": { "types": "./dist/schemas/undo.schemas.d.ts", "default": "./dist/schemas/undo.schemas.js" },
"require": { "types": "./dist/schemas/undo.schemas.d.ts", "default": "./dist/schemas/undo.schemas.js" }
},
"./errors/undo-error": {
"import": { "types": "./dist/errors/undo-error.d.ts", "default": "./dist/errors/undo-error.js" },
"require": { "types": "./dist/errors/undo-error.d.ts", "default": "./dist/errors/undo-error.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/diff": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

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,286 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { UNDO_CODES, UndoError } from "../errors/undo-error.js";
import {
composeAsyncCommands,
createAsyncCommand,
executeAsyncCommand,
undoAsyncCommand,
} from "./async-command.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface Counter {
value: number;
}
const asyncInc = (n = 1) =>
createAsyncCommand<Counter>(
`+${n}`,
async (s) => ({ value: s.value + n }),
async (s) => ({ value: s.value - n })
);
// ---------------------------------------------------------------------------
// createAsyncCommand
// ---------------------------------------------------------------------------
describe("createAsyncCommand", () => {
it("sets id and label", { timeout: 30000 }, async () => {
const cmd = createAsyncCommand<Counter>(
"Test",
async (s) => s,
async (s) => s
);
expect(cmd.id).toBeDefined();
expect(cmd.label).toBe("Test");
});
it("uses injected clock and idGenerator", { timeout: 30000 }, async () => {
const cmd = createAsyncCommand<Counter>(
"X",
async (s) => s,
async (s) => s,
undefined,
{ clock: () => 42, idGenerator: () => "fixed-id" }
);
expect(cmd.id).toBe("fixed-id");
expect(cmd.timestamp).toBe(42);
});
it("preserves metadata", { timeout: 30000 }, async () => {
const cmd = createAsyncCommand<Counter>(
"X",
async (s) => s,
async (s) => s,
{ key: "val" }
);
expect(cmd.metadata?.["key"]).toBe("val");
});
it("wraps execute errors in UndoError COMMAND_FAILED", {
timeout: 30000,
}, async () => {
const cmd = createAsyncCommand<Counter>(
"Fail",
async () => {
throw new Error("oops");
},
async (s) => s
);
await expect(cmd.execute({ value: 0 })).rejects.toThrow(UndoError);
await expect(cmd.execute({ value: 0 })).rejects.toMatchObject({
code: UNDO_CODES.COMMAND_FAILED,
});
});
it("wraps undo errors in UndoError COMMAND_FAILED", {
timeout: 30000,
}, async () => {
const cmd = createAsyncCommand<Counter>(
"Fail",
async (s) => s,
async () => {
throw new Error("undo fail");
}
);
await expect(cmd.undo({ value: 0 })).rejects.toThrow(UndoError);
});
it("wraps non-Error rejections", { timeout: 30000 }, async () => {
const cmd = createAsyncCommand<Counter>(
"Fail",
async () => {
throw "string error";
},
async (s) => s
);
await expect(cmd.execute({ value: 0 })).rejects.toMatchObject({
code: UNDO_CODES.COMMAND_FAILED,
});
});
});
// ---------------------------------------------------------------------------
// composeAsyncCommands
// ---------------------------------------------------------------------------
describe("composeAsyncCommands", () => {
it("executes in order", { timeout: 30000 }, async () => {
const a = asyncInc(1);
const b = asyncInc(2);
const composed = composeAsyncCommands("AB", [a, b]);
const result = await executeAsyncCommand({ value: 0 }, composed);
expect(result.value).toBe(3);
});
it("undoes in reverse order", { timeout: 30000 }, async () => {
const a = asyncInc(1);
const b = asyncInc(2);
const composed = composeAsyncCommands("AB", [a, b]);
const after = await executeAsyncCommand({ value: 0 }, composed);
const back = await undoAsyncCommand(after, composed);
expect(back.value).toBe(0);
});
it("handles empty commands", { timeout: 30000 }, async () => {
const noop = composeAsyncCommands<Counter>("Noop", []);
const result = await executeAsyncCommand({ value: 5 }, noop);
expect(result.value).toBe(5);
});
});
// ---------------------------------------------------------------------------
// executeAsyncCommand / undoAsyncCommand
// ---------------------------------------------------------------------------
describe("executeAsyncCommand", () => {
it("applies command", { timeout: 30000 }, async () => {
const cmd = asyncInc(10);
const result = await executeAsyncCommand({ value: 0 }, cmd);
expect(result.value).toBe(10);
});
it("wraps non-UndoError rejection", { timeout: 30000 }, async () => {
const failing = createAsyncCommand<Counter>(
"F",
async () => {
throw new Error("raw");
},
async (s) => s
);
// createAsyncCommand already wraps, so calling execute throws UndoError
await expect(
executeAsyncCommand({ value: 0 }, failing)
).rejects.toMatchObject({
code: UNDO_CODES.COMMAND_FAILED,
});
});
it("passes through UndoError without double-wrapping", {
timeout: 30000,
}, async () => {
const err = new UndoError(UNDO_CODES.COMMAND_FAILED, "already wrapped");
const cmd = createAsyncCommand<Counter>(
"F",
async () => {
throw err;
},
async (s) => s
);
try {
await executeAsyncCommand({ value: 0 }, cmd);
} catch (caught) {
expect(caught).toBeInstanceOf(UndoError);
// The message matches the inner error since createAsyncCommand wraps
expect((caught as UndoError).code).toBe(UNDO_CODES.COMMAND_FAILED);
}
});
});
describe("undoAsyncCommand", () => {
it("reverses command", { timeout: 30000 }, async () => {
const cmd = asyncInc(7);
const after = await executeAsyncCommand({ value: 0 }, cmd);
const back = await undoAsyncCommand(after, cmd);
expect(back.value).toBe(0);
});
it("passes through UndoError from undo without re-wrapping", {
timeout: 30000,
}, async () => {
const innerErr = new UndoError(UNDO_CODES.COMMAND_FAILED, "inner");
// Use a raw AsyncCommand so the UndoError isn't double-wrapped
const rawCmd = {
id: "raw",
label: "Raw",
timestamp: 0,
execute: async (s: Counter) => s,
undo: async (): Promise<Counter> => {
throw innerErr;
},
};
const caught = await undoAsyncCommand({ value: 0 }, rawCmd).catch(
(e) => e
);
expect(caught).toBe(innerErr);
});
it("wraps non-UndoError from undo", { timeout: 30000 }, async () => {
const rawCmd = {
id: "raw",
label: "Raw",
timestamp: 0,
execute: async (s: Counter) => s,
undo: async (): Promise<Counter> => {
throw new Error("raw undo fail");
},
};
await expect(
undoAsyncCommand({ value: 0 }, rawCmd)
).rejects.toMatchObject({ code: UNDO_CODES.COMMAND_FAILED });
});
});
describe("executeAsyncCommand (non-UndoError fallback)", () => {
it("wraps a raw non-UndoError rejection", { timeout: 30000 }, async () => {
const rawCmd = {
id: "raw",
label: "RawExec",
timestamp: 0,
execute: async (): Promise<Counter> => {
throw new Error("plain error");
},
undo: async (s: Counter) => s,
};
await expect(
executeAsyncCommand({ value: 0 }, rawCmd)
).rejects.toMatchObject({ code: UNDO_CODES.COMMAND_FAILED });
});
it("wraps a non-Error (string) rejection from executeAsyncCommand", {
timeout: 30000,
}, async () => {
const rawCmd = {
id: "raw",
label: "RawExec",
timestamp: 0,
execute: async (): Promise<Counter> => {
throw "a string error" as any;
},
undo: async (s: Counter) => s,
};
await expect(
executeAsyncCommand({ value: 0 }, rawCmd)
).rejects.toMatchObject({ code: UNDO_CODES.COMMAND_FAILED });
});
it("wraps a non-Error (string) rejection from undoAsyncCommand", {
timeout: 30000,
}, async () => {
const rawCmd = {
id: "raw",
label: "RawUndo",
timestamp: 0,
execute: async (s: Counter) => s,
undo: async (): Promise<Counter> => {
throw "a string undo error" as any;
},
};
await expect(
undoAsyncCommand({ value: 0 }, rawCmd)
).rejects.toMatchObject({ code: UNDO_CODES.COMMAND_FAILED });
});
});
describe("createAsyncCommand (non-Error undo throw)", () => {
it("wraps non-Error thrown from undo", { timeout: 30000 }, async () => {
const cmd = createAsyncCommand<Counter>(
"Fail",
async (s) => s,
async () => {
throw "string undo error" as any;
}
);
await expect(cmd.undo({ value: 0 })).rejects.toMatchObject({
code: UNDO_CODES.COMMAND_FAILED,
});
});
});

View File

@@ -0,0 +1,155 @@
import type { Clock, IdGenerator } from "@suaveplan/types";
import { UNDO_CODES, UndoError } from "../errors/undo-error.js";
import type { AsyncCommand } from "../types/undo.types.js";
// ---------------------------------------------------------------------------
// Internal defaults
// ---------------------------------------------------------------------------
const defaultClock: Clock = () => Date.now();
const defaultIdGenerator: IdGenerator = () => crypto.randomUUID();
// ---------------------------------------------------------------------------
// AsyncCommandFactoryOptions
// ---------------------------------------------------------------------------
export interface AsyncCommandFactoryOptions {
clock?: Clock;
idGenerator?: IdGenerator;
}
// ---------------------------------------------------------------------------
// createAsyncCommand
// ---------------------------------------------------------------------------
/**
* Creates an `AsyncCommand<T>` backed by async execute/undo closures.
* Errors from `execute` and `undo` are wrapped in `UndoError(UNDO.COMMAND_FAILED)`.
*/
export function createAsyncCommand<T>(
label: string,
executeFn: (state: T) => Promise<T>,
undoFn: (state: T) => Promise<T>,
metadata?: Record<string, unknown>,
opts?: AsyncCommandFactoryOptions
): AsyncCommand<T> {
const clock = opts?.clock ?? defaultClock;
const idGen = opts?.idGenerator ?? defaultIdGenerator;
const cmd: AsyncCommand<T> = {
id: idGen(),
label,
timestamp: clock(),
execute: async (state: T): Promise<T> => {
try {
return await executeFn(state);
} catch (err) {
throw new UndoError(
UNDO_CODES.COMMAND_FAILED,
`Async command "${label}" execute failed`,
{
cause:
err instanceof Error ? err : new Error(String(err)),
}
);
}
},
undo: async (state: T): Promise<T> => {
try {
return await undoFn(state);
} catch (err) {
throw new UndoError(
UNDO_CODES.COMMAND_FAILED,
`Async command "${label}" undo failed`,
{
cause:
err instanceof Error ? err : new Error(String(err)),
}
);
}
},
};
if (metadata !== undefined) {
(cmd as { metadata?: Record<string, unknown> }).metadata = metadata;
}
return cmd;
}
// ---------------------------------------------------------------------------
// composeAsyncCommands
// ---------------------------------------------------------------------------
/**
* Composes multiple `AsyncCommand<T>` instances into a single command.
* Execute applies them in order; undo applies them in reverse.
*/
export function composeAsyncCommands<T>(
label: string,
commands: AsyncCommand<T>[],
opts?: AsyncCommandFactoryOptions
): AsyncCommand<T> {
const clock = opts?.clock ?? defaultClock;
const idGen = opts?.idGenerator ?? defaultIdGenerator;
return {
id: idGen(),
label,
timestamp: clock(),
execute: async (state: T): Promise<T> => {
let s = state;
for (const cmd of commands) {
s = await executeAsyncCommand(s, cmd);
}
return s;
},
undo: async (state: T): Promise<T> => {
let s = state;
for (const cmd of [...commands].reverse()) {
s = await undoAsyncCommand(s, cmd);
}
return s;
},
};
}
// ---------------------------------------------------------------------------
// executeAsyncCommand / undoAsyncCommand
// ---------------------------------------------------------------------------
/**
* Executes an async command against the given state.
* Errors are wrapped in `UndoError(UNDO.COMMAND_FAILED)`.
*/
export async function executeAsyncCommand<T>(
state: T,
command: AsyncCommand<T>
): Promise<T> {
try {
return await command.execute(state);
} catch (err) {
if (err instanceof UndoError) throw err;
throw new UndoError(
UNDO_CODES.COMMAND_FAILED,
`Async command "${command.label}" execute failed`,
{ cause: err instanceof Error ? err : new Error(String(err)) }
);
}
}
/**
* Undoes an async command from the given state.
* Errors are wrapped in `UndoError(UNDO.COMMAND_FAILED)`.
*/
export async function undoAsyncCommand<T>(
state: T,
command: AsyncCommand<T>
): Promise<T> {
try {
return await command.undo(state);
} catch (err) {
if (err instanceof UndoError) throw err;
throw new UndoError(
UNDO_CODES.COMMAND_FAILED,
`Async command "${command.label}" undo failed`,
{ cause: err instanceof Error ? err : new Error(String(err)) }
);
}
}

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,368 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { UNDO_CODES } from "../errors/undo-error.js";
import { createAsyncCommand } from "./async-command.js";
import { AsyncUndoStack } from "./async-stack.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface Counter {
value: number;
}
const asyncInc = (n = 1) =>
createAsyncCommand<Counter>(
`+${n}`,
async (s) => ({ value: s.value + n }),
async (s) => ({ value: s.value - n })
);
const fakeEventBus = () => {
const listeners: Record<string, ((p: unknown) => void)[]> = {};
return {
on: (type: string, cb: (p: unknown) => void) => {
listeners[type] = [...(listeners[type] ?? []), cb];
return () => undefined;
},
emit: (type: string, payload: unknown) => {
for (const cb of listeners[type] ?? []) cb(payload);
},
listeners,
};
};
// ---------------------------------------------------------------------------
// Basic push/undo/redo
// ---------------------------------------------------------------------------
describe("AsyncUndoStack", () => {
it("starts with initial state", { timeout: 30000 }, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
expect(s.current.value).toBe(0);
});
it("push increments undoSize", { timeout: 30000 }, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
await s.push(asyncInc(1));
expect(s.undoSize).toBe(1);
});
it("push clears redo stack", { timeout: 30000 }, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
await s.push(asyncInc(1));
await s.undo();
expect(s.canRedo).toBe(true);
await s.push(asyncInc(2));
expect(s.canRedo).toBe(false);
});
it("undo reverses state", { timeout: 30000 }, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
await s.push(asyncInc(5));
await s.undo();
expect(s.current.value).toBe(0);
});
it("redo re-applies command", { timeout: 30000 }, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
await s.push(asyncInc(3));
await s.undo();
await s.redo();
expect(s.current.value).toBe(3);
});
it("throws EMPTY_STACK on undo when empty", {
timeout: 30000,
}, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
await expect(s.undo()).rejects.toMatchObject({
code: UNDO_CODES.EMPTY_STACK,
});
});
it("throws EMPTY_STACK on redo when empty", {
timeout: 30000,
}, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
await expect(s.redo()).rejects.toMatchObject({
code: UNDO_CODES.EMPTY_STACK,
});
});
it("round-trips push/undo/redo", { timeout: 30000 }, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
for (let i = 0; i < 5; i++) await s.push(asyncInc(1));
expect(s.current.value).toBe(5);
for (let i = 0; i < 5; i++) await s.undo();
expect(s.current.value).toBe(0);
for (let i = 0; i < 5; i++) await s.redo();
expect(s.current.value).toBe(5);
});
it("redoSize reflects redo stack depth", { timeout: 30000 }, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
await s.push(asyncInc(1));
await s.undo();
expect(s.redoSize).toBe(1);
});
it("activeBranchId is always main", { timeout: 30000 }, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
expect(s.activeBranchId).toBe("main");
});
it("canUndo is false initially and true after push", {
timeout: 30000,
}, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
expect(s.canUndo).toBe(false);
await s.push(asyncInc(1));
expect(s.canUndo).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Error wrapping
// ---------------------------------------------------------------------------
describe("error wrapping", () => {
it("wraps push failure in COMMAND_FAILED", { timeout: 30000 }, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
const failing = createAsyncCommand<Counter>(
"Fail",
async () => {
throw new Error("network error");
},
async (st) => st
);
await expect(s.push(failing)).rejects.toMatchObject({
code: UNDO_CODES.COMMAND_FAILED,
});
});
});
// ---------------------------------------------------------------------------
// Transaction
// ---------------------------------------------------------------------------
describe("transaction", () => {
it("commit applies all commands", { timeout: 30000 }, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
const tx = s.beginTransaction();
tx.add(asyncInc(10));
tx.add(asyncInc(5));
await tx.commit();
expect(s.current.value).toBe(15);
});
it("rollback discards pending", { timeout: 30000 }, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
const tx = s.beginTransaction();
tx.add(asyncInc(10));
await tx.rollback();
expect(s.current.value).toBe(0);
});
it("throws TRANSACTION_ACTIVE on nested open", {
timeout: 30000,
}, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
s.beginTransaction();
expect(() => s.beginTransaction()).toThrowError(
expect.objectContaining({ code: UNDO_CODES.TRANSACTION_ACTIVE })
);
});
it("throws TRANSACTION_INACTIVE after commit", {
timeout: 30000,
}, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
const tx = s.beginTransaction();
await tx.commit();
expect(() => tx.add(asyncInc(1))).toThrowError(
expect.objectContaining({ code: UNDO_CODES.TRANSACTION_INACTIVE })
);
});
it("throws TRANSACTION_INACTIVE on commit after rollback", {
timeout: 30000,
}, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
const tx = s.beginTransaction();
await tx.rollback();
await expect(tx.commit()).rejects.toMatchObject({
code: UNDO_CODES.TRANSACTION_INACTIVE,
});
});
});
// ---------------------------------------------------------------------------
// Event emission
// ---------------------------------------------------------------------------
describe("event emission", () => {
it("emits undo:push", { timeout: 30000 }, async () => {
const bus = fakeEventBus();
const s = new AsyncUndoStack<Counter>({ value: 0 }, {}, bus as never);
const events: unknown[] = [];
bus.on("undo:push", (p) => events.push(p));
const cmd = asyncInc(1);
await s.push(cmd);
expect(events).toHaveLength(1);
expect((events[0] as Record<string, unknown>)["commandId"]).toBe(
cmd.id
);
});
it("emits undo:undo", { timeout: 30000 }, async () => {
const bus = fakeEventBus();
const s = new AsyncUndoStack<Counter>({ value: 0 }, {}, bus as never);
const events: unknown[] = [];
bus.on("undo:undo", (p) => events.push(p));
await s.push(asyncInc(1));
await s.undo();
expect(events).toHaveLength(1);
});
it("emits undo:redo", { timeout: 30000 }, async () => {
const bus = fakeEventBus();
const s = new AsyncUndoStack<Counter>({ value: 0 }, {}, bus as never);
const events: unknown[] = [];
bus.on("undo:redo", (p) => events.push(p));
await s.push(asyncInc(1));
await s.undo();
await s.redo();
expect(events).toHaveLength(1);
});
it("emits undo:clear on clear", { timeout: 30000 }, async () => {
const bus = fakeEventBus();
const s = new AsyncUndoStack<Counter>({ value: 0 }, {}, bus as never);
const events: unknown[] = [];
bus.on("undo:clear", (p) => events.push(p));
s.clear();
expect(events).toHaveLength(1);
});
it("emits undo:clear on reset", { timeout: 30000 }, async () => {
const bus = fakeEventBus();
const s = new AsyncUndoStack<Counter>({ value: 0 }, {}, bus as never);
const events: unknown[] = [];
bus.on("undo:clear", (p) => events.push(p));
s.reset({ value: 99 });
expect(events).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// clear / reset / snapshot
// ---------------------------------------------------------------------------
describe("clear / reset / snapshot", () => {
it("clear keeps current but clears history", {
timeout: 30000,
}, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
await s.push(asyncInc(5));
s.clear();
expect(s.current.value).toBe(5);
expect(s.undoSize).toBe(0);
});
it("reset changes current and clears history", {
timeout: 30000,
}, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
await s.push(asyncInc(5));
s.reset({ value: 99 });
expect(s.current.value).toBe(99);
expect(s.undoSize).toBe(0);
});
it("snapshot returns correct shape", { timeout: 30000 }, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
await s.push(asyncInc(1));
await s.undo();
const snap = s.snapshot();
expect(snap.undoSize).toBe(0);
expect(snap.redoSize).toBe(1);
});
});
// ---------------------------------------------------------------------------
// maxEntries
// ---------------------------------------------------------------------------
describe("maxEntries", () => {
it("trims to maxEntries", { timeout: 30000 }, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 }, { maxEntries: 3 });
for (let i = 0; i < 5; i++) await s.push(asyncInc(1));
expect(s.undoSize).toBe(3);
});
it("calls onTruncate", { timeout: 30000 }, async () => {
const onTruncate = vi.fn();
const s = new AsyncUndoStack<Counter>(
{ value: 0 },
{ maxEntries: 2, onTruncate }
);
await s.push(asyncInc(1));
await s.push(asyncInc(1));
await s.push(asyncInc(1));
expect(onTruncate).toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// maxMemoryBytes
// ---------------------------------------------------------------------------
describe("maxMemoryBytes", () => {
it("trims entries exceeding memory limit", { timeout: 30000 }, async () => {
const s = new AsyncUndoStack<Counter>(
{ value: 0 },
{ maxMemoryBytes: 1 }
);
await s.push(asyncInc(1));
expect(s.undoSize).toBeLessThanOrEqual(1);
});
it("calls onTruncate when over memory limit", {
timeout: 30000,
}, async () => {
const onTruncate = vi.fn();
const s = new AsyncUndoStack<Counter>(
{ value: 0 },
{ maxMemoryBytes: 1, onTruncate }
);
await s.push(asyncInc(1));
expect(onTruncate).toHaveBeenCalled();
});
it("stops trimming once under memory limit (break branch)", {
timeout: 30000,
}, async () => {
// Each command is ~70-90 bytes; 200 bytes allows 2 but not 3.
const s = new AsyncUndoStack<Counter>(
{ value: 0 },
{ maxMemoryBytes: 200 }
);
await s.push(asyncInc(1));
await s.push(asyncInc(1));
await s.push(asyncInc(1));
expect(s.undoSize).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// metadata preservation
// ---------------------------------------------------------------------------
describe("metadata", () => {
it("preserves metadata on pushed commands", {
timeout: 30000,
}, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
const cmd = createAsyncCommand<Counter>(
"WithMeta",
async (st) => ({ value: st.value + 1 }),
async (st) => ({ value: st.value - 1 }),
{ source: "test" }
);
await s.push(cmd);
const entries = s.snapshot().undoStack;
expect(entries[0]?.command.metadata?.["source"]).toBe("test");
});
});

View File

@@ -0,0 +1,276 @@
import { metrics } from "@opentelemetry/api";
import type { EventBusLike } from "@suaveplan/types";
import { estimateCommandSize } from "../command/command.js";
import { UNDO_CODES, UndoError } from "../errors/undo-error.js";
import { UndoStackOptionsSchema } from "../schemas/undo.schemas.js";
import type {
AsyncCommand,
AsyncTransactionHandle,
StackEntry,
UndoEventMap,
UndoStackOptions,
UndoStackState,
} from "../types/undo.types.js";
import { executeAsyncCommand } from "./async-command.js";
// ---------------------------------------------------------------------------
// OTel meter
// ---------------------------------------------------------------------------
const meter = metrics.getMeter("undo");
const pushCounter = meter.createCounter("undo.push.total");
const undoCounter = meter.createCounter("undo.undo.total");
const redoCounter = meter.createCounter("undo.redo.total");
const depthHistogram = meter.createHistogram("undo.stack.depth");
const OTEL_ATTRS = { stack_type: "async" };
// ---------------------------------------------------------------------------
// AsyncUndoStack
// ---------------------------------------------------------------------------
/**
* An asynchronous undo/redo stack where `push`, `undo`, and `redo` return
* `Promise<T>`. Async command failures are wrapped in `UndoError(UNDO.COMMAND_FAILED)`.
*/
export class AsyncUndoStack<T> {
protected _stack: StackEntry<T>[] = [];
protected _redoStack: StackEntry<T>[] = [];
protected _current: T;
protected _options: UndoStackOptions;
protected _bus: EventBusLike<UndoEventMap> | undefined;
protected _txActive = false;
protected _txEntries: AsyncCommand<T>[] = [];
public constructor(
initial: T,
options?: UndoStackOptions,
bus?: EventBusLike<UndoEventMap>
) {
const parsed = UndoStackOptionsSchema.parse(options ?? {});
this._options = parsed as UndoStackOptions;
this._current = initial;
this._bus = bus;
}
// ---------------------------------------------------------------------------
// Getters
// ---------------------------------------------------------------------------
public get current(): T {
return this._current;
}
public get canUndo(): boolean {
return this._stack.length > 0;
}
public get canRedo(): boolean {
return this._redoStack.length > 0;
}
public get undoSize(): number {
return this._stack.length;
}
public get redoSize(): number {
return this._redoStack.length;
}
public get activeBranchId(): string {
return "main";
}
// ---------------------------------------------------------------------------
// push
// ---------------------------------------------------------------------------
/**
* Executes an async command and pushes it onto the stack.
* Clears the redo stack. Wraps command failures in `UndoError(UNDO.COMMAND_FAILED)`.
*/
public async push(command: AsyncCommand<T>): Promise<T> {
const stateBefore = this._current;
// executeAsyncCommand wraps all errors in UndoError(COMMAND_FAILED).
this._current = await executeAsyncCommand(this._current, command);
// Build a sync-compatible entry for storage
const capturedCurrent = this._current;
const syncCmd: import("../types/undo.types.js").Command<T> = {
id: command.id,
label: command.label,
timestamp: command.timestamp,
execute: () => capturedCurrent,
undo: () => stateBefore,
};
if (command.metadata !== undefined) {
(syncCmd as { metadata?: Record<string, unknown> }).metadata =
command.metadata;
}
this._stack.push({ command: syncCmd, stateBefore });
this._redoStack = [];
this._trim();
pushCounter.add(1, OTEL_ATTRS);
depthHistogram.record(this._stack.length, OTEL_ATTRS);
this._bus?.emit("undo:push", {
commandId: command.id,
label: command.label,
undoSize: this._stack.length,
});
return this._current;
}
// ---------------------------------------------------------------------------
// undo
// ---------------------------------------------------------------------------
/**
* Undoes the most recent command asynchronously.
* Throws `UndoError(UNDO.EMPTY_STACK)` when nothing to undo.
*/
public async undo(): Promise<T> {
const entry = this._stack.pop();
if (!entry) {
throw new UndoError(UNDO_CODES.EMPTY_STACK, "Nothing to undo");
}
this._current = entry.command.undo(this._current);
this._redoStack.push(entry);
undoCounter.add(1, OTEL_ATTRS);
depthHistogram.record(this._stack.length, OTEL_ATTRS);
this._bus?.emit("undo:undo", {
commandId: entry.command.id,
label: entry.command.label,
undoSize: this._stack.length,
});
return this._current;
}
// ---------------------------------------------------------------------------
// redo
// ---------------------------------------------------------------------------
/**
* Re-applies the most recently undone command asynchronously.
* Throws `UndoError(UNDO.EMPTY_STACK)` when nothing to redo.
*/
public async redo(): Promise<T> {
const entry = this._redoStack.pop();
if (!entry) {
throw new UndoError(UNDO_CODES.EMPTY_STACK, "Nothing to redo");
}
this._current = entry.command.execute(this._current);
this._stack.push(entry);
redoCounter.add(1, OTEL_ATTRS);
depthHistogram.record(this._stack.length, OTEL_ATTRS);
this._bus?.emit("undo:redo", {
commandId: entry.command.id,
label: entry.command.label,
redoSize: this._redoStack.length,
});
return this._current;
}
// ---------------------------------------------------------------------------
// Transaction
// ---------------------------------------------------------------------------
/**
* Begins an async transaction. Returns an `AsyncTransactionHandle`.
* Throws `UndoError(UNDO.TRANSACTION_ACTIVE)` if one is already open.
*/
public beginTransaction(): AsyncTransactionHandle<T> {
if (this._txActive) {
throw new UndoError(
UNDO_CODES.TRANSACTION_ACTIVE,
"A transaction is already active"
);
}
this._txActive = true;
this._txEntries = [];
let closed = false;
const guard = (): void => {
if (closed) {
throw new UndoError(
UNDO_CODES.TRANSACTION_INACTIVE,
"Transaction is already closed"
);
}
};
return {
add: (command: AsyncCommand<T>) => {
guard();
this._txEntries.push(command);
},
commit: async (): Promise<void> => {
guard();
closed = true;
this._txActive = false;
for (const cmd of this._txEntries) {
await this.push(cmd);
}
this._txEntries = [];
},
rollback: async (): Promise<void> => {
guard();
closed = true;
this._txActive = false;
this._txEntries = [];
},
};
}
// ---------------------------------------------------------------------------
// clear / reset / snapshot
// ---------------------------------------------------------------------------
/** Clears all history, keeps current state. */
public clear(): void {
this._stack = [];
this._redoStack = [];
this._bus?.emit("undo:clear", {});
}
/** Resets to new initial state, clears history. */
public reset(newInitial: T): void {
this._current = newInitial;
this._stack = [];
this._redoStack = [];
this._bus?.emit("undo:clear", {});
}
/** Returns a serializable snapshot. */
public snapshot(): UndoStackState<T> {
return {
current: this._current,
undoStack: [...this._stack],
redoStack: [...this._redoStack],
undoSize: this._stack.length,
redoSize: this._redoStack.length,
};
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
protected _trim(): void {
const { maxEntries, maxMemoryBytes, onTruncate } = this._options;
const removed: StackEntry<unknown>[] = [];
if (maxEntries !== undefined) {
while (this._stack.length > maxEntries) {
removed.push(this._stack.shift()! as StackEntry<unknown>);
}
}
if (maxMemoryBytes !== undefined) {
while (this._stack.length > 0) {
const total = this._stack.reduce(
(acc, e) => acc + estimateCommandSize(e.command),
0
);
if (total <= maxMemoryBytes) break;
removed.push(this._stack.shift()! as StackEntry<unknown>);
}
}
if (removed.length > 0 && onTruncate) {
onTruncate(removed);
}
}
}

View File

@@ -0,0 +1,8 @@
export type { AsyncCommandFactoryOptions } from "./async-command.js";
export {
composeAsyncCommands,
createAsyncCommand,
executeAsyncCommand,
undoAsyncCommand,
} from "./async-command.js";
export { AsyncUndoStack } from "./async-stack.js";

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,364 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { createCommand } from "../command/command.js";
import { UNDO_CODES } from "../errors/undo-error.js";
import { BranchingHistory } from "./branch.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface Counter {
value: number;
}
let seq = 0;
const _testClock = () => ++seq;
const _makeIdGen = () => {
let n = 0;
return () => `id-${++n}`;
};
const inc = (n = 1) =>
createCommand<Counter>(
`+${n}`,
(s) => ({ value: s.value + n }),
(s) => ({ value: s.value - n })
);
// ---------------------------------------------------------------------------
// Linear undo/redo
// ---------------------------------------------------------------------------
describe("BranchingHistory", () => {
it("starts with initial state", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
expect(h.current.value).toBe(0);
});
it("push applies command", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
h.push(inc(5));
expect(h.current.value).toBe(5);
});
it("undo reverses command", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
h.push(inc(5));
h.undo();
expect(h.current.value).toBe(0);
});
it("redo re-applies command", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
h.push(inc(5));
h.undo();
h.redo();
expect(h.current.value).toBe(5);
});
it("canUndo is false initially", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
expect(h.canUndo).toBe(false);
});
it("canRedo is false initially", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
expect(h.canRedo).toBe(false);
});
it("throws EMPTY_STACK on undo when nothing to undo", {
timeout: 5000,
}, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
expect(() => h.undo()).toThrowError(
expect.objectContaining({ code: UNDO_CODES.EMPTY_STACK })
);
});
it("throws EMPTY_STACK on redo when nothing to redo", {
timeout: 5000,
}, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
expect(() => h.redo()).toThrowError(
expect.objectContaining({ code: UNDO_CODES.EMPTY_STACK })
);
});
});
// ---------------------------------------------------------------------------
// pushState
// ---------------------------------------------------------------------------
describe("pushState", () => {
it("sets current state", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
h.pushState({ value: 77 });
expect(h.current.value).toBe(77);
});
it("can be undone", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
h.pushState({ value: 77 });
h.undo();
expect(h.current.value).toBe(0);
});
});
// ---------------------------------------------------------------------------
// Branch on diverge
// ---------------------------------------------------------------------------
describe("branchOnDiverge", () => {
it("creates a new branch when pushing with redo entries", {
timeout: 5000,
}, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
h.push(inc(1));
h.undo();
expect(h.canRedo).toBe(true);
const before = h.branches.length;
h.push(inc(2)); // diverges — fork
expect(h.branches.length).toBeGreaterThan(before);
});
it("does NOT fork when branchOnDiverge is false", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>(
{ value: 0 },
{ branchOnDiverge: false }
);
h.push(inc(1));
h.undo();
const before = h.branches.length;
h.push(inc(2));
expect(h.branches.length).toBe(before); // no fork
});
it("forked branch preserves redo entries", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
h.push(inc(1)); // +1
h.push(inc(2)); // +3
h.undo(); // redo has +2
h.push(inc(5)); // diverge → fork branch retains +2 redo
const forked = h.branches.find((b) => b.label.startsWith("fork-"));
expect(forked).toBeDefined();
expect(forked?.redoStack.length).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// switchBranch
// ---------------------------------------------------------------------------
describe("switchBranch", () => {
it("switches to another branch and returns its current", {
timeout: 5000,
}, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
h.push(inc(1));
h.undo();
h.push(inc(9)); // fork
const forked = h.branches.find((b) => b.label.startsWith("fork-"));
expect(forked).toBeDefined();
const forkedId = forked!.id;
const result = h.switchBranch(forkedId);
expect(result).toBeDefined(); // returns branch current
expect(h.activeBranchId).toBe(forkedId);
});
it("throws AT_BRANCH_ROOT for unknown branchId", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
expect(() => h.switchBranch("nonexistent")).toThrowError(
expect.objectContaining({ code: UNDO_CODES.AT_BRANCH_ROOT })
);
});
it("includes branchId in error context", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
try {
h.switchBranch("missing-branch");
} catch (err: unknown) {
const ctx = (err as { context?: Record<string, unknown> }).context;
expect(ctx?.["branchId"]).toBe("missing-branch");
}
});
});
// ---------------------------------------------------------------------------
// getBranch
// ---------------------------------------------------------------------------
describe("getBranch", () => {
it("returns the branch for a valid id", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
const b = h.getBranch(h.activeBranchId);
expect(b).toBeDefined();
expect(b?.id).toBe(h.activeBranchId);
});
it("returns undefined for unknown id", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
expect(h.getBranch("no-such-branch")).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// deleteBranch
// ---------------------------------------------------------------------------
describe("deleteBranch", () => {
it("removes a non-active branch", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
h.push(inc(1));
h.undo();
h.push(inc(2)); // fork
const forked = h.branches.find((b) => b.label.startsWith("fork-"));
expect(forked).toBeDefined();
h.deleteBranch(forked!.id);
expect(h.getBranch(forked!.id)).toBeUndefined();
});
it("throws AT_BRANCH_ROOT when deleting active branch", {
timeout: 5000,
}, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
expect(() => h.deleteBranch(h.activeBranchId)).toThrowError(
expect.objectContaining({ code: UNDO_CODES.AT_BRANCH_ROOT })
);
});
});
// ---------------------------------------------------------------------------
// Transaction
// ---------------------------------------------------------------------------
describe("transaction", () => {
it("commit applies all commands", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
const tx = h.beginTransaction();
tx.add(inc(10));
tx.add(inc(5));
tx.commit();
expect(h.current.value).toBe(15);
});
it("rollback discards pending", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
const tx = h.beginTransaction();
tx.add(inc(10));
tx.rollback();
expect(h.current.value).toBe(0);
});
it("throws TRANSACTION_ACTIVE on nested open", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
h.beginTransaction();
expect(() => h.beginTransaction()).toThrowError(
expect.objectContaining({ code: UNDO_CODES.TRANSACTION_ACTIVE })
);
});
it("throws TRANSACTION_INACTIVE after commit", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
const tx = h.beginTransaction();
tx.commit();
expect(() => tx.add(inc(1))).toThrowError(
expect.objectContaining({ code: UNDO_CODES.TRANSACTION_INACTIVE })
);
});
});
// ---------------------------------------------------------------------------
// Clock injection
// ---------------------------------------------------------------------------
describe("clock injection", () => {
it("uses injected clock for createdAt", { timeout: 5000 }, () => {
let t = 5000;
const clock = () => t++;
const h = new BranchingHistory<Counter>({ value: 0 }, {}, clock);
const branch = h.getBranch(h.activeBranchId);
expect(branch?.createdAt).toBeGreaterThanOrEqual(5000);
});
});
// ---------------------------------------------------------------------------
// snapshot
// ---------------------------------------------------------------------------
describe("snapshot", () => {
it("returns current and activeBranchId", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>({ value: 7 });
const snap = h.snapshot();
expect(snap.current.value).toBe(7);
expect(snap.activeBranchId).toBe(h.activeBranchId);
});
it("snapshot branches is independent copy", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
const snap = h.snapshot();
const ids = Object.keys(snap.branches);
expect(ids.length).toBeGreaterThanOrEqual(1);
});
});
// ---------------------------------------------------------------------------
// Property: branch isolation
// ---------------------------------------------------------------------------
describe("property: branch isolation", () => {
it("mutating one branch does not affect another", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.array(fc.integer({ min: 1, max: 5 }), {
minLength: 1,
maxLength: 10,
}),
fc.array(fc.integer({ min: 1, max: 5 }), {
minLength: 1,
maxLength: 5,
}),
(mainIncs, forkIncs) => {
const h = new BranchingHistory<Counter>({ value: 0 });
for (const n of mainIncs) h.push(inc(n));
// Undo one to set up diverge
h.undo();
const _mainStateBeforeDiverge = h.current.value;
// Push on new path — forks
h.push(inc(99));
const forked = h.branches.find((b) =>
b.label.startsWith("fork-")
);
if (!forked) return true; // nothing to test
// Main branch state
const mainBranchState = h.current.value;
// Switch to forked, push more
h.switchBranch(forked.id);
for (const n of forkIncs) h.push(inc(n));
// Switch back to main — state unchanged
const mainId = h.branches.find(
(b) => b.label === "main"
)?.id;
if (!mainId) return true;
h.switchBranch(mainId);
return h.current.value === mainBranchState;
}
),
{ numRuns: 50 }
);
});
});
// ---------------------------------------------------------------------------
// maxEntries (branch trimming)
// ---------------------------------------------------------------------------
describe("maxEntries (branch)", () => {
it("truncates branch undo stack to maxEntries", { timeout: 5000 }, () => {
const h = new BranchingHistory<Counter>(
{ value: 0 },
{ maxEntries: 3 }
);
for (let i = 0; i < 5; i++) h.push(inc(1));
const branch = h.getBranch(h.activeBranchId);
expect(branch?.undoStack.length).toBeLessThanOrEqual(3);
});
});

View File

@@ -0,0 +1,319 @@
import { metrics } from "@opentelemetry/api";
import type { Clock, IdGenerator } from "@suaveplan/types";
import { executeCommand, undoCommand } from "../command/command.js";
import { UNDO_CODES, UndoError } from "../errors/undo-error.js";
import { BranchingOptionsSchema } from "../schemas/undo.schemas.js";
import type {
Branch,
BranchingOptions,
BranchingState,
Command,
TransactionHandle,
} from "../types/undo.types.js";
// ---------------------------------------------------------------------------
// OTel meter
// ---------------------------------------------------------------------------
const meter = metrics.getMeter("undo");
const pushCounter = meter.createCounter("undo.push.total");
const undoCounter = meter.createCounter("undo.undo.total");
const redoCounter = meter.createCounter("undo.redo.total");
const depthHistogram = meter.createHistogram("undo.stack.depth");
const OTEL_ATTRS = { stack_type: "branching" };
// ---------------------------------------------------------------------------
// BranchingHistory
// ---------------------------------------------------------------------------
/**
* A history manager that forks new branches when a push diverges from the
* current redo stack (when `branchOnDiverge: true`), preserving alternate
* timelines for exploration or comparison.
*/
export class BranchingHistory<T> {
protected _branches: Map<string, Branch<T>>;
protected _activeBranchId: string;
protected _clock: Clock;
protected _idGen: IdGenerator;
protected _options: BranchingOptions;
protected _txActive = false;
protected _txEntries: Command<T>[] = [];
public constructor(
initial: T,
options?: BranchingOptions,
clock?: Clock,
idGenerator?: IdGenerator
) {
const parsed = BranchingOptionsSchema.parse(options ?? {});
this._options = parsed as BranchingOptions;
this._clock = clock ?? (parsed.clock as Clock | undefined) ?? Date.now;
this._idGen =
idGenerator ??
(parsed.idGenerator as IdGenerator | undefined) ??
(() => crypto.randomUUID());
const initialId = this._idGen();
const initialBranch: Branch<T> = {
id: initialId,
label: parsed.initialBranchLabel ?? "main",
createdAt: this._clock(),
undoStack: [],
redoStack: [],
current: initial,
};
this._branches = new Map([[initialId, initialBranch]]);
this._activeBranchId = initialId;
}
// ---------------------------------------------------------------------------
// Getters
// ---------------------------------------------------------------------------
public get current(): T {
return this._activeBranch.current;
}
public get canUndo(): boolean {
return this._activeBranch.undoStack.length > 0;
}
public get canRedo(): boolean {
return this._activeBranch.redoStack.length > 0;
}
public get activeBranchId(): string {
return this._activeBranchId;
}
public get branches(): Branch<T>[] {
return Array.from(this._branches.values());
}
protected get _activeBranch(): Branch<T> {
return this._branches.get(this._activeBranchId)!;
}
// ---------------------------------------------------------------------------
// push
// ---------------------------------------------------------------------------
/**
* Pushes a command and executes it on the active branch.
* When `branchOnDiverge` is true and the redo stack is non-empty,
* forks the redo entries to a new branch before discarding them.
*/
public push(command: Command<T>): T {
const branch = this._activeBranch;
if (branch.redoStack.length > 0) {
if (this._options.branchOnDiverge !== false) {
this._forkRedoEntries(branch);
}
branch.redoStack.splice(0, branch.redoStack.length);
}
const stateBefore = branch.current;
branch.current = executeCommand(branch.current, command);
branch.undoStack.push({ command, stateBefore });
this._trimBranch(branch);
pushCounter.add(1, OTEL_ATTRS);
depthHistogram.record(branch.undoStack.length, OTEL_ATTRS);
return branch.current;
}
/**
* Pushes a state snapshot directly on the active branch.
*/
public pushState(newState: T): T {
const prev = this._activeBranch.current;
const cmd: Command<T> = {
id: this._idGen(),
label: "pushState",
timestamp: this._clock(),
execute: () => newState,
undo: () => prev,
};
return this.push(cmd);
}
// ---------------------------------------------------------------------------
// undo
// ---------------------------------------------------------------------------
/**
* Undoes the most recent command on the active branch.
* Throws `UndoError(UNDO.EMPTY_STACK)` if nothing to undo.
*/
public undo(): T {
const branch = this._activeBranch;
const entry = branch.undoStack.pop();
if (!entry) {
throw new UndoError(UNDO_CODES.EMPTY_STACK, "Nothing to undo");
}
branch.current = undoCommand(branch.current, entry.command);
branch.redoStack.push(entry);
undoCounter.add(1, OTEL_ATTRS);
depthHistogram.record(branch.undoStack.length, OTEL_ATTRS);
return branch.current;
}
// ---------------------------------------------------------------------------
// redo
// ---------------------------------------------------------------------------
/**
* Re-applies the most recently undone command on the active branch.
* Throws `UndoError(UNDO.EMPTY_STACK)` if nothing to redo.
*/
public redo(): T {
const branch = this._activeBranch;
const entry = branch.redoStack.pop();
if (!entry) {
throw new UndoError(UNDO_CODES.EMPTY_STACK, "Nothing to redo");
}
branch.current = executeCommand(branch.current, entry.command);
branch.undoStack.push(entry);
redoCounter.add(1, OTEL_ATTRS);
depthHistogram.record(branch.undoStack.length, OTEL_ATTRS);
return branch.current;
}
// ---------------------------------------------------------------------------
// switchBranch
// ---------------------------------------------------------------------------
/**
* Switches the active branch to `branchId`.
* Throws `UndoError(UNDO.AT_BRANCH_ROOT)` if the branch does not exist.
*/
public switchBranch(branchId: string): T {
if (!this._branches.has(branchId)) {
throw new UndoError(
UNDO_CODES.AT_BRANCH_ROOT,
`Branch "${branchId}" does not exist`,
{ context: { branchId } }
);
}
this._activeBranchId = branchId;
return this._activeBranch.current;
}
// ---------------------------------------------------------------------------
// getBranch
// ---------------------------------------------------------------------------
/** Returns the branch with the given ID, or `undefined` if not found. */
public getBranch(branchId: string): Branch<T> | undefined {
return this._branches.get(branchId);
}
// ---------------------------------------------------------------------------
// deleteBranch
// ---------------------------------------------------------------------------
/**
* Deletes a branch. Throws `UndoError(UNDO.AT_BRANCH_ROOT)` if attempting
* to delete the active branch.
*/
public deleteBranch(branchId: string): void {
if (branchId === this._activeBranchId) {
throw new UndoError(
UNDO_CODES.AT_BRANCH_ROOT,
"Cannot delete the active branch",
{ context: { branchId } }
);
}
this._branches.delete(branchId);
}
// ---------------------------------------------------------------------------
// Transaction
// ---------------------------------------------------------------------------
/**
* Begins a transaction on the active branch.
* Throws `UndoError(UNDO.TRANSACTION_ACTIVE)` if one is already open.
*/
public beginTransaction(): TransactionHandle<T> {
if (this._txActive) {
throw new UndoError(
UNDO_CODES.TRANSACTION_ACTIVE,
"A transaction is already active"
);
}
this._txActive = true;
this._txEntries = [];
let closed = false;
const guard = (): void => {
if (closed) {
throw new UndoError(
UNDO_CODES.TRANSACTION_INACTIVE,
"Transaction is already closed"
);
}
};
return {
add: (cmd: Command<T>) => {
guard();
this._txEntries.push(cmd);
},
commit: () => {
guard();
closed = true;
this._txActive = false;
for (const cmd of this._txEntries) this.push(cmd);
this._txEntries = [];
},
rollback: () => {
guard();
closed = true;
this._txActive = false;
this._txEntries = [];
},
};
}
// ---------------------------------------------------------------------------
// snapshot
// ---------------------------------------------------------------------------
/** Returns a serializable snapshot of all branches and the current state. */
public snapshot(): BranchingState<T> {
const branchesRecord: Record<string, Branch<T>> = {};
for (const [id, b] of this._branches) {
branchesRecord[id] = {
...b,
undoStack: [...b.undoStack],
redoStack: [...b.redoStack],
};
}
return {
current: this.current,
activeBranchId: this._activeBranchId,
branches: branchesRecord,
};
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
protected _forkRedoEntries(branch: Branch<T>): void {
const newId = this._idGen();
const forkedBranch: Branch<T> = {
id: newId,
label: `fork-from-${branch.id}`,
createdAt: this._clock(),
undoStack: [...branch.undoStack],
redoStack: [...branch.redoStack],
current: branch.current,
};
this._branches.set(newId, forkedBranch);
}
protected _trimBranch(branch: Branch<T>): void {
const max = this._options.maxEntries;
if (max === undefined) return;
while (branch.undoStack.length > max) {
branch.undoStack.shift();
}
}
}

View File

@@ -0,0 +1 @@
export { BranchingHistory } from "./branch.js";

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,336 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import {
composeCommands,
createCommand,
createPatchCommand,
estimateCommandSize,
executeCommand,
undoCommand,
} from "./command.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
let counter = 0;
const testClock = () => ++counter;
const testIdGen = () => `id-${counter}`;
const opts = { clock: testClock, idGenerator: testIdGen };
interface Counter {
value: number;
}
interface Doc {
title: string;
body: string;
}
// ---------------------------------------------------------------------------
// createCommand
// ---------------------------------------------------------------------------
describe("createCommand", () => {
it("sets a unique id", { timeout: 5000 }, () => {
const a = createCommand<Counter>(
"A",
(s) => s,
(s) => s,
undefined,
opts
);
const b = createCommand<Counter>(
"B",
(s) => s,
(s) => s,
undefined,
opts
);
expect(a.id).not.toBe(b.id);
});
it("sets timestamp from clock", { timeout: 5000 }, () => {
const t = 100;
const c = createCommand<Counter>(
"X",
(s) => s,
(s) => s,
undefined,
{
clock: () => t,
idGenerator: () => "id",
}
);
expect(c.timestamp).toBe(100);
});
it("preserves label", { timeout: 5000 }, () => {
const c = createCommand<Counter>(
"My label",
(s) => s,
(s) => s
);
expect(c.label).toBe("My label");
});
it("preserves metadata", { timeout: 5000 }, () => {
const c = createCommand<Counter>(
"L",
(s) => s,
(s) => s,
{ source: "test" }
);
expect(c.metadata?.["source"]).toBe("test");
});
it("defaults clock to Date.now", { timeout: 5000 }, () => {
const before = Date.now();
const c = createCommand<Counter>(
"L",
(s) => s,
(s) => s
);
const after = Date.now();
expect(c.timestamp).toBeGreaterThanOrEqual(before);
expect(c.timestamp).toBeLessThanOrEqual(after);
});
});
// ---------------------------------------------------------------------------
// composeCommands
// ---------------------------------------------------------------------------
describe("composeCommands", () => {
it("executes commands in order", { timeout: 5000 }, () => {
const add1 = createCommand<Counter>(
"Add1",
(s) => ({ value: s.value + 1 }),
(s) => ({ value: s.value - 1 })
);
const mul2 = createCommand<Counter>(
"Mul2",
(s) => ({ value: s.value * 2 }),
(s) => ({ value: s.value / 2 })
);
const composed = composeCommands("Both", [add1, mul2]);
const result = executeCommand({ value: 3 }, composed);
// (3+1)*2 = 8
expect(result.value).toBe(8);
});
it("undoes commands in reverse order", { timeout: 5000 }, () => {
const add1 = createCommand<Counter>(
"Add1",
(s) => ({ value: s.value + 1 }),
(s) => ({ value: s.value - 1 })
);
const mul2 = createCommand<Counter>(
"Mul2",
(s) => ({ value: s.value * 2 }),
(s) => ({ value: s.value / 2 })
);
const composed = composeCommands("Both", [add1, mul2]);
const initial: Counter = { value: 3 };
const after = executeCommand(initial, composed);
// (3+1)*2 = 8; undo: 8/2 = 4, 4-1 = 3
const restored = undoCommand(after, composed);
expect(restored.value).toBe(3);
});
it("handles empty command array", { timeout: 5000 }, () => {
const noop = composeCommands<Counter>("Noop", []);
const s = { value: 42 };
expect(executeCommand(s, noop).value).toBe(42);
expect(undoCommand(s, noop).value).toBe(42);
});
it("creates a new unique id", { timeout: 5000 }, () => {
const cmd = composeCommands<Counter>("C", [], opts);
expect(cmd.id).toBeDefined();
expect(cmd.label).toBe("C");
});
});
// ---------------------------------------------------------------------------
// executeCommand / undoCommand (closure)
// ---------------------------------------------------------------------------
describe("executeCommand / undoCommand (closure)", () => {
it("applies execute to state", { timeout: 5000 }, () => {
const cmd = createCommand<Counter>(
"Inc",
(s) => ({ value: s.value + 5 }),
(s) => ({ value: s.value - 5 })
);
expect(executeCommand({ value: 10 }, cmd).value).toBe(15);
});
it("applies undo to state", { timeout: 5000 }, () => {
const cmd = createCommand<Counter>(
"Inc",
(s) => ({ value: s.value + 5 }),
(s) => ({ value: s.value - 5 })
);
expect(undoCommand({ value: 15 }, cmd).value).toBe(10);
});
it("round-trips execute then undo", { timeout: 5000 }, () => {
const cmd = createCommand<Counter>(
"Inc",
(s) => ({ value: s.value + 1 }),
(s) => ({ value: s.value - 1 })
);
const initial: Counter = { value: 7 };
const after = executeCommand(initial, cmd);
const restored = undoCommand(after, cmd);
expect(restored.value).toBe(7);
});
});
// ---------------------------------------------------------------------------
// createPatchCommand
// ---------------------------------------------------------------------------
describe("createPatchCommand", () => {
it("creates a PatchCommand with type=patch", { timeout: 5000 }, () => {
const cmd = createPatchCommand<Doc>(
"Edit",
{ title: "A", body: "" },
{ title: "B", body: "" }
);
expect(cmd.type).toBe("patch");
});
it("executes to produce new state", { timeout: 5000 }, () => {
const old: Doc = { title: "Draft", body: "" };
const newDoc: Doc = { title: "Final", body: "Hello" };
const cmd = createPatchCommand<Doc>("Edit", old, newDoc);
const result = executeCommand(old, cmd);
expect(result).toEqual(newDoc);
});
it("undoes to restore old state", { timeout: 5000 }, () => {
const old: Doc = { title: "Draft", body: "" };
const newDoc: Doc = { title: "Final", body: "Hello" };
const cmd = createPatchCommand<Doc>("Edit", old, newDoc);
const after = executeCommand(old, cmd);
const restored = undoCommand(after, cmd);
expect(restored).toEqual(old);
});
it("round-trips for complex objects", { timeout: 5000 }, () => {
const before = { a: 1, b: [1, 2, 3], c: { d: true } };
const after = { a: 2, b: [1, 2, 4], c: { d: false } };
const cmd = createPatchCommand("Change", before, after);
const result = executeCommand(before, cmd);
expect(result).toEqual(after);
const back = undoCommand(result, cmd);
expect(back).toEqual(before);
});
it("accepts metadata", { timeout: 5000 }, () => {
const cmd = createPatchCommand(
"L",
{ x: 1 },
{ x: 2 },
{ author: "test" }
);
expect(cmd.metadata?.["author"]).toBe("test");
});
it("uses injected clock and idGenerator", { timeout: 5000 }, () => {
const cmd = createPatchCommand("L", { x: 1 }, { x: 2 }, undefined, {
clock: () => 9999,
idGenerator: () => "my-id",
});
expect(cmd.id).toBe("my-id");
expect(cmd.timestamp).toBe(9999);
});
});
// ---------------------------------------------------------------------------
// estimateCommandSize
// ---------------------------------------------------------------------------
describe("estimateCommandSize", () => {
it("returns a positive number for closure command", {
timeout: 5000,
}, () => {
const cmd = createCommand<Counter>(
"Inc",
(s) => s,
(s) => s
);
expect(estimateCommandSize(cmd)).toBeGreaterThan(0);
});
it("returns a positive number for patch command", { timeout: 5000 }, () => {
const cmd = createPatchCommand("Edit", { x: 1 }, { x: 2 });
expect(estimateCommandSize(cmd)).toBeGreaterThan(0);
});
it("patch command size >= closure command size", { timeout: 5000 }, () => {
const closure = createCommand<Counter>(
"X",
(s) => s,
(s) => s
);
// Large before/after states generate a substantial patch payload
const before = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7, h: 8 };
const after = {
a: 10,
b: 20,
c: 30,
d: 40,
e: 50,
f: 60,
g: 70,
h: 80,
};
const patch = createPatchCommand("X", before, after);
expect(estimateCommandSize(patch)).toBeGreaterThan(
estimateCommandSize(closure)
);
});
it("falls back to id+label length when metadata is not serializable", {
timeout: 5000,
}, () => {
const cmd = createCommand<Counter>(
"Label",
(s) => s,
(s) => s,
// circular reference — JSON.stringify will throw
(() => {
const obj: Record<string, unknown> = {};
obj["self"] = obj;
return obj;
})()
);
// Should not throw, returns fallback size
expect(estimateCommandSize(cmd)).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// Property-based tests
// ---------------------------------------------------------------------------
describe("property: execute then undo restores state (patch command)", () => {
it("round-trip for any JSON-serializable state", { timeout: 30000 }, () => {
fc.assert(
fc.property(
fc.record({
a: fc.integer(),
b: fc.string(),
c: fc.boolean(),
}),
fc.record({
a: fc.integer(),
b: fc.string(),
c: fc.boolean(),
}),
(initial, next) => {
const cmd = createPatchCommand("P", initial, next);
const after = executeCommand(initial, cmd);
const back = undoCommand(after, cmd);
return JSON.stringify(back) === JSON.stringify(initial);
}
),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,208 @@
import { applyPatch, diff, invertPatch } from "@suaveplan/diff";
import type { Clock, IdGenerator } from "@suaveplan/types";
import type { Command, PatchCommand } from "../types/undo.types.js";
import { isPatchCommand } from "../types/undo.types.js";
// ---------------------------------------------------------------------------
// Internal defaults
// ---------------------------------------------------------------------------
const defaultClock: Clock = () => Date.now();
const defaultIdGenerator: IdGenerator = () => crypto.randomUUID();
// ---------------------------------------------------------------------------
// Factory options
// ---------------------------------------------------------------------------
export interface CommandFactoryOptions {
clock?: Clock;
idGenerator?: IdGenerator;
}
// ---------------------------------------------------------------------------
// createCommand
// ---------------------------------------------------------------------------
/**
* Creates a new synchronous `Command<T>` backed by user-supplied closures.
*
* @param label - Human-readable description of the operation.
* @param executeFn - Function that applies the command to the current state.
* @param undoFn - Function that reverses the command from the resulting state.
* @param metadata - Optional arbitrary metadata to attach.
* @param opts - Optional injectable `clock` and `idGenerator`.
*/
export function createCommand<T>(
label: string,
executeFn: (state: T) => T,
undoFn: (state: T) => T,
metadata?: Record<string, unknown>,
opts?: CommandFactoryOptions
): Command<T> {
const clock = opts?.clock ?? defaultClock;
const idGen = opts?.idGenerator ?? defaultIdGenerator;
const cmd: Command<T> = {
id: idGen(),
label,
timestamp: clock(),
execute: executeFn,
undo: undoFn,
};
if (metadata !== undefined) {
(cmd as { metadata?: Record<string, unknown> }).metadata = metadata;
}
return cmd;
}
// ---------------------------------------------------------------------------
// composeCommands
// ---------------------------------------------------------------------------
/**
* Combines multiple commands into a single composite command.
* On execute, applies them in order; on undo, applies them in reverse.
*/
export function composeCommands<T>(
label: string,
commands: Command<T>[],
opts?: CommandFactoryOptions
): Command<T> {
const clock = opts?.clock ?? defaultClock;
const idGen = opts?.idGenerator ?? defaultIdGenerator;
return {
id: idGen(),
label,
timestamp: clock(),
execute: (state: T): T =>
commands.reduce((s, cmd) => executeCommand(s, cmd), state),
undo: (state: T): T =>
[...commands]
.reverse()
.reduce((s, cmd) => undoCommand(s, cmd), state),
};
}
// ---------------------------------------------------------------------------
// executeCommand / undoCommand
// ---------------------------------------------------------------------------
/**
* Executes a command against the given state.
* Delegates to `command.execute(state)`, which for `PatchCommand` instances
* applies the forward patch via `applyPatch` from `@suaveplan/diff`.
*/
export function executeCommand<T>(state: T, command: Command<T>): T {
return command.execute(state);
}
/**
* Undoes a command from the given state.
* Delegates to `command.undo(state)`, which for `PatchCommand` instances
* applies the inverse patch via `applyPatch` from `@suaveplan/diff`.
*/
export function undoCommand<T>(state: T, command: Command<T>): T {
return command.undo(state);
}
// ---------------------------------------------------------------------------
// createPatchCommand
// ---------------------------------------------------------------------------
/**
* Creates a `PatchCommand<T>` by computing the structural diff between
* `oldState` and `newState`. Uses `diff` from `@suaveplan/diff` to produce
* the forward patch and `invertPatch` for the inverse.
*
* @param label - Human-readable label.
* @param oldState - State before the change.
* @param newState - State after the change.
* @param metadata - Optional metadata.
* @param opts - Injectable `clock` and `idGenerator`.
*/
export function createPatchCommand<T>(
label: string,
oldState: T,
newState: T,
metadata?: Record<string, unknown>,
opts?: CommandFactoryOptions
): PatchCommand<T> {
const clock = opts?.clock ?? defaultClock;
const idGen = opts?.idGenerator ?? defaultIdGenerator;
const patch = diff(
oldState as unknown as import("@suaveplan/diff").JsonValue,
newState as unknown as import("@suaveplan/diff").JsonValue
);
const inversePatch = invertPatch(
oldState as unknown as import("@suaveplan/diff").JsonValue,
patch
);
const id = idGen();
const timestamp = clock();
// Closures delegate to applyPatch for structural correctness
const cmd: PatchCommand<T> = {
id,
label,
timestamp,
type: "patch",
patch,
inversePatch,
execute: (state: T): T => {
const result = applyPatch(
state as unknown as import("@suaveplan/diff").JsonValue,
patch
);
return result.doc as unknown as T;
},
undo: (state: T): T => {
const result = applyPatch(
state as unknown as import("@suaveplan/diff").JsonValue,
inversePatch
);
return result.doc as unknown as T;
},
};
if (metadata !== undefined) {
(cmd as { metadata?: Record<string, unknown> }).metadata = metadata;
}
return cmd;
}
// ---------------------------------------------------------------------------
// estimateCommandSize
// ---------------------------------------------------------------------------
/**
* Estimates the memory footprint of a command in bytes using JSON serialization.
* For `PatchCommand`, includes the serialized patch arrays.
* For closure commands, only serializes `id`, `label`, `timestamp`, and `metadata`.
*/
export function estimateCommandSize<T>(command: Command<T>): number {
try {
if (isPatchCommand(command)) {
const repr = {
id: command.id,
label: command.label,
timestamp: command.timestamp,
type: command.type,
patch: command.patch,
inversePatch: command.inversePatch,
metadata: command.metadata,
};
return JSON.stringify(repr).length;
}
const repr = {
id: command.id,
label: command.label,
timestamp: command.timestamp,
metadata: command.metadata,
};
return JSON.stringify(repr).length;
} catch {
// Fallback if metadata contains non-serializable values
return command.id.length + command.label.length + 64;
}
}

View File

@@ -0,0 +1,9 @@
export type { CommandFactoryOptions } from "./command.js";
export {
composeCommands,
createCommand,
createPatchCommand,
estimateCommandSize,
executeCommand,
undoCommand,
} from "./command.js";

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,195 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { createCommand } from "../command/command.js";
import { UndoStack } from "../stack/stack.js";
import { compressHistory, HistoryCompressor } from "./history-compressor.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface Counter {
value: number;
}
const _inc = (n = 1) =>
createCommand<Counter>(
"Inc",
(s) => ({ value: s.value + n }),
(s) => ({ value: s.value - n })
);
const labeled = (label: string, n = 1) =>
createCommand<Counter>(
label,
(s) => ({ value: s.value + n }),
(s) => ({ value: s.value - n })
);
function makeStack(
pushCount: number,
labelFn?: (i: number) => string
): UndoStack<Counter> {
const s = new UndoStack<Counter>({ value: 0 });
for (let i = 0; i < pushCount; i++) {
s.push(labeled(labelFn ? labelFn(i) : "Inc", 1));
}
return s;
}
// ---------------------------------------------------------------------------
// HistoryCompressor class
// ---------------------------------------------------------------------------
describe("HistoryCompressor", () => {
it("returns compressionDepth getter", { timeout: 5000 }, () => {
const c = new HistoryCompressor({ compressionDepth: 20 });
expect(c.compressionDepth).toBe(20);
});
it("returns maxHistorySize getter", { timeout: 5000 }, () => {
const c = new HistoryCompressor({ maxHistorySize: 100 });
expect(c.maxHistorySize).toBe(100);
});
it("defaults compressionDepth to 50", { timeout: 5000 }, () => {
const c = new HistoryCompressor();
expect(c.compressionDepth).toBe(50);
});
it("compress delegates to compressHistory", { timeout: 5000 }, () => {
const s = makeStack(10);
const c = new HistoryCompressor({ compressionDepth: 3 });
const result = c.compress(s);
expect(result.stack).toBeDefined();
expect(result.newSize).toBeGreaterThan(0);
});
it("compress with maxHistorySize drops old entries", {
timeout: 5000,
}, () => {
// Unique labels so squashing doesn't reduce below maxHistorySize
const s = makeStack(50, (i) => `Op${i}`);
const c = new HistoryCompressor({
compressionDepth: 5,
maxHistorySize: 10,
});
const result = c.compress(s);
expect(result.stack.undoSize).toBeLessThanOrEqual(10);
expect(result.droppedCount).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// compressHistory
// ---------------------------------------------------------------------------
describe("compressHistory", () => {
it("is a no-op when all entries within depth", { timeout: 5000 }, () => {
const s = makeStack(5);
const result = compressHistory(s, { compressionDepth: 10 });
expect(result.squashedCount).toBe(0);
expect(result.droppedCount).toBe(0);
expect(result.newSize).toBe(5);
});
it("squashes old entries with same label", { timeout: 5000 }, () => {
const s = makeStack(10); // all label "Inc"
const result = compressHistory(s, { compressionDepth: 3 });
// 7 old entries → 1 composite; squashedCount = 6 (7-1)
expect(result.squashedCount).toBe(6);
expect(result.newSize).toBeLessThan(10);
});
it("preserves current state after compression", { timeout: 5000 }, () => {
const s = makeStack(20);
const original = s.current.value;
const result = compressHistory(s, { compressionDepth: 5 });
expect(result.stack.current.value).toBe(original);
});
it("respects maxHistorySize by dropping old entries", {
timeout: 5000,
}, () => {
// Use unique labels so squashing doesn't reduce count below maxHistorySize
const s = makeStack(100, (i) => `Op${i}`);
const result = compressHistory(s, {
compressionDepth: 10,
maxHistorySize: 15,
});
expect(result.stack.undoSize).toBeLessThanOrEqual(15);
expect(result.droppedCount).toBeGreaterThan(0);
});
it("handles empty stack", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
const result = compressHistory(s, { compressionDepth: 10 });
expect(result.squashedCount).toBe(0);
expect(result.newSize).toBe(0);
});
it("does not squash entries with different labels", {
timeout: 5000,
}, () => {
const s = new UndoStack<Counter>({ value: 0 });
s.push(labeled("A", 1));
s.push(labeled("B", 1));
s.push(labeled("C", 1));
const result = compressHistory(s, { compressionDepth: 0 });
expect(result.squashedCount).toBe(0);
});
it("CompressionResult has correct shape", { timeout: 5000 }, () => {
const s = makeStack(10);
const result = compressHistory(s, { compressionDepth: 5 });
expect(typeof result.squashedCount).toBe("number");
expect(typeof result.droppedCount).toBe("number");
expect(typeof result.newSize).toBe("number");
});
it("returns stack as UndoStack instance", { timeout: 5000 }, () => {
const s = makeStack(5);
const result = compressHistory(s);
expect(result.stack).toBeInstanceOf(UndoStack);
});
});
// ---------------------------------------------------------------------------
// Property-based tests
// ---------------------------------------------------------------------------
describe("property: compressHistory idempotency", () => {
it("re-compressing does not change state", { timeout: 30000 }, () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 30 }),
fc.integer({ min: 1, max: 10 }),
(pushCount, depth) => {
const s = makeStack(pushCount);
const r1 = compressHistory(s, { compressionDepth: depth });
const r2 = compressHistory(r1.stack, {
compressionDepth: depth,
});
// State must be preserved
return r1.stack.current.value === r2.stack.current.value;
}
),
{ numRuns: 100 }
);
});
it("compressed stack has same or fewer entries than original", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 50 }),
fc.integer({ min: 1, max: 20 }),
(pushCount, depth) => {
const s = makeStack(pushCount);
const result = compressHistory(s, {
compressionDepth: depth,
});
return result.newSize <= pushCount;
}
),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,179 @@
import { metrics } from "@opentelemetry/api";
import { composeCommands } from "../command/command.js";
import { HistoryCompressorOptionsSchema } from "../schemas/undo.schemas.js";
import { UndoStack } from "../stack/stack.js";
import type {
CompressionResult,
HistoryCompressorOptions,
} from "../types/undo.types.js";
// ---------------------------------------------------------------------------
// OTel
// ---------------------------------------------------------------------------
const meter = metrics.getMeter("undo");
const compressCounter = meter.createCounter("undo.compress.total");
const compressRatioHistogram = meter.createHistogram("undo.compress.ratio");
// ---------------------------------------------------------------------------
// HistoryCompressor
// ---------------------------------------------------------------------------
/**
* Squashes older undo history entries into composite commands to reduce
* memory usage while preserving the ability to undo in bulk.
*/
export class HistoryCompressor<T> {
protected _compressionDepth: number;
protected _maxHistorySize: number | undefined;
public constructor(options?: HistoryCompressorOptions) {
const parsed = HistoryCompressorOptionsSchema.parse(options ?? {});
this._compressionDepth = parsed.compressionDepth ?? 50;
this._maxHistorySize = parsed.maxHistorySize;
}
public get compressionDepth(): number {
return this._compressionDepth;
}
public get maxHistorySize(): number | undefined {
return this._maxHistorySize;
}
/**
* Compresses the undo history of `stack`. Returns the compressed stack
* along with a `CompressionResult` summary.
*/
public compress(
stack: UndoStack<T>
): CompressionResult & { stack: UndoStack<T> } {
const opts: HistoryCompressorOptions = {
compressionDepth: this._compressionDepth,
};
if (this._maxHistorySize !== undefined) {
opts.maxHistorySize = this._maxHistorySize;
}
return compressHistory(stack, opts);
}
}
// ---------------------------------------------------------------------------
// compressHistory (standalone function)
// ---------------------------------------------------------------------------
/**
* Squashes all undo entries older than `compressionDepth` by label, then
* optionally drops entries beyond `maxHistorySize`. Returns a new `UndoStack`
* with the compressed history and a `CompressionResult`.
*/
export function compressHistory<T>(
stack: UndoStack<T>,
options?: HistoryCompressorOptions
): CompressionResult & { stack: UndoStack<T> } {
const parsed = HistoryCompressorOptionsSchema.parse(options ?? {});
const depth = parsed.compressionDepth ?? 50;
const maxSize = parsed.maxHistorySize;
const entries = stack.getUndoStack();
const originalCount = entries.length;
// Entries within depth are kept as-is (the most-recent ones)
const keepCount = Math.min(depth, entries.length);
const oldEntries = entries.slice(0, entries.length - keepCount);
const recentEntries = entries.slice(entries.length - keepCount);
// Group old entries by label and squash each group
const squashedGroups = squashByLabel(oldEntries);
let squashedCount = 0;
// Count how many original entries were squashed into fewer composites
for (const group of squashedGroups) {
squashedCount += group.originalCount - 1; // -1 because we kept 1 composite
}
// Merged old entries
const compressedOld = squashedGroups.map((g) => g.entry);
// Combine: compressed old + recent
let newEntries = [...compressedOld, ...recentEntries];
// Apply maxHistorySize
let droppedCount = 0;
if (maxSize !== undefined && newEntries.length > maxSize) {
const over = newEntries.length - maxSize;
newEntries = newEntries.slice(over);
droppedCount = over;
}
// Rebuild the stack: start from the oldest stateBefore and replay
const initial =
newEntries.length > 0 ? newEntries[0]!.stateBefore : stack.current;
const newStack = new UndoStack<T>(initial);
for (const entry of newEntries) {
// Push directly using the stored command (skip re-execution)
newStack["_stack"].push(entry);
newStack["_current"] = entry.command.execute(entry.stateBefore);
}
const newSize = newStack.undoSize;
// OTel
compressCounter.add(1);
if (originalCount > 0) {
compressRatioHistogram.record(originalCount / Math.max(1, newSize));
}
return {
squashedCount,
droppedCount,
newSize,
stack: newStack,
};
}
// ---------------------------------------------------------------------------
// Internal: squash by label
// ---------------------------------------------------------------------------
interface SquashGroup<T> {
entry: import("../types/undo.types.js").StackEntry<T>;
originalCount: number;
}
function squashByLabel<T>(
entries: import("../types/undo.types.js").StackEntry<T>[]
): SquashGroup<T>[] {
if (entries.length === 0) return [];
const groups: SquashGroup<T>[] = [];
let i = 0;
while (i < entries.length) {
const label = entries[i]!.command.label;
const group: typeof entries = [entries[i]!];
let j = i + 1;
while (j < entries.length && entries[j]!.command.label === label) {
group.push(entries[j]!);
j++;
}
if (group.length === 1) {
groups.push({ entry: group[0]!, originalCount: 1 });
} else {
const composed = composeCommands(
label,
group.map((e) => e.command)
);
groups.push({
entry: {
command: composed,
stateBefore: group[0]!.stateBefore,
},
originalCount: group.length,
});
}
i = j;
}
return groups;
}

View File

@@ -0,0 +1 @@
export { compressHistory, HistoryCompressor } from "./history-compressor.js";

View File

@@ -0,0 +1,2 @@
export type { UndoCode, UndoErrorContext } from "./undo-error.js";
export { UNDO_CODES, UndoError } from "./undo-error.js";

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,115 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { UNDO_CODES, UndoError } from "./undo-error.js";
describe("UNDO_CODES", () => {
it("has all six codes defined", { timeout: 5000 }, () => {
expect(UNDO_CODES.EMPTY_STACK).toBe("UNDO.EMPTY_STACK");
expect(UNDO_CODES.AT_BRANCH_ROOT).toBe("UNDO.AT_BRANCH_ROOT");
expect(UNDO_CODES.DEPENDENCY_CONFLICT).toBe("UNDO.DEPENDENCY_CONFLICT");
expect(UNDO_CODES.COMMAND_FAILED).toBe("UNDO.COMMAND_FAILED");
expect(UNDO_CODES.TRANSACTION_ACTIVE).toBe("UNDO.TRANSACTION_ACTIVE");
expect(UNDO_CODES.TRANSACTION_INACTIVE).toBe(
"UNDO.TRANSACTION_INACTIVE"
);
});
it("all code values are unique", { timeout: 5000 }, () => {
const values = Object.values(UNDO_CODES);
const unique = new Set(values);
expect(unique.size).toBe(values.length);
});
it("all codes start with UNDO. prefix", { timeout: 5000 }, () => {
for (const code of Object.values(UNDO_CODES)) {
expect(code).toMatch(/^UNDO\./);
}
});
});
describe("UndoError", () => {
it("is an instance of SuaveplanError", { timeout: 5000 }, () => {
const err = new UndoError(UNDO_CODES.EMPTY_STACK, "nothing to undo");
expect(err).toBeInstanceOf(SuaveplanError);
});
it("is an instance of UndoError", { timeout: 5000 }, () => {
const err = new UndoError(UNDO_CODES.EMPTY_STACK, "nothing to undo");
expect(err).toBeInstanceOf(UndoError);
});
it("sets name to UndoError", { timeout: 5000 }, () => {
const err = new UndoError(UNDO_CODES.EMPTY_STACK, "nothing to undo");
expect(err.name).toBe("UndoError");
});
it("preserves code on the error instance", { timeout: 5000 }, () => {
const err = new UndoError(
UNDO_CODES.AT_BRANCH_ROOT,
"branch not found"
);
expect(err.code).toBe("UNDO.AT_BRANCH_ROOT");
});
it("preserves message", { timeout: 5000 }, () => {
const err = new UndoError(UNDO_CODES.COMMAND_FAILED, "async rejected");
expect(err.message).toBe("async rejected");
});
it("preserves cause", { timeout: 5000 }, () => {
const cause = new Error("original");
const err = new UndoError(UNDO_CODES.COMMAND_FAILED, "wrapped", {
cause,
});
expect(err.cause).toBe(cause);
});
it("carries commandId context", { timeout: 5000 }, () => {
const err = new UndoError(UNDO_CODES.COMMAND_FAILED, "failed", {
context: { commandId: "cmd-123" },
});
expect((err.context as Record<string, unknown>)?.["commandId"]).toBe(
"cmd-123"
);
});
it("carries branchId context", { timeout: 5000 }, () => {
const err = new UndoError(UNDO_CODES.AT_BRANCH_ROOT, "no branch", {
context: { branchId: "branch-abc" },
});
expect((err.context as Record<string, unknown>)?.["branchId"]).toBe(
"branch-abc"
);
});
it("carries conflictIds context", { timeout: 5000 }, () => {
const err = new UndoError(UNDO_CODES.DEPENDENCY_CONFLICT, "conflict", {
context: { conflictIds: ["c1", "c2"] },
});
expect(
(err.context as Record<string, unknown>)?.["conflictIds"]
).toEqual(["c1", "c2"]);
});
it("can be constructed with no options", { timeout: 5000 }, () => {
const err = new UndoError(UNDO_CODES.TRANSACTION_ACTIVE, "tx active");
expect(err.cause).toBeUndefined();
expect(err.context).toBeUndefined();
});
it("discriminates by code across all codes", { timeout: 5000 }, () => {
const codes = [
UNDO_CODES.EMPTY_STACK,
UNDO_CODES.AT_BRANCH_ROOT,
UNDO_CODES.DEPENDENCY_CONFLICT,
UNDO_CODES.COMMAND_FAILED,
UNDO_CODES.TRANSACTION_ACTIVE,
UNDO_CODES.TRANSACTION_INACTIVE,
] as const;
for (const code of codes) {
const err = new UndoError(code, "test");
expect(err.code).toBe(code);
}
});
});

View File

@@ -0,0 +1,65 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/undo`.
*
* Each code is scoped under the `UNDO` namespace via `defineCodes`, producing
* fully-qualified strings such as `"UNDO.EMPTY_STACK"` that are safe for
* `error.code`-based discrimination across module boundaries.
*/
export const UNDO_CODES = defineCodes("UNDO", [
"EMPTY_STACK",
"AT_BRANCH_ROOT",
"DEPENDENCY_CONFLICT",
"COMMAND_FAILED",
"TRANSACTION_ACTIVE",
"TRANSACTION_INACTIVE",
] as const);
export type UndoCode = (typeof UNDO_CODES)[keyof typeof UNDO_CODES];
/**
* Context fields carried by an `UndoError`.
*/
export interface UndoErrorContext {
/** ID of the command involved (if applicable). */
commandId?: string;
/** ID of the branch involved (if applicable). */
branchId?: string;
/** IDs of commands that conflict with a selective undo request. */
conflictIds?: string[];
}
/**
* Typed error for all `@suaveplan/undo` failures.
*
* Extends `SuaveplanError` so callers can discriminate via
* `error instanceof UndoError` or the stable `error.code` property.
*
* @example
* ```ts
* import { UndoError, UNDO_CODES } from "@suaveplan/undo/errors/undo-error";
*
* try {
* stack.undo();
* } catch (err) {
* if (err instanceof UndoError && err.code === UNDO_CODES.EMPTY_STACK) {
* console.warn("Nothing to undo");
* }
* }
* ```
*/
export class UndoError extends SuaveplanError<UndoCode> {
public constructor(
code: UndoCode,
message: string,
options?: { cause?: Error; context?: UndoErrorContext }
) {
super(
code,
message,
options as { cause?: Error; context?: Record<string, unknown> }
);
this.name = "UndoError";
}
}

View File

@@ -0,0 +1,78 @@
// Errors
// Async
export type { AsyncCommandFactoryOptions } from "./async/async-command.js";
export {
composeAsyncCommands,
createAsyncCommand,
executeAsyncCommand,
undoAsyncCommand,
} from "./async/async-command.js";
export { AsyncUndoStack } from "./async/async-stack.js";
// BranchingHistory
export { BranchingHistory } from "./branch/branch.js";
// Command primitives
export type { CommandFactoryOptions } from "./command/command.js";
export {
composeCommands,
createCommand,
createPatchCommand,
estimateCommandSize,
executeCommand,
undoCommand,
} from "./command/command.js";
// Compression
export {
compressHistory,
HistoryCompressor,
} from "./compression/history-compressor.js";
export type { UndoCode, UndoErrorContext } from "./errors/undo-error.js";
export { UNDO_CODES, UndoError } from "./errors/undo-error.js";
// Command merger
export { CommandMerger } from "./merge/command-merger.js";
// Schemas
export type {
BranchingOptionsInput,
CommandMergerOptionsInput,
HistoryCompressorOptionsInput,
SelectiveUndoOptionsInput,
UndoStackOptionsInput,
} from "./schemas/undo.schemas.js";
export {
BranchingOptionsSchema,
CommandMergerOptionsSchema,
HistoryCompressorOptionsSchema,
SelectiveUndoOptionsSchema,
UndoStackOptionsSchema,
} from "./schemas/undo.schemas.js";
// Selective undo
export { SelectiveUndoStack } from "./selective/selective-undo.js";
// UndoStack
export { UndoStack } from "./stack/stack.js";
// Types
export type {
AsyncCommand,
AsyncTransactionHandle,
Branch,
BranchingOptions,
BranchingState,
Clock,
Command,
CommandMergerOptions,
CompressionResult,
DependencyChecker,
EventBusLike,
HistoryCompressorOptions,
IdGenerator,
MergePredicate,
Patch,
PatchCommand,
SelectiveUndoOptions,
SelectiveUndoResult,
StackEntry,
TransactionHandle,
UndoEventMap,
UndoStackOptions,
UndoStackState,
} from "./types/undo.types.js";
export { isAsyncCommand, isPatchCommand } from "./types/undo.types.js";

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,205 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { createCommand, executeCommand } from "../command/command.js";
import { CommandMerger } from "./command-merger.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface Counter {
value: number;
}
let t = 0;
const _clock = () => ++t;
const inc = (n = 1, label = "Inc") =>
createCommand<Counter>(
label,
(s) => ({ value: s.value + n }),
(s) => ({ value: s.value - n })
);
// ---------------------------------------------------------------------------
// CommandMerger
// ---------------------------------------------------------------------------
describe("CommandMerger", () => {
it("hasPending is false initially", { timeout: 5000 }, () => {
const m = new CommandMerger<Counter>();
expect(m.hasPending).toBe(false);
});
it("pendingCount is 0 initially", { timeout: 5000 }, () => {
const m = new CommandMerger<Counter>();
expect(m.pendingCount).toBe(0);
});
it("add accumulates without flushing", { timeout: 5000 }, () => {
const m = new CommandMerger<Counter>();
const result = m.add(inc(1));
expect(result).toBeUndefined();
expect(m.hasPending).toBe(true);
expect(m.pendingCount).toBe(1);
});
it("flush returns merged command", { timeout: 5000 }, () => {
const m = new CommandMerger<Counter>();
m.add(inc(1));
m.add(inc(2));
const flushed = m.flush();
expect(flushed).toBeDefined();
expect(flushed?.label).toBe("Inc");
expect(m.hasPending).toBe(false);
});
it("flush returns undefined when nothing pending", {
timeout: 5000,
}, () => {
const m = new CommandMerger<Counter>();
expect(m.flush()).toBeUndefined();
});
it("flush of single command returns it directly", { timeout: 5000 }, () => {
const m = new CommandMerger<Counter>();
const cmd = inc(5);
m.add(cmd);
const flushed = m.flush();
expect(flushed?.id).toBe(cmd.id);
});
it("reset discards pending", { timeout: 5000 }, () => {
const m = new CommandMerger<Counter>();
m.add(inc(1));
m.reset();
expect(m.hasPending).toBe(false);
expect(m.flush()).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// maxCount window
// ---------------------------------------------------------------------------
describe("maxCount window", () => {
it("flushes when count limit reached", { timeout: 5000 }, () => {
const m = new CommandMerger<Counter>({ maxCount: 3 });
m.add(inc(1));
m.add(inc(1));
const flushed = m.add(inc(1)); // 3rd → flush
expect(flushed).toBeDefined();
expect(m.hasPending).toBe(false);
});
it("merged command executes correctly", { timeout: 5000 }, () => {
const m = new CommandMerger<Counter>({ maxCount: 3 });
m.add(inc(1));
m.add(inc(2));
const flushed = m.add(inc(3));
expect(flushed).toBeDefined();
const result = executeCommand({ value: 0 }, flushed!);
expect(result.value).toBe(6);
});
});
// ---------------------------------------------------------------------------
// time window
// ---------------------------------------------------------------------------
describe("timeWindowMs window", () => {
it("flushes when time window expires", { timeout: 5000 }, () => {
let localTime = 0;
const localClock = () => localTime;
const m = new CommandMerger<Counter>({
timeWindowMs: 100,
clock: localClock,
});
m.add(inc(1)); // window start = 0
localTime = 50;
expect(m.add(inc(1))).toBeUndefined(); // within window
localTime = 150; // past window
const flushed = m.add(inc(2)); // triggers flush
expect(flushed).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// canMerge
// ---------------------------------------------------------------------------
describe("canMerge", () => {
it("returns true for same-label commands (default predicate)", {
timeout: 5000,
}, () => {
const m = new CommandMerger<Counter>();
expect(m.canMerge(inc(1, "Inc"), inc(2, "Inc"))).toBe(true);
});
it("returns false for different labels", { timeout: 5000 }, () => {
const m = new CommandMerger<Counter>();
expect(m.canMerge(inc(1, "A"), inc(2, "B"))).toBe(false);
});
it("uses custom predicate", { timeout: 5000 }, () => {
const m = new CommandMerger<Counter>({}, () => false);
expect(m.canMerge(inc(1, "Inc"), inc(2, "Inc"))).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Non-mergeable command triggers flush
// ---------------------------------------------------------------------------
describe("non-mergeable flush", () => {
it("flushes buffer when new command has different label", {
timeout: 5000,
}, () => {
const m = new CommandMerger<Counter>();
m.add(inc(1, "A"));
m.add(inc(1, "A"));
const flushed = m.add(inc(1, "B")); // B is not mergeable with A → flush A buffer
expect(flushed).toBeDefined();
expect(m.pendingCount).toBe(1); // B is now pending
});
});
// ---------------------------------------------------------------------------
// Property-based tests
// ---------------------------------------------------------------------------
describe("property: merge associativity", () => {
it("merged command produces same state as executing individually", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.array(fc.integer({ min: 1, max: 5 }), {
minLength: 1,
maxLength: 10,
}),
(deltas) => {
const commands = deltas.map((d) =>
createCommand<Counter>(
"Inc",
(s) => ({ value: s.value + d }),
(s) => ({ value: s.value - d })
)
);
const m = new CommandMerger<Counter>({
maxCount: commands.length + 1,
});
for (const cmd of commands) m.add(cmd);
const merged = m.flush();
// Execute individually
let individual: Counter = { value: 0 };
for (const cmd of commands) {
individual = executeCommand(individual, cmd);
}
// Execute merged
const mergedResult = merged
? executeCommand({ value: 0 }, merged)
: { value: 0 };
return mergedResult.value === individual.value;
}
),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,140 @@
import type { Clock } from "@suaveplan/types";
import { composeCommands } from "../command/command.js";
import { CommandMergerOptionsSchema } from "../schemas/undo.schemas.js";
import type {
Command,
CommandMergerOptions,
MergePredicate,
} from "../types/undo.types.js";
// ---------------------------------------------------------------------------
// CommandMerger
// ---------------------------------------------------------------------------
/**
* Accumulates commands and merges them into a single composite command when
* a time or count window closes. Useful for coalescing rapid keystrokes,
* drag operations, or other high-frequency user actions into a single undo step.
*/
export class CommandMerger<T> {
protected _pending: Command<T>[] = [];
protected _options: CommandMergerOptions;
protected _clock: Clock;
protected _predicate: MergePredicate<T>;
protected _windowStart: number | undefined;
public constructor(
options?: CommandMergerOptions,
predicate?: MergePredicate<T>
) {
const parsed = CommandMergerOptionsSchema.parse(options ?? {});
this._options = parsed as CommandMergerOptions;
this._clock = (parsed.clock as Clock | undefined) ?? Date.now;
// Default predicate: merge commands with identical label
this._predicate = predicate ?? ((a, b) => a.label === b.label);
}
// ---------------------------------------------------------------------------
// Getters
// ---------------------------------------------------------------------------
public get hasPending(): boolean {
return this._pending.length > 0;
}
public get pendingCount(): number {
return this._pending.length;
}
// ---------------------------------------------------------------------------
// canMerge
// ---------------------------------------------------------------------------
/** Returns `true` if `a` and `b` can be merged according to the predicate. */
public canMerge(a: Command<T>, b: Command<T>): boolean {
return this._predicate(a, b);
}
// ---------------------------------------------------------------------------
// add
// ---------------------------------------------------------------------------
/**
* Adds a command to the pending buffer.
* Returns a flushed merged command when the window closes, otherwise `undefined`.
*/
public add(command: Command<T>): Command<T> | undefined {
const now = this._clock();
// Check time window: if elapsed, flush before adding new command
if (
this._options.timeWindowMs !== undefined &&
this._windowStart !== undefined &&
now - this._windowStart >= this._options.timeWindowMs
) {
const flushed = this._doFlush();
this._pending = [command];
this._windowStart = now;
return flushed;
}
// Check if the new command is mergeable with the last pending
if (
this._pending.length > 0 &&
!this._predicate(this._pending[this._pending.length - 1]!, command)
) {
// Not mergeable — flush pending and start new buffer
const flushed = this._doFlush();
this._pending = [command];
this._windowStart = now;
return flushed;
}
// Start window on first add
if (this._pending.length === 0) {
this._windowStart = now;
}
this._pending.push(command);
// Check count window
if (
this._options.maxCount !== undefined &&
this._pending.length >= this._options.maxCount
) {
return this._doFlush();
}
return undefined;
}
// ---------------------------------------------------------------------------
// flush / reset
// ---------------------------------------------------------------------------
/**
* Flushes the pending buffer, returning the merged command (or the single
* pending command if only one). Returns `undefined` if nothing is pending.
*/
public flush(): Command<T> | undefined {
if (this._pending.length === 0) return undefined;
return this._doFlush();
}
/** Discards all pending commands without returning them. */
public reset(): void {
this._pending = [];
this._windowStart = undefined;
}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
protected _doFlush(): Command<T> {
const items = this._pending;
this._pending = [];
this._windowStart = undefined;
if (items.length === 1) return items[0]!;
return composeCommands(items[0]!.label, items);
}
}

View File

@@ -0,0 +1 @@
export { CommandMerger } from "./command-merger.js";

View File

@@ -0,0 +1,14 @@
export type {
BranchingOptionsInput,
CommandMergerOptionsInput,
HistoryCompressorOptionsInput,
SelectiveUndoOptionsInput,
UndoStackOptionsInput,
} from "./undo.schemas.js";
export {
BranchingOptionsSchema,
CommandMergerOptionsSchema,
HistoryCompressorOptionsSchema,
SelectiveUndoOptionsSchema,
UndoStackOptionsSchema,
} from "./undo.schemas.js";

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,197 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BranchingOptionsSchema,
CommandMergerOptionsSchema,
HistoryCompressorOptionsSchema,
SelectiveUndoOptionsSchema,
UndoStackOptionsSchema,
} from "./undo.schemas.js";
describe("UndoStackOptionsSchema", () => {
it("accepts empty object", { timeout: 5000 }, () => {
expect(() => UndoStackOptionsSchema.parse({})).not.toThrow();
});
it("accepts valid options", { timeout: 5000 }, () => {
const opts = UndoStackOptionsSchema.parse({
maxEntries: 100,
maxMemoryBytes: 1024,
onTruncate: () => undefined,
clock: Date.now,
idGenerator: () => "id",
});
expect(opts.maxEntries).toBe(100);
expect(opts.maxMemoryBytes).toBe(1024);
});
it("rejects negative maxEntries", { timeout: 5000 }, () => {
expect(() =>
UndoStackOptionsSchema.parse({ maxEntries: -1 })
).toThrow();
});
it("rejects zero maxEntries", { timeout: 5000 }, () => {
expect(() => UndoStackOptionsSchema.parse({ maxEntries: 0 })).toThrow();
});
it("rejects non-integer maxEntries", { timeout: 5000 }, () => {
expect(() =>
UndoStackOptionsSchema.parse({ maxEntries: 1.5 })
).toThrow();
});
it("rejects negative maxMemoryBytes", { timeout: 5000 }, () => {
expect(() =>
UndoStackOptionsSchema.parse({ maxMemoryBytes: -100 })
).toThrow();
});
it("rejects unknown keys (strict)", { timeout: 5000 }, () => {
expect(() =>
UndoStackOptionsSchema.parse({ unknownKey: true })
).toThrow();
});
it("rejects non-function clock", { timeout: 5000 }, () => {
expect(() => UndoStackOptionsSchema.parse({ clock: 123 })).toThrow();
});
});
describe("BranchingOptionsSchema", () => {
it("accepts valid options", { timeout: 5000 }, () => {
const opts = BranchingOptionsSchema.parse({
initialBranchLabel: "main",
branchOnDiverge: true,
maxEntries: 50,
clock: Date.now,
idGenerator: () => "id",
});
expect(opts.initialBranchLabel).toBe("main");
expect(opts.branchOnDiverge).toBe(true);
});
it("rejects empty initialBranchLabel", { timeout: 5000 }, () => {
expect(() =>
BranchingOptionsSchema.parse({ initialBranchLabel: "" })
).toThrow();
});
it("rejects unknown keys", { timeout: 5000 }, () => {
expect(() =>
BranchingOptionsSchema.parse({ extra: "field" })
).toThrow();
});
it("accepts empty object", { timeout: 5000 }, () => {
expect(() => BranchingOptionsSchema.parse({})).not.toThrow();
});
});
describe("HistoryCompressorOptionsSchema", () => {
it("accepts valid options", { timeout: 5000 }, () => {
const opts = HistoryCompressorOptionsSchema.parse({
compressionDepth: 50,
maxHistorySize: 200,
});
expect(opts.compressionDepth).toBe(50);
expect(opts.maxHistorySize).toBe(200);
});
it("accepts zero compressionDepth (means compress everything)", {
timeout: 5000,
}, () => {
expect(() =>
HistoryCompressorOptionsSchema.parse({ compressionDepth: 0 })
).not.toThrow();
});
it("rejects negative compressionDepth", { timeout: 5000 }, () => {
expect(() =>
HistoryCompressorOptionsSchema.parse({ compressionDepth: -1 })
).toThrow();
});
it("rejects negative maxHistorySize", { timeout: 5000 }, () => {
expect(() =>
HistoryCompressorOptionsSchema.parse({ maxHistorySize: -5 })
).toThrow();
});
it("rejects non-integer values", { timeout: 5000 }, () => {
expect(() =>
HistoryCompressorOptionsSchema.parse({ compressionDepth: 1.5 })
).toThrow();
});
it("accepts empty object", { timeout: 5000 }, () => {
expect(() => HistoryCompressorOptionsSchema.parse({})).not.toThrow();
});
});
describe("CommandMergerOptionsSchema", () => {
it("accepts valid options", { timeout: 5000 }, () => {
const opts = CommandMergerOptionsSchema.parse({
maxCount: 10,
timeWindowMs: 500,
clock: Date.now,
});
expect(opts.maxCount).toBe(10);
expect(opts.timeWindowMs).toBe(500);
});
it("rejects zero maxCount", { timeout: 5000 }, () => {
expect(() =>
CommandMergerOptionsSchema.parse({ maxCount: 0 })
).toThrow();
});
it("accepts fractional timeWindowMs", { timeout: 5000 }, () => {
expect(() =>
CommandMergerOptionsSchema.parse({ timeWindowMs: 0.5 })
).not.toThrow();
});
it("rejects zero timeWindowMs", { timeout: 5000 }, () => {
expect(() =>
CommandMergerOptionsSchema.parse({ timeWindowMs: 0 })
).toThrow();
});
it("accepts empty object", { timeout: 5000 }, () => {
expect(() => CommandMergerOptionsSchema.parse({})).not.toThrow();
});
});
describe("SelectiveUndoOptionsSchema", () => {
it("accepts valid options including base fields", { timeout: 5000 }, () => {
const opts = SelectiveUndoOptionsSchema.parse({
maxEntries: 100,
throwOnConflict: true,
dependencyCheck: () => false,
});
expect(opts.throwOnConflict).toBe(true);
expect(opts.maxEntries).toBe(100);
});
it("accepts empty object", { timeout: 5000 }, () => {
expect(() => SelectiveUndoOptionsSchema.parse({})).not.toThrow();
});
it("rejects non-boolean throwOnConflict", { timeout: 5000 }, () => {
expect(() =>
SelectiveUndoOptionsSchema.parse({ throwOnConflict: "yes" })
).toThrow();
});
it("rejects non-function dependencyCheck", { timeout: 5000 }, () => {
expect(() =>
SelectiveUndoOptionsSchema.parse({ dependencyCheck: true })
).toThrow();
});
it("inherits maxEntries validation from base", { timeout: 5000 }, () => {
expect(() =>
SelectiveUndoOptionsSchema.parse({ maxEntries: -1 })
).toThrow();
});
});

View File

@@ -0,0 +1,72 @@
import z from "zod";
/**
* Zod schema for `UndoStackOptions`.
*/
export const UndoStackOptionsSchema = z
.object({
maxEntries: z.number().int().positive().optional(),
maxMemoryBytes: z.number().int().positive().optional(),
onTruncate: z.function().optional(),
clock: z.function().optional(),
idGenerator: z.function().optional(),
})
.strict();
export type UndoStackOptionsInput = z.input<typeof UndoStackOptionsSchema>;
/**
* Zod schema for `BranchingOptions`.
*/
export const BranchingOptionsSchema = z
.object({
initialBranchLabel: z.string().min(1).optional(),
branchOnDiverge: z.boolean().optional(),
maxEntries: z.number().int().positive().optional(),
clock: z.function().optional(),
idGenerator: z.function().optional(),
})
.strict();
export type BranchingOptionsInput = z.input<typeof BranchingOptionsSchema>;
/**
* Zod schema for `HistoryCompressorOptions`.
*/
export const HistoryCompressorOptionsSchema = z
.object({
compressionDepth: z.number().int().min(0).optional(),
maxHistorySize: z.number().int().positive().optional(),
})
.strict();
export type HistoryCompressorOptionsInput = z.input<
typeof HistoryCompressorOptionsSchema
>;
/**
* Zod schema for `CommandMergerOptions`.
*/
export const CommandMergerOptionsSchema = z
.object({
maxCount: z.number().int().positive().optional(),
timeWindowMs: z.number().positive().optional(),
clock: z.function().optional(),
})
.strict();
export type CommandMergerOptionsInput = z.input<
typeof CommandMergerOptionsSchema
>;
/**
* Zod schema for `SelectiveUndoOptions`.
*/
export const SelectiveUndoOptionsSchema = UndoStackOptionsSchema.extend({
throwOnConflict: z.boolean().optional(),
dependencyCheck: z.function().optional(),
});
export type SelectiveUndoOptionsInput = z.input<
typeof SelectiveUndoOptionsSchema
>;

View File

@@ -0,0 +1 @@
export { SelectiveUndoStack } from "./selective-undo.js";

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,242 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createCommand } from "../command/command.js";
import { UNDO_CODES } from "../errors/undo-error.js";
import type { DependencyChecker } from "../types/undo.types.js";
import { SelectiveUndoStack } from "./selective-undo.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface Counter {
value: number;
}
const cmd = (label: string, delta: number, id?: string) => {
const c = createCommand<Counter>(
label,
(s) => ({ value: s.value + delta }),
(s) => ({ value: s.value - delta }),
id ? { _id: id } : undefined,
{ idGenerator: id ? () => id : undefined }
);
return c;
};
// Dependency checker: later depends on earlier if later.metadata.dependsOn includes earlier.id
const metadataDepCheck: DependencyChecker<Counter> = (earlier, later) =>
(later.metadata?.["dependsOn"] as string[] | undefined)?.includes(
earlier.id
) ?? false;
// ---------------------------------------------------------------------------
// undoById
// ---------------------------------------------------------------------------
describe("SelectiveUndoStack.undoById", () => {
it("undoes first command and re-applies later ones", {
timeout: 5000,
}, () => {
const s = new SelectiveUndoStack<Counter>({ value: 0 });
const a = cmd("A", 10);
const b = cmd("B", 5);
s.push(a); // 10
s.push(b); // 15
// Undo A: 0 + 5 = 5
const result = s.undoById(a.id);
expect(result.success).toBe(true);
expect(s.current.value).toBe(5);
});
it("returns success: false for unknown id", { timeout: 5000 }, () => {
const s = new SelectiveUndoStack<Counter>({ value: 0 });
const result = s.undoById("nonexistent");
expect(result.success).toBe(false);
expect(result.conflictIds).toEqual([]);
});
it("returns conflictIds when dependency exists (no throw)", {
timeout: 5000,
}, () => {
const s = new SelectiveUndoStack<Counter>(
{ value: 0 },
{ dependencyCheck: metadataDepCheck as DependencyChecker<unknown> }
);
const a = createCommand<Counter>(
"A",
(st) => ({ value: st.value + 1 }),
(st) => ({ value: st.value - 1 })
);
const b = createCommand<Counter>(
"B",
(st) => ({ value: st.value + 2 }),
(st) => ({ value: st.value - 2 }),
{ dependsOn: [a.id] }
);
s.push(a);
s.push(b);
const result = s.undoById(a.id);
expect(result.success).toBe(false);
expect(result.conflictIds).toContain(b.id);
});
it("throws DEPENDENCY_CONFLICT when throwOnConflict is true", {
timeout: 5000,
}, () => {
const s = new SelectiveUndoStack<Counter>(
{ value: 0 },
{
throwOnConflict: true,
dependencyCheck: metadataDepCheck as DependencyChecker<unknown>,
}
);
const a = createCommand<Counter>(
"A",
(st) => ({ value: st.value + 1 }),
(st) => ({ value: st.value - 1 })
);
const b = createCommand<Counter>(
"B",
(st) => ({ value: st.value + 2 }),
(st) => ({ value: st.value - 2 }),
{ dependsOn: [a.id] }
);
s.push(a);
s.push(b);
expect(() => s.undoById(a.id)).toThrowError(
expect.objectContaining({ code: UNDO_CODES.DEPENDENCY_CONFLICT })
);
});
it("includes conflictIds in error context", { timeout: 5000 }, () => {
const s = new SelectiveUndoStack<Counter>(
{ value: 0 },
{
throwOnConflict: true,
dependencyCheck: metadataDepCheck as DependencyChecker<unknown>,
}
);
const a = createCommand<Counter>(
"A",
(st) => ({ value: st.value + 1 }),
(st) => ({ value: st.value - 1 })
);
const b = createCommand<Counter>(
"B",
(st) => ({ value: st.value + 2 }),
(st) => ({ value: st.value - 2 }),
{ dependsOn: [a.id] }
);
s.push(a);
s.push(b);
try {
s.undoById(a.id);
} catch (err: unknown) {
const ctx = (err as { context?: Record<string, unknown> }).context;
expect(ctx?.["conflictIds"]).toContain(b.id);
}
});
it("preserves later commands after selective undo", {
timeout: 5000,
}, () => {
const s = new SelectiveUndoStack<Counter>({ value: 0 });
const a = cmd("A", 1);
const b = cmd("B", 2);
const c = cmd("C", 3);
s.push(a); // 1
s.push(b); // 3
s.push(c); // 6
// Undo b: 0 + 1 + 3 = 4
s.undoById(b.id);
expect(s.current.value).toBe(4);
});
it("undoes the most recent command", { timeout: 5000 }, () => {
const s = new SelectiveUndoStack<Counter>({ value: 0 });
const a = cmd("A", 5);
const b = cmd("B", 3);
s.push(a); // 5
s.push(b); // 8
s.undoById(b.id); // removes last, no re-apply needed
expect(s.current.value).toBe(5);
});
});
// ---------------------------------------------------------------------------
// getDependents
// ---------------------------------------------------------------------------
describe("getDependents", () => {
it("returns empty for no dependents", { timeout: 5000 }, () => {
const s = new SelectiveUndoStack<Counter>({ value: 0 });
const a = cmd("A", 1);
s.push(a);
expect(s.getDependents(a.id)).toEqual([]);
});
it("returns dependent IDs", { timeout: 5000 }, () => {
const s = new SelectiveUndoStack<Counter>(
{ value: 0 },
{ dependencyCheck: metadataDepCheck as DependencyChecker<unknown> }
);
const a = createCommand<Counter>(
"A",
(st) => ({ value: st.value + 1 }),
(st) => ({ value: st.value - 1 })
);
const b = createCommand<Counter>(
"B",
(st) => ({ value: st.value + 1 }),
(st) => ({ value: st.value - 1 }),
{ dependsOn: [a.id] }
);
s.push(a);
s.push(b);
expect(s.getDependents(a.id)).toContain(b.id);
});
it("returns empty for unknown id", { timeout: 5000 }, () => {
const s = new SelectiveUndoStack<Counter>({ value: 0 });
expect(s.getDependents("no-such-id")).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// canUndoById
// ---------------------------------------------------------------------------
describe("canUndoById", () => {
it("returns success: true when no conflicts", { timeout: 5000 }, () => {
const s = new SelectiveUndoStack<Counter>({ value: 0 });
const a = cmd("A", 1);
s.push(a);
expect(s.canUndoById(a.id).success).toBe(true);
});
it("returns success: false with conflictIds when conflicts exist", {
timeout: 5000,
}, () => {
const s = new SelectiveUndoStack<Counter>(
{ value: 0 },
{ dependencyCheck: metadataDepCheck as DependencyChecker<unknown> }
);
const a = createCommand<Counter>(
"A",
(st) => ({ value: st.value + 1 }),
(st) => ({ value: st.value - 1 })
);
const b = createCommand<Counter>(
"B",
(st) => ({ value: st.value + 1 }),
(st) => ({ value: st.value - 1 }),
{ dependsOn: [a.id] }
);
s.push(a);
s.push(b);
const result = s.canUndoById(a.id);
expect(result.success).toBe(false);
expect(result.conflictIds).toContain(b.id);
});
it("returns success: false for unknown id", { timeout: 5000 }, () => {
const s = new SelectiveUndoStack<Counter>({ value: 0 });
expect(s.canUndoById("no-such").success).toBe(false);
});
});

View File

@@ -0,0 +1,172 @@
import { metrics } from "@opentelemetry/api";
import type { EventBusLike } from "@suaveplan/types";
import { executeCommand } from "../command/command.js";
import { UNDO_CODES, UndoError } from "../errors/undo-error.js";
import { SelectiveUndoOptionsSchema } from "../schemas/undo.schemas.js";
import { UndoStack } from "../stack/stack.js";
import type {
DependencyChecker,
SelectiveUndoOptions,
SelectiveUndoResult,
UndoEventMap,
UndoStackOptions,
} from "../types/undo.types.js";
// ---------------------------------------------------------------------------
// OTel
// ---------------------------------------------------------------------------
const meter = metrics.getMeter("undo");
const undoCounter = meter.createCounter("undo.undo.total");
// ---------------------------------------------------------------------------
// SelectiveUndoStack
// ---------------------------------------------------------------------------
/**
* Extends `UndoStack<T>` with the ability to undo a specific command by ID,
* regardless of its position in the history. Dependency checking prevents
* undoing a command when a later command depends on its outcome.
*/
export class SelectiveUndoStack<T> extends UndoStack<T> {
protected _throwOnConflict: boolean;
protected _dependencyCheck: DependencyChecker<unknown>;
public constructor(
initial: T,
options?: SelectiveUndoOptions,
bus?: EventBusLike<UndoEventMap>
) {
const parsed = SelectiveUndoOptionsSchema.parse(options ?? {});
// Strip selective-only keys before passing to the parent UndoStack (which
// uses the strict UndoStackOptionsSchema and would reject unknown keys).
const { throwOnConflict, dependencyCheck, ...baseOptions } =
parsed as typeof parsed & {
throwOnConflict?: boolean;
dependencyCheck?: DependencyChecker<unknown>;
};
super(initial, baseOptions as UndoStackOptions, bus);
this._throwOnConflict = throwOnConflict ?? false;
this._dependencyCheck = dependencyCheck ?? (() => false);
}
// ---------------------------------------------------------------------------
// undoById
// ---------------------------------------------------------------------------
/**
* Undoes the command with the given `id`.
* - If any later command has a dependency on the target via `dependencyCheck`,
* returns `{ success: false, conflictIds }` (or throws if `throwOnConflict`).
* - On success, removes the target command and re-applies subsequent commands.
*/
public undoById(commandId: string): SelectiveUndoResult<T> {
const idx = this._stack.findIndex((e) => e.command.id === commandId);
if (idx === -1) {
return { success: false, conflictIds: [] };
}
const targetEntry = this._stack[idx]!;
const laterEntries = this._stack.slice(idx + 1);
// Check for conflicts
const conflictIds: string[] = [];
for (const later of laterEntries) {
if (
this._dependencyCheck(
targetEntry.command as never,
later.command as never
)
) {
conflictIds.push(later.command.id);
}
}
if (conflictIds.length > 0) {
if (this._throwOnConflict) {
throw new UndoError(
UNDO_CODES.DEPENDENCY_CONFLICT,
`Cannot undo command "${commandId}" — ${conflictIds.length} later command(s) depend on it`,
{ context: { commandId, conflictIds } }
);
}
return { success: false, conflictIds };
}
// No conflicts — rewind to stateBefore target, remove target, re-apply later entries
// Start from stateBefore of the target entry
let state = targetEntry.stateBefore;
// Re-apply all later commands from the target's stateBefore
const newLaterEntries: typeof this._stack = [];
for (const entry of laterEntries) {
const newBefore = state;
state = executeCommand(state, entry.command);
newLaterEntries.push({
command: entry.command,
stateBefore: newBefore,
});
}
// Rebuild stack: entries before target + new later entries
this._stack = [...this._stack.slice(0, idx), ...newLaterEntries];
this._current = state;
undoCounter.add(1, { stack_type: "selective" });
this._bus?.emit("undo:undo", {
commandId,
label: targetEntry.command.label,
undoSize: this._stack.length,
});
return { success: true, state, conflictIds: [] };
}
// ---------------------------------------------------------------------------
// getDependents
// ---------------------------------------------------------------------------
/**
* Returns the IDs of commands that depend on the command with `commandId`.
*/
public getDependents(commandId: string): string[] {
const idx = this._stack.findIndex((e) => e.command.id === commandId);
if (idx === -1) return [];
const target = this._stack[idx]!;
return this._stack
.slice(idx + 1)
.filter((e) =>
this._dependencyCheck(
target.command as never,
e.command as never
)
)
.map((e) => e.command.id);
}
// ---------------------------------------------------------------------------
// canUndoById
// ---------------------------------------------------------------------------
/**
* Returns whether the command with `commandId` can be selectively undone
* without conflicts. Non-throwing.
*/
public canUndoById(commandId: string): SelectiveUndoResult<T> {
const idx = this._stack.findIndex((e) => e.command.id === commandId);
if (idx === -1) {
return { success: false, conflictIds: [] };
}
const target = this._stack[idx]!;
const later = this._stack.slice(idx + 1);
const conflictIds = later
.filter((e) =>
this._dependencyCheck(
target.command as never,
e.command as never
)
)
.map((e) => e.command.id);
return { success: conflictIds.length === 0, conflictIds };
}
}

View File

@@ -0,0 +1 @@
export { UndoStack } from "./stack.js";

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,456 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { createCommand } from "../command/command.js";
import { UNDO_CODES } from "../errors/undo-error.js";
import { UndoStack } from "./stack.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface Counter {
value: number;
}
const inc = (n = 1) =>
createCommand<Counter>(
`Inc${n}`,
(s) => ({ value: s.value + n }),
(s) => ({ value: s.value - n })
);
const fakeEventBus = () => {
const listeners: Record<string, ((p: unknown) => void)[]> = {};
return {
on: (type: string, cb: (p: unknown) => void) => {
listeners[type] = [...(listeners[type] ?? []), cb];
return () => undefined;
},
emit: (type: string, payload: unknown) => {
for (const cb of listeners[type] ?? []) cb(payload);
},
listeners,
};
};
// ---------------------------------------------------------------------------
// Basic push/undo/redo
// ---------------------------------------------------------------------------
describe("UndoStack", () => {
it("starts with initial state", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 42 });
expect(s.current.value).toBe(42);
});
it("push increments undoSize", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
s.push(inc(1));
expect(s.undoSize).toBe(1);
expect(s.canUndo).toBe(true);
});
it("push clears redo stack", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
s.push(inc(1));
s.undo();
expect(s.canRedo).toBe(true);
s.push(inc(2));
expect(s.canRedo).toBe(false);
});
it("undo reverses command", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
s.push(inc(5));
s.undo();
expect(s.current.value).toBe(0);
});
it("redo re-applies command", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
s.push(inc(3));
s.undo();
s.redo();
expect(s.current.value).toBe(3);
});
it("undo then redo round-trip", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
for (let i = 0; i < 5; i++) s.push(inc(1));
expect(s.current.value).toBe(5);
for (let i = 0; i < 5; i++) s.undo();
expect(s.current.value).toBe(0);
for (let i = 0; i < 5; i++) s.redo();
expect(s.current.value).toBe(5);
});
it("throws EMPTY_STACK on undo when empty", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
expect(() => s.undo()).toThrowError(
expect.objectContaining({ code: UNDO_CODES.EMPTY_STACK })
);
});
it("throws EMPTY_STACK on redo when empty", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
expect(() => s.redo()).toThrowError(
expect.objectContaining({ code: UNDO_CODES.EMPTY_STACK })
);
});
it("canUndo is false initially", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
expect(s.canUndo).toBe(false);
});
it("canRedo is false initially", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
expect(s.canRedo).toBe(false);
});
});
// ---------------------------------------------------------------------------
// pushState
// ---------------------------------------------------------------------------
describe("pushState", () => {
it("replaces current state", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
s.pushState({ value: 99 });
expect(s.current.value).toBe(99);
});
it("can be undone", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
s.pushState({ value: 99 });
s.undo();
expect(s.current.value).toBe(0);
});
it("can be redone after undo", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
s.pushState({ value: 99 });
s.undo();
s.redo();
expect(s.current.value).toBe(99);
});
});
// ---------------------------------------------------------------------------
// maxEntries truncation
// ---------------------------------------------------------------------------
describe("maxEntries", () => {
it("truncates to maxEntries", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 }, { maxEntries: 3 });
for (let i = 0; i < 5; i++) s.push(inc(1));
expect(s.undoSize).toBe(3);
});
it("calls onTruncate with removed entries", { timeout: 5000 }, () => {
const onTruncate = vi.fn();
const s = new UndoStack<Counter>(
{ value: 0 },
{ maxEntries: 2, onTruncate }
);
s.push(inc(1));
s.push(inc(1));
s.push(inc(1)); // triggers truncation
expect(onTruncate).toHaveBeenCalled();
const removed = onTruncate.mock.calls[0]?.[0] as unknown[];
expect(removed.length).toBeGreaterThanOrEqual(1);
});
});
// ---------------------------------------------------------------------------
// maxMemoryBytes truncation
// ---------------------------------------------------------------------------
describe("maxMemoryBytes", () => {
it("trims entries that exceed memory limit", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 }, { maxMemoryBytes: 1 });
s.push(inc(1));
// With 1 byte limit, after adding enough commands old ones get removed
// The stack may have 0 entries since even 1 command exceeds 1 byte
expect(s.undoSize).toBeLessThanOrEqual(1);
});
it("calls onTruncate when over memory limit", { timeout: 5000 }, () => {
const onTruncate = vi.fn();
const s = new UndoStack<Counter>(
{ value: 0 },
{ maxMemoryBytes: 1, onTruncate }
);
s.push(inc(1));
expect(onTruncate).toHaveBeenCalled();
});
it("stops trimming once under memory limit (break branch)", {
timeout: 5000,
}, () => {
// A closure command is ~70-90 bytes JSON-serialised.
// maxMemoryBytes:200 allows 2 commands but not 3.
// After 3 pushes the oldest is removed, leaving 2, then break is taken.
const s = new UndoStack<Counter>({ value: 0 }, { maxMemoryBytes: 200 });
s.push(inc(1));
s.push(inc(1));
s.push(inc(1));
// After 3 pushes: total may have exceeded limit, oldest removed,
// then total drops under 200 and break fires.
expect(s.undoSize).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// Transaction
// ---------------------------------------------------------------------------
describe("transaction", () => {
it("commit applies all commands in order", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
const tx = s.beginTransaction();
tx.add(inc(10));
tx.add(inc(5));
tx.commit();
expect(s.current.value).toBe(15);
});
it("rollback discards all pending commands", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
const tx = s.beginTransaction();
tx.add(inc(10));
tx.rollback();
expect(s.current.value).toBe(0);
expect(s.undoSize).toBe(0);
});
it("throws TRANSACTION_ACTIVE on nested beginTransaction", {
timeout: 5000,
}, () => {
const s = new UndoStack<Counter>({ value: 0 });
s.beginTransaction();
expect(() => s.beginTransaction()).toThrowError(
expect.objectContaining({ code: UNDO_CODES.TRANSACTION_ACTIVE })
);
});
it("throws TRANSACTION_INACTIVE on add after commit", {
timeout: 5000,
}, () => {
const s = new UndoStack<Counter>({ value: 0 });
const tx = s.beginTransaction();
tx.commit();
expect(() => tx.add(inc(1))).toThrowError(
expect.objectContaining({ code: UNDO_CODES.TRANSACTION_INACTIVE })
);
});
it("throws TRANSACTION_INACTIVE on commit after rollback", {
timeout: 5000,
}, () => {
const s = new UndoStack<Counter>({ value: 0 });
const tx = s.beginTransaction();
tx.rollback();
expect(() => tx.commit()).toThrowError(
expect.objectContaining({ code: UNDO_CODES.TRANSACTION_INACTIVE })
);
});
it("allows new transaction after commit", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
const tx = s.beginTransaction();
tx.commit();
expect(() => s.beginTransaction()).not.toThrow();
});
});
// ---------------------------------------------------------------------------
// clear / reset
// ---------------------------------------------------------------------------
describe("clear / reset", () => {
it("clear removes history but keeps current", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
s.push(inc(10));
s.clear();
expect(s.current.value).toBe(10);
expect(s.undoSize).toBe(0);
});
it("reset changes current and clears history", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
s.push(inc(10));
s.reset({ value: 99 });
expect(s.current.value).toBe(99);
expect(s.undoSize).toBe(0);
});
});
// ---------------------------------------------------------------------------
// snapshot
// ---------------------------------------------------------------------------
describe("snapshot", () => {
it("returns current state and stack sizes", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 5 });
s.push(inc(1));
s.push(inc(1));
s.undo();
const snap = s.snapshot();
expect(snap.current.value).toBe(6);
expect(snap.undoSize).toBe(1);
expect(snap.redoSize).toBe(1);
});
it("copies stacks (immutable)", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
s.push(inc(1));
const snap = s.snapshot();
snap.undoStack.pop();
expect(s.undoSize).toBe(1); // original unaffected
});
});
// ---------------------------------------------------------------------------
// Event emission
// ---------------------------------------------------------------------------
describe("event emission", () => {
it("emits undo:push on push", { timeout: 5000 }, () => {
const bus = fakeEventBus();
const s = new UndoStack<Counter>({ value: 0 }, {}, bus as never);
const events: unknown[] = [];
bus.on("undo:push", (p) => events.push(p));
const cmd = inc(1);
s.push(cmd);
expect(events).toHaveLength(1);
expect((events[0] as Record<string, unknown>)["commandId"]).toBe(
cmd.id
);
});
it("emits undo:undo on undo", { timeout: 5000 }, () => {
const bus = fakeEventBus();
const s = new UndoStack<Counter>({ value: 0 }, {}, bus as never);
const events: unknown[] = [];
bus.on("undo:undo", (p) => events.push(p));
s.push(inc(1));
s.undo();
expect(events).toHaveLength(1);
});
it("emits undo:redo on redo", { timeout: 5000 }, () => {
const bus = fakeEventBus();
const s = new UndoStack<Counter>({ value: 0 }, {}, bus as never);
const events: unknown[] = [];
bus.on("undo:redo", (p) => events.push(p));
s.push(inc(1));
s.undo();
s.redo();
expect(events).toHaveLength(1);
});
it("emits undo:clear on clear", { timeout: 5000 }, () => {
const bus = fakeEventBus();
const s = new UndoStack<Counter>({ value: 0 }, {}, bus as never);
const events: unknown[] = [];
bus.on("undo:clear", (p) => events.push(p));
s.clear();
expect(events).toHaveLength(1);
});
it("emits undo:clear on reset", { timeout: 5000 }, () => {
const bus = fakeEventBus();
const s = new UndoStack<Counter>({ value: 0 }, {}, bus as never);
const events: unknown[] = [];
bus.on("undo:clear", (p) => events.push(p));
s.reset({ value: 99 });
expect(events).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// getUndoStack / getRedoStack
// ---------------------------------------------------------------------------
describe("getUndoStack / getRedoStack", () => {
it("returns copy of undo entries", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
s.push(inc(1));
const entries = s.getUndoStack();
entries.pop();
expect(s.undoSize).toBe(1);
});
it("returns copy of redo entries", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
s.push(inc(1));
s.undo();
const entries = s.getRedoStack();
entries.pop();
expect(s.redoSize).toBe(1);
});
});
// ---------------------------------------------------------------------------
// activeBranchId
// ---------------------------------------------------------------------------
describe("activeBranchId", () => {
it("returns main for linear stack", { timeout: 5000 }, () => {
const s = new UndoStack<Counter>({ value: 0 });
expect(s.activeBranchId).toBe("main");
});
});
// ---------------------------------------------------------------------------
// Property-based tests
// ---------------------------------------------------------------------------
describe("property: push N, undo all, redo all", () => {
it("final state equals state after push N", { timeout: 30000 }, () => {
fc.assert(
fc.property(
fc.array(fc.integer({ min: 1, max: 10 }), {
minLength: 1,
maxLength: 20,
}),
(increments) => {
const s = new UndoStack<Counter>({ value: 0 });
for (const n of increments) {
s.push(
createCommand(
`Inc${n}`,
(st) => ({ value: st.value + n }),
(st) => ({ value: st.value - n })
)
);
}
const afterPush = s.current.value;
const allUndone = increments.length;
for (let i = 0; i < allUndone; i++) s.undo();
for (let i = 0; i < allUndone; i++) s.redo();
return s.current.value === afterPush;
}
),
{ numRuns: 100 }
);
});
it("undoSize never exceeds maxEntries", { timeout: 30000 }, () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 10 }),
fc.array(fc.integer({ min: 1, max: 5 }), {
minLength: 0,
maxLength: 30,
}),
(maxEntries, increments) => {
const s = new UndoStack<Counter>(
{ value: 0 },
{ maxEntries }
);
for (const n of increments) {
s.push(
createCommand(
"I",
(st) => ({ value: st.value + n }),
(st) => ({ value: st.value - n })
)
);
}
return s.undoSize <= maxEntries;
}
),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,304 @@
import { metrics } from "@opentelemetry/api";
import type { EventBusLike } from "@suaveplan/types";
import {
estimateCommandSize,
executeCommand,
undoCommand,
} from "../command/command.js";
import { UNDO_CODES, UndoError } from "../errors/undo-error.js";
import { UndoStackOptionsSchema } from "../schemas/undo.schemas.js";
import type {
Command,
StackEntry,
TransactionHandle,
UndoEventMap,
UndoStackOptions,
UndoStackState,
} from "../types/undo.types.js";
// ---------------------------------------------------------------------------
// OTel meter
// ---------------------------------------------------------------------------
const meter = metrics.getMeter("undo");
const pushCounter = meter.createCounter("undo.push.total");
const undoCounter = meter.createCounter("undo.undo.total");
const redoCounter = meter.createCounter("undo.redo.total");
const depthHistogram = meter.createHistogram("undo.stack.depth");
// ---------------------------------------------------------------------------
// UndoStack
// ---------------------------------------------------------------------------
/**
* A synchronous linear undo/redo stack with optional memory limits,
* transaction support, and `EventBusLike` integration.
*/
export class UndoStack<T> {
protected _stack: StackEntry<T>[] = [];
protected _redoStack: StackEntry<T>[] = [];
protected _current: T;
protected _options: UndoStackOptions;
protected _bus: EventBusLike<UndoEventMap> | undefined;
protected _txActive = false;
protected _txEntries: Command<T>[] = [];
public constructor(
initial: T,
options?: UndoStackOptions,
bus?: EventBusLike<UndoEventMap>
) {
const parsed = UndoStackOptionsSchema.parse(options ?? {});
this._options = parsed as UndoStackOptions;
this._current = initial;
this._bus = bus;
}
// ---------------------------------------------------------------------------
// Getters
// ---------------------------------------------------------------------------
public get current(): T {
return this._current;
}
public get canUndo(): boolean {
return this._stack.length > 0;
}
public get canRedo(): boolean {
return this._redoStack.length > 0;
}
public get undoSize(): number {
return this._stack.length;
}
public get redoSize(): number {
return this._redoStack.length;
}
public get activeBranchId(): string {
return "main";
}
// ---------------------------------------------------------------------------
// push
// ---------------------------------------------------------------------------
/**
* Pushes a command onto the stack and executes it.
* Clears the redo stack. Trims if `maxEntries` or `maxMemoryBytes` exceeded.
*/
public push(command: Command<T>): T {
const stateBefore = this._current;
this._current = executeCommand(this._current, command);
this._stack.push({ command, stateBefore });
this._redoStack = [];
this._trim();
this._recordPush(command);
return this._current;
}
/**
* Pushes a state snapshot directly (no command).
* Creates an internal command that replaces state on execute/undo.
*/
public pushState(newState: T): T {
const prev = this._current;
const cmd: Command<T> = {
id: (this._options.idGenerator ?? (() => crypto.randomUUID()))(),
label: "pushState",
timestamp: (this._options.clock ?? Date.now)(),
execute: () => newState,
undo: () => prev,
};
this._stack.push({ command: cmd, stateBefore: prev });
this._current = newState;
this._redoStack = [];
this._trim();
this._recordPush(cmd);
return this._current;
}
// ---------------------------------------------------------------------------
// undo
// ---------------------------------------------------------------------------
/**
* Undoes the most recent command. Returns the restored state.
* Throws `UndoError(UNDO.EMPTY_STACK)` if nothing to undo.
*/
public undo(): T {
const entry = this._stack.pop();
if (!entry) {
throw new UndoError(UNDO_CODES.EMPTY_STACK, "Nothing to undo");
}
this._current = undoCommand(this._current, entry.command);
this._redoStack.push(entry);
undoCounter.add(1, { stack_type: "linear" });
depthHistogram.record(this._stack.length);
this._bus?.emit("undo:undo", {
commandId: entry.command.id,
label: entry.command.label,
undoSize: this._stack.length,
});
return this._current;
}
// ---------------------------------------------------------------------------
// redo
// ---------------------------------------------------------------------------
/**
* Re-applies the most recently undone command. Returns the restored state.
* Throws `UndoError(UNDO.EMPTY_STACK)` if nothing to redo.
*/
public redo(): T {
const entry = this._redoStack.pop();
if (!entry) {
throw new UndoError(UNDO_CODES.EMPTY_STACK, "Nothing to redo");
}
this._current = executeCommand(this._current, entry.command);
this._stack.push(entry);
redoCounter.add(1, { stack_type: "linear" });
depthHistogram.record(this._stack.length);
this._bus?.emit("undo:redo", {
commandId: entry.command.id,
label: entry.command.label,
redoSize: this._redoStack.length,
});
return this._current;
}
// ---------------------------------------------------------------------------
// Transaction
// ---------------------------------------------------------------------------
/**
* Begins a transaction. Returns a handle for adding, committing, or rolling back.
* Throws `UndoError(UNDO.TRANSACTION_ACTIVE)` if a transaction is already open.
*/
public beginTransaction(): TransactionHandle<T> {
if (this._txActive) {
throw new UndoError(
UNDO_CODES.TRANSACTION_ACTIVE,
"A transaction is already active"
);
}
this._txActive = true;
this._txEntries = [];
let closed = false;
const guard = (): void => {
if (closed) {
throw new UndoError(
UNDO_CODES.TRANSACTION_INACTIVE,
"Transaction is already closed"
);
}
};
const handle: TransactionHandle<T> = {
add: (command: Command<T>) => {
guard();
this._txEntries.push(command);
},
commit: () => {
guard();
closed = true;
this._txActive = false;
for (const cmd of this._txEntries) {
this.push(cmd);
}
this._txEntries = [];
},
rollback: () => {
guard();
closed = true;
this._txActive = false;
this._txEntries = [];
},
};
return handle;
}
// ---------------------------------------------------------------------------
// clear / reset
// ---------------------------------------------------------------------------
/** Clears all undo and redo history, keeping current state. */
public clear(): void {
this._stack = [];
this._redoStack = [];
this._bus?.emit("undo:clear", {});
}
/** Resets the stack to a new initial state, clearing all history. */
public reset(newInitial: T): void {
this._current = newInitial;
this._stack = [];
this._redoStack = [];
this._bus?.emit("undo:clear", {});
}
// ---------------------------------------------------------------------------
// snapshot / getters
// ---------------------------------------------------------------------------
/** Returns a serializable snapshot of the current stack state. */
public snapshot(): UndoStackState<T> {
return {
current: this._current,
undoStack: [...this._stack],
redoStack: [...this._redoStack],
undoSize: this._stack.length,
redoSize: this._redoStack.length,
};
}
/** Returns a copy of the undo stack entries. */
public getUndoStack(): StackEntry<T>[] {
return [...this._stack];
}
/** Returns a copy of the redo stack entries. */
public getRedoStack(): StackEntry<T>[] {
return [...this._redoStack];
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
protected _recordPush(command: Command<T>): void {
pushCounter.add(1, { stack_type: "linear" });
depthHistogram.record(this._stack.length);
this._bus?.emit("undo:push", {
commandId: command.id,
label: command.label,
undoSize: this._stack.length,
});
}
protected _trim(): void {
const { maxEntries, maxMemoryBytes, onTruncate } = this._options;
const removed: StackEntry<unknown>[] = [];
if (maxEntries !== undefined) {
while (this._stack.length > maxEntries) {
removed.push(this._stack.shift()! as StackEntry<unknown>);
}
}
if (maxMemoryBytes !== undefined) {
while (this._stack.length > 0) {
const totalBytes = this._stack.reduce(
(acc, e) => acc + estimateCommandSize(e.command),
0
);
if (totalBytes <= maxMemoryBytes) break;
removed.push(this._stack.shift()! as StackEntry<unknown>);
}
}
if (removed.length > 0 && onTruncate) {
onTruncate(removed);
}
}
}

View File

@@ -0,0 +1,26 @@
export type {
AsyncCommand,
AsyncTransactionHandle,
Branch,
BranchingOptions,
BranchingState,
Clock,
Command,
CommandMergerOptions,
CompressionResult,
DependencyChecker,
EventBusLike,
HistoryCompressorOptions,
IdGenerator,
MergePredicate,
Patch,
PatchCommand,
SelectiveUndoOptions,
SelectiveUndoResult,
StackEntry,
TransactionHandle,
UndoEventMap,
UndoStackOptions,
UndoStackState,
} from "./undo.types.js";
export { isAsyncCommand, isPatchCommand } from "./undo.types.js";

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.

View File

@@ -0,0 +1,297 @@
import type { Patch } from "@suaveplan/diff";
import type { Clock, EventBusLike, IdGenerator } from "@suaveplan/types";
// ---------------------------------------------------------------------------
// Re-export dependency types used publicly
// ---------------------------------------------------------------------------
export type { Clock, EventBusLike, IdGenerator, Patch };
// ---------------------------------------------------------------------------
// Command
// ---------------------------------------------------------------------------
/**
* A synchronous command that can execute a state transition and reverse it.
*/
export interface Command<T> {
/** Unique identifier for this command instance. */
readonly id: string;
/** Human-readable description of what this command does. */
readonly label: string;
/** Timestamp (ms since epoch) at which the command was created. */
readonly timestamp: number;
/** Execute the command, returning the new state. */
execute: (state: T) => T;
/** Undo the command, returning the previous state. */
undo: (state: T) => T;
/** Arbitrary metadata attached to this command. */
readonly metadata?: Record<string, unknown>;
}
/**
* A command backed by a JSON Patch pair — execute applies `patch`, undo applies `inversePatch`.
*/
export interface PatchCommand<T> extends Command<T> {
readonly type: "patch";
/** RFC 6902 patch to apply on execute. */
readonly patch: Patch;
/** RFC 6902 patch to apply on undo (inverse of `patch`). */
readonly inversePatch: Patch;
}
/**
* An asynchronous command with async execute and undo operations.
*/
export interface AsyncCommand<T> {
readonly id: string;
readonly label: string;
readonly timestamp: number;
execute: (state: T) => Promise<T>;
undo: (state: T) => Promise<T>;
readonly metadata?: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Stack entry
// ---------------------------------------------------------------------------
/**
* An entry on the undo/redo stack, pairing a command with the state snapshot
* taken immediately before the command was executed.
*/
export interface StackEntry<T> {
readonly command: Command<T>;
/** State before this command was executed (used for rollback). */
readonly stateBefore: T;
}
// ---------------------------------------------------------------------------
// Branch
// ---------------------------------------------------------------------------
/**
* A named branch in a `BranchingHistory`, representing an independent
* timeline of undo/redo entries.
*/
export interface Branch<T> {
readonly id: string;
readonly label: string;
readonly createdAt: number;
readonly undoStack: StackEntry<T>[];
readonly redoStack: StackEntry<T>[];
/** State at the tip of this branch (after the last command was executed). */
current: T;
}
// ---------------------------------------------------------------------------
// Options
// ---------------------------------------------------------------------------
/**
* Options for constructing a `UndoStack`.
*/
export interface UndoStackOptions {
/** Maximum number of undo entries to retain (default: unlimited). */
maxEntries?: number;
/** Maximum total memory in bytes for stored commands (default: unlimited). */
maxMemoryBytes?: number;
/** Called with the truncated entries whenever the stack is trimmed. */
onTruncate?: (removed: StackEntry<unknown>[]) => void;
/** Injectable clock for deterministic timestamps (default: `Date.now`). */
clock?: Clock;
/** Injectable ID generator (default: `crypto.randomUUID`). */
idGenerator?: IdGenerator;
}
/**
* Options for constructing a `BranchingHistory`.
*/
export interface BranchingOptions {
/** Label for the initial branch (default: `"main"`). */
initialBranchLabel?: string;
/** When `true` (default), pushing with pending redo entries forks them to a new branch instead of discarding. */
branchOnDiverge?: boolean;
/** Maximum number of undo entries per branch. */
maxEntries?: number;
/** Injectable clock. */
clock?: Clock;
/** Injectable ID generator. */
idGenerator?: IdGenerator;
}
/**
* Options for `HistoryCompressor`.
*/
export interface HistoryCompressorOptions {
/**
* Number of most-recent entries to keep uncompressed.
* Entries older than this depth are eligible for squashing (default: 50).
*/
compressionDepth?: number;
/**
* Maximum total entries to retain after compression.
* If set, the oldest entries beyond this limit are dropped (default: unlimited).
*/
maxHistorySize?: number;
}
/**
* Options for `SelectiveUndoStack`.
*/
export interface SelectiveUndoOptions extends UndoStackOptions {
/**
* When `true`, `undoById` throws `UndoError(UNDO.DEPENDENCY_CONFLICT)` if
* any later command depends on the target. When `false` (default) it returns
* a result with `success: false` and a `conflictIds` list.
*/
throwOnConflict?: boolean;
/**
* Custom dependency check. Returns `true` if `later` depends on `earlier`.
* Default: always returns `false` (no dependencies).
*/
dependencyCheck?: DependencyChecker<unknown>;
}
/**
* Options for `CommandMerger`.
*/
export interface CommandMergerOptions {
/** Maximum number of commands to accumulate before auto-flushing (default: unlimited). */
maxCount?: number;
/** Time window in ms; once this elapses the buffer auto-flushes (default: unlimited). */
timeWindowMs?: number;
/** Injectable clock for time-window calculation. */
clock?: Clock;
}
// ---------------------------------------------------------------------------
// State snapshots
// ---------------------------------------------------------------------------
/**
* Full serializable snapshot of an `UndoStack`.
*/
export interface UndoStackState<T> {
readonly current: T;
readonly undoStack: StackEntry<T>[];
readonly redoStack: StackEntry<T>[];
readonly undoSize: number;
readonly redoSize: number;
}
/**
* Full serializable snapshot of a `BranchingHistory`.
*/
export interface BranchingState<T> {
readonly current: T;
readonly activeBranchId: string;
readonly branches: Record<string, Branch<T>>;
}
// ---------------------------------------------------------------------------
// Transaction handles
// ---------------------------------------------------------------------------
/**
* Handle returned by `UndoStack.beginTransaction`.
* Use `add` to accumulate commands, `commit` to apply them atomically,
* or `rollback` to discard them.
*/
export interface TransactionHandle<T> {
add(command: Command<T>): void;
commit(): void;
rollback(): void;
}
/**
* Async version of `TransactionHandle`.
*/
export interface AsyncTransactionHandle<T> {
add(command: AsyncCommand<T>): void;
commit(): Promise<void>;
rollback(): Promise<void>;
}
// ---------------------------------------------------------------------------
// Compression result
// ---------------------------------------------------------------------------
/**
* Result returned by `HistoryCompressor.compress` and `compressHistory`.
*/
export interface CompressionResult {
/** Number of entries that were squashed into fewer composite entries. */
readonly squashedCount: number;
/** Number of entries removed to enforce `maxHistorySize`. */
readonly droppedCount: number;
/** Total entries in the new stack. */
readonly newSize: number;
}
// ---------------------------------------------------------------------------
// Selective undo result
// ---------------------------------------------------------------------------
/**
* Result of `SelectiveUndoStack.undoById`.
*/
export interface SelectiveUndoResult<T> {
readonly success: boolean;
/** New state after selective undo (undefined when `success` is false). */
readonly state?: T;
/** IDs of commands that conflict with the requested undo. */
readonly conflictIds: string[];
}
// ---------------------------------------------------------------------------
// Helper types
// ---------------------------------------------------------------------------
/**
* Predicate used by `SelectiveUndoStack` to detect whether a later command
* depends on an earlier one.
*/
export type DependencyChecker<T> = (
earlier: Command<T>,
later: Command<T>
) => boolean;
/**
* Predicate used by `CommandMerger` to determine whether two adjacent
* commands can be merged.
*/
export type MergePredicate<T> = (a: Command<T>, b: Command<T>) => boolean;
// ---------------------------------------------------------------------------
// Event map for UndoStack bus events
// ---------------------------------------------------------------------------
export interface UndoEventMap extends Record<string, unknown> {
"undo:push": { commandId: string; label: string; undoSize: number };
"undo:undo": { commandId: string; label: string; undoSize: number };
"undo:redo": { commandId: string; label: string; redoSize: number };
"undo:clear": Record<string, never>;
}
// ---------------------------------------------------------------------------
// Type guards
// ---------------------------------------------------------------------------
/**
* Returns `true` if `cmd` is a `PatchCommand`.
*/
export function isPatchCommand<T>(cmd: Command<T>): cmd is PatchCommand<T> {
return (cmd as PatchCommand<T>).type === "patch";
}
/**
* Returns `true` if `cmd` is an `AsyncCommand`.
*/
export function isAsyncCommand<T>(
cmd: Command<T> | AsyncCommand<T>
): cmd is AsyncCommand<T> {
return (
typeof (cmd as AsyncCommand<T>).execute === "function" &&
(cmd as AsyncCommand<T>).execute.constructor.name === "AsyncFunction"
);
}

View File

@@ -0,0 +1,42 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const pkg = JSON.parse(
readFileSync(resolve(import.meta.dirname, "../package.json"), "utf-8")
) as Record<string, unknown>;
describe("dep-graph", () => {
it("has sideEffects: false", { timeout: 5000 }, () => {
expect(pkg["sideEffects"]).toBe(false);
});
it("has no disallowed @suaveplan/* runtime deps (only error, types, events, diff)", {
timeout: 5000,
}, () => {
const deps = Object.keys(
(pkg["dependencies"] as Record<string, string>) ?? {}
);
const suaveDeps = deps.filter((d) => d.startsWith("@suaveplan/"));
const allowed = new Set([
"@suaveplan/error",
"@suaveplan/types",
"@suaveplan/events",
"@suaveplan/diff",
]);
for (const dep of suaveDeps) {
expect(allowed.has(dep), `unexpected dep: ${dep}`).toBe(true);
}
});
it("workspace deps use workspace:^ range", { timeout: 5000 }, () => {
const deps = (pkg["dependencies"] as Record<string, string>) ?? {};
for (const [name, version] of Object.entries(deps)) {
if (name.startsWith("@suaveplan/")) {
expect(version, `${name} should use workspace:^`).toBe(
"workspace:^"
);
}
}
});
});

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createAsyncCommand } from "../src/async/async-command.js";
import { AsyncUndoStack } from "../src/async/async-stack.js";
import { BranchingHistory } from "../src/branch/branch.js";
import { createCommand } from "../src/command/command.js";
import { compressHistory } from "../src/compression/history-compressor.js";
import { SelectiveUndoStack } from "../src/selective/selective-undo.js";
import { UndoStack } from "../src/stack/stack.js";
// OTel meters are no-ops by default (no exporter registered) — we just verify
// that operations complete without throwing and that the meter API is exercised.
// The default @opentelemetry/api ships a NoopMeterProvider.
interface Counter {
value: number;
}
const mkCmd = (n = 1) =>
createCommand<Counter>(
`Inc${n}`,
(s) => ({ value: s.value + n }),
(s) => ({ value: s.value - n })
);
describe("OTel instrumentation (no-op SDK)", () => {
it("UndoStack push increments counter without throwing", {
timeout: 5000,
}, () => {
const s = new UndoStack<Counter>({ value: 0 });
expect(() => {
s.push(mkCmd(1));
s.push(mkCmd(2));
}).not.toThrow();
});
it("UndoStack undo increments counter without throwing", {
timeout: 5000,
}, () => {
const s = new UndoStack<Counter>({ value: 0 });
s.push(mkCmd(1));
expect(() => s.undo()).not.toThrow();
});
it("UndoStack redo increments counter without throwing", {
timeout: 5000,
}, () => {
const s = new UndoStack<Counter>({ value: 0 });
s.push(mkCmd(1));
s.undo();
expect(() => s.redo()).not.toThrow();
});
it("BranchingHistory push, undo, redo without throwing", {
timeout: 5000,
}, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
expect(() => {
h.push(mkCmd(1));
h.undo();
h.redo();
}).not.toThrow();
});
it("AsyncUndoStack push/undo/redo without throwing", {
timeout: 30000,
}, async () => {
const s = new AsyncUndoStack<Counter>({ value: 0 });
const aCmd = createAsyncCommand<Counter>(
"AI",
async (st) => ({ value: st.value + 1 }),
async (st) => ({ value: st.value - 1 })
);
await s.push(aCmd);
await s.undo();
await s.redo();
expect(s.current.value).toBe(1);
});
it("compressHistory records compress counter without throwing", {
timeout: 5000,
}, () => {
const s = new UndoStack<Counter>({ value: 0 });
for (let i = 0; i < 20; i++) s.push(mkCmd(1));
expect(() => compressHistory(s, { compressionDepth: 5 })).not.toThrow();
});
it("SelectiveUndoStack undoById records undo counter", {
timeout: 5000,
}, () => {
const s = new SelectiveUndoStack<Counter>({ value: 0 });
const a = mkCmd(1);
s.push(a);
expect(() => s.undoById(a.id)).not.toThrow();
});
it("BranchingHistory switchBranch without throwing", {
timeout: 5000,
}, () => {
const h = new BranchingHistory<Counter>({ value: 0 });
h.push(mkCmd(1));
h.undo();
h.push(mkCmd(2)); // diverge → fork
const forked = h.branches.find((b) => b.label.startsWith("fork-"));
if (forked) {
expect(() => h.switchBranch(forked.id)).not.toThrow();
}
});
});

View File

@@ -0,0 +1,113 @@
import { describe, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { BranchingHistory } from "../../src/branch/branch.js";
import { createCommand } from "../../src/command/command.js";
interface Counter {
value: number;
}
describe("property: BranchingHistory invariants", () => {
it("branch isolation: pushing on one branch doesn't change another", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.array(fc.integer({ min: 1, max: 5 }), {
minLength: 2,
maxLength: 10,
}),
fc.array(fc.integer({ min: 1, max: 5 }), {
minLength: 1,
maxLength: 5,
}),
(mainDeltas, extraDeltas) => {
const h = new BranchingHistory<Counter>({ value: 0 });
for (const d of mainDeltas) {
h.push(
createCommand(
"D",
(s) => ({ value: s.value + d }),
(s) => ({ value: s.value - d })
)
);
}
h.undo(); // set up diverge
const _stateBeforeFork = h.current.value;
h.push(
createCommand(
"Fork",
(s) => ({ value: s.value + 99 }),
(s) => ({ value: s.value - 99 })
)
);
const forked = h.branches.find((b) =>
b.label.startsWith("fork-")
);
if (!forked) return true; // nothing to test
const mainId = h.branches.find(
(b) => b.label === "main"
)?.id;
if (!mainId) return true;
const mainCurrent = h.current.value; // on main now
// Switch to forked and push more
h.switchBranch(forked.id);
for (const d of extraDeltas) {
h.push(
createCommand(
"E",
(s) => ({ value: s.value + d }),
(s) => ({ value: s.value - d })
)
);
}
// Switch back to main — must be unchanged
h.switchBranch(mainId);
return h.current.value === mainCurrent;
}
),
{ numRuns: 100 }
);
});
it("undo/redo cursor: canUndo + canRedo invariant holds", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.array(fc.integer({ min: 1, max: 5 }), {
minLength: 1,
maxLength: 20,
}),
fc.integer({ min: 0, max: 20 }),
(deltas, undoSteps) => {
const h = new BranchingHistory<Counter>(
{ value: 0 },
{ branchOnDiverge: false }
);
for (const d of deltas) {
h.push(
createCommand(
"D",
(s) => ({ value: s.value + d }),
(s) => ({ value: s.value - d })
)
);
}
const steps = Math.min(undoSteps, deltas.length);
for (let i = 0; i < steps; i++) h.undo();
const branch = h.getBranch(h.activeBranchId)!;
return (
branch.undoStack.length + branch.redoStack.length ===
deltas.length
);
}
),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,45 @@
import { describe, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import {
createCommand,
executeCommand,
undoCommand,
} from "../../src/command/command.js";
interface State {
x: number;
y: number;
label: string;
}
describe("property: command round-trip", () => {
it("execute then undo restores state for arbitrary closures", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.record({
x: fc.integer(),
y: fc.integer(),
label: fc.string(),
}),
fc.integer({ min: 1, max: 100 }),
(initial, delta) => {
const cmd = createCommand<State>(
"Transform",
(s) => ({ ...s, x: s.x + delta }),
(s) => ({ ...s, x: s.x - delta })
);
const after = executeCommand(initial, cmd);
const back = undoCommand(after, cmd);
return (
back.x === initial.x &&
back.y === initial.y &&
back.label === initial.label
);
}
),
{ numRuns: 200 }
);
});
});

View File

@@ -0,0 +1,45 @@
import { describe, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { createCommand } from "../../src/command/command.js";
import { compressHistory } from "../../src/compression/history-compressor.js";
import { UndoStack } from "../../src/stack/stack.js";
interface Counter {
value: number;
}
describe("property: compressHistory idempotency", () => {
it("re-compressing does not change state or exceed original size", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 50 }),
fc.integer({ min: 1, max: 20 }),
(pushCount, depth) => {
const s = new UndoStack<Counter>({ value: 0 });
for (let i = 0; i < pushCount; i++) {
s.push(
createCommand(
"Inc",
(st) => ({ value: st.value + 1 }),
(st) => ({ value: st.value - 1 })
)
);
}
const r1 = compressHistory(s, { compressionDepth: depth });
const r2 = compressHistory(r1.stack, {
compressionDepth: depth,
});
// State preserved
if (r1.stack.current.value !== r2.stack.current.value)
return false;
// Second compression adds no new squashes (all same-label entries already merged)
// Size should not increase
return r2.newSize <= r1.newSize;
}
),
{ numRuns: 200 }
);
});
});

View File

@@ -0,0 +1,51 @@
import { describe, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { createCommand, executeCommand } from "../../src/command/command.js";
import { CommandMerger } from "../../src/merge/command-merger.js";
interface Counter {
value: number;
}
describe("property: CommandMerger merge associativity", () => {
it("merged command executes to same state as executing individually", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.array(fc.integer({ min: 1, max: 10 }), {
minLength: 1,
maxLength: 15,
}),
(deltas) => {
const commands = deltas.map((d) =>
createCommand<Counter>(
"Inc",
(s) => ({ value: s.value + d }),
(s) => ({ value: s.value - d })
)
);
// Execute individually
let individual: Counter = { value: 0 };
for (const cmd of commands) {
individual = executeCommand(individual, cmd);
}
// Merge all then execute
const m = new CommandMerger<Counter>({
maxCount: commands.length + 1,
});
for (const cmd of commands) m.add(cmd);
const merged = m.flush();
const mergedResult = merged
? executeCommand({ value: 0 }, merged)
: { value: 0 };
return mergedResult.value === individual.value;
}
),
{ numRuns: 200 }
);
});
});

View File

@@ -0,0 +1,99 @@
import { describe, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { createCommand } from "../../src/command/command.js";
import { UndoStack } from "../../src/stack/stack.js";
interface Counter {
value: number;
}
describe("property: UndoStack invariants", () => {
it("push N, undo all, redo all: final state = after-push state", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.array(fc.integer({ min: 1, max: 10 }), {
minLength: 1,
maxLength: 20,
}),
(deltas) => {
const s = new UndoStack<Counter>({ value: 0 });
for (const d of deltas) {
s.push(
createCommand(
"D",
(st) => ({ value: st.value + d }),
(st) => ({ value: st.value - d })
)
);
}
const afterPush = s.current.value;
for (let i = 0; i < deltas.length; i++) s.undo();
for (let i = 0; i < deltas.length; i++) s.redo();
return s.current.value === afterPush;
}
),
{ numRuns: 200 }
);
});
it("undoSize never exceeds maxEntries", { timeout: 30000 }, () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 20 }),
fc.array(fc.integer({ min: 1, max: 5 }), {
minLength: 0,
maxLength: 50,
}),
(maxEntries, deltas) => {
const s = new UndoStack<Counter>(
{ value: 0 },
{ maxEntries }
);
for (const d of deltas) {
s.push(
createCommand(
"D",
(st) => ({ value: st.value + d }),
(st) => ({ value: st.value - d })
)
);
}
return s.undoSize <= maxEntries;
}
),
{ numRuns: 200 }
);
});
it("undoSize + redoSize = total pushed (within maxEntries limit)", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.array(fc.integer({ min: 1, max: 5 }), {
minLength: 0,
maxLength: 15,
}),
fc.integer({ min: 0, max: 15 }),
(deltas, undoSteps) => {
const s = new UndoStack<Counter>({ value: 0 });
for (const d of deltas) {
s.push(
createCommand(
"D",
(st) => ({ value: st.value + d }),
(st) => ({ value: st.value - d })
)
);
}
const steps = Math.min(undoSteps, deltas.length);
for (let i = 0; i < steps; i++) s.undo();
return s.undoSize + s.redoSize === deltas.length;
}
),
{ numRuns: 200 }
);
});
});

View File

@@ -0,0 +1,8 @@
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/undo",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"@suaveplan/events",
"@suaveplan/diff",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,26 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});