Files
skills/docs/foundation/flow/undo/src/async/async-stack.md
2026-05-29 08:20:56 +01:00

4.4 KiB

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

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

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.

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.