Ledger 3.1
Ledger 3.1 unifies the observable-state contract, removes redundant state, and tightens disposal behavior.
state is now Subscribable
LedgerReadable is replaced by the exported Subscribable contract, which matches Subscribable in @vielzeug/arsenal: getSnapshot() and subscribe(listener). The duplicate value getter and peek() read are gone: one read shape remains.
// Before
const undo = ledger.state.value.undo;
const undo2 = ledger.state.peek().undo;
// After
const undo = ledger.state.getSnapshot().undo;accepting is removed
LedgerState.accepting duplicated ledger.disposed (it only ever flipped during dispose()). Read disposed instead.
// Before
const { accepting } = ledger.state.getSnapshot();
// After
const accepting = !ledger.disposed;whenIdle() rejects on disposal
whenIdle() no longer resolves against a disposed ledger. It rejects with LedgerDisposedError immediately if already disposed, or while waiting if disposal happens first: matching do(), undo(), redo(), and clear().
clear() skips empty history
clear() resolves without queueing when undo and redo are already empty, and rejects with LedgerDisposedError after disposal.
LedgerConfigurationError is renamed
The config error is now LedgerConfigError, matching the <Pkg>ConfigError name used across the monorepo.
Ledger 3.0
Ledger 3.0 removes its Ripple dependency and runtime injection. Command, queue, history, cancellation, composition, and command-error behavior remain unchanged. State subscriptions become framework-neutral invalidation callbacks with isolated listener failures.
Remove the runtime option
// Before
import * as ripple from '@vielzeug/ripple';
const ledger = createLedger({ maxHistory: 50, runtime: ripple });
// After
const ledger = createLedger({ maxHistory: 50 });LedgerOptions now contains only maxHistory.
Adapt the structural state readable
ledger.state is no longer a Ripple signal. It implements a framework-neutral structural contract with getSnapshot() and subscribe(listener). Subscriptions are invalidation callbacks: they are not immediate, receive no value argument, and run from a listener snapshot. Failures are rethrown in a microtask without interrupting Ledger bookkeeping or later subscribers.
// Before: Ripple dependency tracking
const stop = effect(() => renderHistory(ledger.state.getSnapshot()));
// After: framework-neutral subscription
const render = () => renderHistory(ledger.state.getSnapshot());
render();
const stop = ledger.state.subscribe(render);Project it into Ripple explicitly when the application needs Ripple derivations:
import { signal } from '@vielzeug/ripple';
const state = signal(ledger.state.getSnapshot());
const stop = ledger.state.subscribe(() => {
state.value = ledger.state.getSnapshot();
});State replacements, history arrays, and history entries remain frozen. Metadata remains a caller-owned reference.
Ledger 2.0
Ledger 2.0 accepts only reversible commands, publishes one atomic state snapshot, and separates queued cancellation from active cooperative cancellation.
Rename commands
Replace optional execute and rollback with required apply and revert.
// Before
await ledger.do({
execute: saveNext,
rollback: restorePrevious,
});
// After
await ledger.do({
apply: saveNext,
revert: restorePrevious,
});Move irreversible work outside Ledger commands.
Replace individual readables
// Before
if (ledger.canUndo.value) void ledger.undo();
console.log(ledger.historySnapshot.value);
// After
if (ledger.state.getSnapshot().undo.length > 0) void ledger.undo();
console.log(ledger.state.getSnapshot().undo);Removed names include Command, CommandMeta, canUndo, canRedo, historySize, historySnapshot, isProcessing, and pendingCount.
Handle cancellation
Queued signal cancellation rejects with LedgerCancelledError before user code starts. Active work receives context.signal and must stop cooperatively.
const controller = new AbortController();
const operation = ledger.do(command, { signal: controller.signal });
controller.abort();
await operation.catch((error) => {
if (!(error instanceof LedgerCancelledError)) throw error;
});If active code ignores cancellation and finishes, its history transition is not committed. Ledger does not automatically compensate partial effects.
Compose only reversible steps
compose() accepts readonly reversible commands, applies them in order, and reverts or compensates in reverse order.
await ledger.do(
compose([
{ apply: updateX, revert: restoreX },
{ apply: updateY, revert: restoreY },
], 'Move item'),
);If application and compensation both fail, inspect LedgerExecutionError.cause as an AggregateError.
Upgrade Checklist
- Remove
LedgerOptions.runtime. - Replace automatic Ripple dependency tracking with
ledger.state.subscribe()or an explicit adapter signal. - Import
SubscribableandUnsubscribewhen framework adapters need explicit annotations. - Replace
CommandwithReversibleCommand. - Rename
execute/rollbacktoapply/revert. - Replace individual history readables with one
statesnapshot. - Catch
LedgerCancelledErrorseparately from execution and rollback failures. - Keep irreversible work outside commands.
- Await
whenIdle()at lifecycle drain boundaries.
Review the Usage Guide and API Reference for current contracts.