Skip to content

API Overview ​

SymbolPurposeExecutionCommon gotcha
createLedger()Create serialized reversible historySync constructionEvery operation method is queued
compose()Combine commands into one history entrySync constructionChild metadata is not aggregated
ledger.do()Apply and record a commandAsyncFailed or cancelled apply is not recorded
ledger.undo() / redo()Move the latest history entryAsyncFailures leave history on its original side
ledger.clear()Remove history without revertingAsyncSerialized behind earlier work
ledger.whenIdle()Await an empty operation queueAsyncCannot force non-cooperative work to settle

Package Entry Point ​

ImportPurpose
@vielzeug/ledgerComplete Ledger runtime, error hierarchy, state-readable contract, unsubscribe type, and command/history types

createLedger() ​

ts
function createLedger<TMeta = undefined>(options?: LedgerOptions): Ledger<TMeta>

Creates a ledger with one submission-order operation queue and one atomic state readable.

ts
import { createLedger } from '@vielzeug/ledger';

const ledger = createLedger<{ field: string }>({ maxHistory: 50 });

LedgerOptions ​

ts
interface LedgerOptions {
  maxHistory?: number;
}

maxHistory defaults to 100. It must be a non-negative safe integer. 0 executes commands without retaining history.

compose() ​

ts
function compose<TMeta = undefined>(
  commands: readonly ReversibleCommand<TMeta>[],
  label?: string,
): ReversibleCommand<TMeta>

Snapshots child command callbacks, applies children in order, and reverts them in reverse order. The result creates one history entry with the supplied label and meta: undefined.

If child application fails, completed children are compensated in reverse order. Compensation attempts continue after failures. Combined apply and compensation failures are preserved in an AggregateError, which Ledger exposes as LedgerExecutionError.cause. Normal composed reversion also attempts every child and reports failures through LedgerRollbackError.cause as an AggregateError.

Ledger ​

ts
interface Ledger<TMeta = undefined> {
  clear(): Promise<void>;
  readonly disposalSignal: AbortSignal;
  dispose(): void;
  readonly disposed: boolean;
  do(command: ReversibleCommand<TMeta>, options?: LedgerCallOptions): Promise<void>;
  redo(options?: LedgerCallOptions): Promise<void>;
  readonly state: Subscribable<LedgerState<TMeta>>;
  undo(options?: LedgerCallOptions): Promise<void>;
  whenIdle(): Promise<void>;
  [Symbol.dispose](): void;
}

All operation methods are serialized in call order.

do() ​

Snapshots the command's apply, revert, label, and metadata reference when submitted. On successful apply it appends one undo entry, evicts the oldest entry beyond maxHistory, and clears redo history. Apply failure rejects with LedgerExecutionError and records nothing. Ledger does not automatically revert a failed individual command.

undo() ​

Reverts the final undo entry. Success moves it to the end of redo history. If no undo entry exists, the queued operation resolves without changing history. Revert failure rejects with LedgerRollbackError and leaves the entry in undo history.

redo() ​

Applies the final redo entry. Success moves it to the end of undo history. If no redo entry exists, it resolves without changing history. Apply failure rejects with LedgerExecutionError and leaves the entry in redo history.

record() ​

Appends an already-executed command to undo history synchronously, without running it through the queue. For stores that execute their own writes: typically synchronous, embedded state stores: undo() calls the recorded revert, and redo() calls the recorded apply, so apply must be able to re-apply the effect. Recording shares do()'s bookkeeping: it clears redo history and evicts the oldest entry beyond maxHistory. It throws LedgerDisposedError on a disposed ledger.

clear() ​

Queues removal of all undo and redo entries. It does not call command revert functions and does not cancel earlier work. When history is already empty it resolves without queueing; after disposal it rejects with LedgerDisposedError.

whenIdle() ​

Resolves immediately when queued and running are zero, otherwise resolves after both reach zero. Multiple waiters are supported. It rejects with LedgerDisposedError when the ledger is disposed, immediately or while waiting.

Disposal ​

dispose() is permanent and idempotent. It clears undo and redo history, aborts active command contexts, rejects queued operations that have not started with LedgerDisposedError, and rejects pending whenIdle() waiters. Active commands must observe their signal and settle before running reaches zero. Read disposed to detect permanent disposal. [Symbol.dispose]() delegates to dispose().

Command Types ​

ReversibleCommand ​

ts
interface ReversibleCommand<TMeta = undefined> {
  readonly apply: (context: CommandContext) => Promise<void> | void;
  readonly label?: string;
  readonly meta?: TMeta;
  readonly revert: (context: CommandContext) => Promise<void> | void;
}

apply and revert may be synchronous or asynchronous. Metadata is retained by reference; Ledger does not deep-clone or deep-freeze it.

CommandContext ​

ts
interface CommandContext {
  readonly signal: AbortSignal;
}

The signal combines ledger disposal with the operation signal. Queued cancellation prevents user code from running. Active cancellation is cooperative. If user code ignores abort and completes, the operation still rejects with LedgerCancelledError and its history transition is not committed.

LedgerCallOptions ​

ts
interface LedgerCallOptions {
  signal?: AbortSignal;
}

Supported by do(), undo(), and redo(). clear() has no call options.

State Types ​

LedgerState ​

ts
interface LedgerState<TMeta = undefined> {
  readonly queued: number;
  readonly redo: readonly HistoryEntry<TMeta>[];
  readonly running: number;
  readonly undo: readonly HistoryEntry<TMeta>[];
}
FieldMeaning
queuedSubmitted operations that have not started
runningCurrently executing operation; serialization keeps this at 0 or 1
undoChronological successfully applied history; final entry is next to undo
redoChronological reverted history; final entry is next to redo

State objects and history arrays are frozen replacements.

HistoryEntry ​

ts
interface HistoryEntry<TMeta = undefined> {
  readonly label: string | undefined;
  readonly meta: TMeta | undefined;
}

History entries are frozen and intentionally omit command callbacks.

Subscribable and Unsubscribe ​

Ledger.state implements this framework-neutral structural contract, which matches Subscribable in @vielzeug/arsenal. Both names are root type exports.

ts
type Unsubscribe = () => void;

interface Subscribable<T> {
  getSnapshot(): T;
  subscribe(listener: () => void): Unsubscribe;
}

Subscriptions are not immediate and receive no value argument. Listeners run synchronously from a snapshot after state replacement. A listener failure does not interrupt Ledger bookkeeping or later listeners; it is rethrown in a microtask.

Errors ​

ErrorTriggerHistory result
LedgerCancelledErrorQueued signal cancellation or active cooperative cancellationNo transition committed
LedgerDisposedErrorSubmission after disposal or queued work rejected by disposalHistory already cleared by disposal
LedgerExecutionErrorapply() fails during do() or redo()New command absent, or redo entry retained
LedgerRollbackErrorrevert() fails during undo()Undo entry retained
LedgerConfigErrorInvalid maxHistoryConstruction fails
LedgerErrorBase class and internal history corruptionDepends on operation

Wrapped operation failures preserve the original value in .cause. Use instanceof LedgerError to catch the hierarchy, then narrow to a specific subtype.