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