Transaction
The atomic unit of change.
A commit has to be all-or-nothing. If four steps apply cleanly and the fifth would violate schema, none of them should be observable. If the renderer watches the document, it must never see a half-applied state.
A Transaction is that atomic unit. It carries the ordered steps, an optional selection update, optional stored marks, and metadata. The engine either commits the transaction whole or leaves state untouched.
Worked example
The planner's output from a splitBlock intent lowers to a transaction with one step:
const tx: Transaction = {
id: "tx:42" as TransactionId,
steps: [createSplitBlockStep([0], 0, 4)],
};
const result: TransactionResult = applyTransaction(stateBefore.doc, tx);
const nextDoc = result.doc;
const nextSelection = result.selection ?? stateBefore.selection;
applyTransaction returns the new document and a Mapping. It does not commit editor state, push history, or notify subscribers; those are engine responsibilities.
transactionApplied fires after apply succeeds:
transactionApplied
transactionId: "tx:42"
stepCount: 1
Fields
interface Transaction {
readonly id: TransactionId;
readonly steps: readonly Step[];
readonly selection?: Selection;
readonly storedMarks?: readonly Mark[] | null;
readonly meta?: TransactionMeta;
}
id—Brand<string, "TransactionId">, assigned by the engine. Stable within a recording.steps— ordered; each applies against the output of the previous one.selection— optional committed selection after apply. Omitted means "derive from the last step."storedMarks—undefinedmeans no change; aMark[]sets them;nullclears them. Stored marks are the formatting a collapsed caret will apply to the next typed character.meta— JSON-safe bag. Intent meta flows through; plugins can attach trace tags here.
What applyTransaction returns
interface TransactionResult {
readonly doc: Doc;
readonly mapping: Mapping;
readonly selection?: Selection;
readonly storedMarks?: readonly Mark[] | null;
readonly meta?: TransactionMeta;
readonly removedSpans?: readonly RemovedMarkSpan[];
}
Each Step also has an invert(doc): Step method. The engine does not collect inverses during apply; instead a HistoryEntry keeps the original transaction plus the full stateBefore and stateAfter. Undo restores stateBefore. Inverse steps exist for analysis and future re-mapping work, not for the current undo path.
Why transactions have JSON
In memory, each Step is an object with methods. For storage and replay, transactionToJSON(...) lowers the transaction to its durable data: step kind, step inputs, selection, stored marks, and metadata. That JSON form has no function references, DOM nodes, or class instances. This is what lets the engine:
- Round-trip through canonical JSON for snapshot tests.
- Stream transactions to a server for persistence.
- Replay a recorded intent stream and compare the final doc byte-for-byte.
See packages/core/src/transaction.ts for the types and packages/core/src/steps/steps-apply.ts for applyTransaction.