Transaction System

Using transactions to group operations into atomic units for consistent history management in model-based editors.

Overview

Transactions group multiple operations into atomic units that are recorded as a single history entry. This ensures that related operations are undone/redone together, maintaining document consistency and providing a better user experience.

Key benefits:

  • Atomic operations - all succeed or all fail
  • Single undo entry for related operations
  • Consistent document state
  • Better undo/redo granularity

Transaction and History

Transactions are the foundation of atomic history management. Each transaction becomes a single entry in the undo/redo stack.

Atomic History Entries

Group related operations into a single history entry:

interface HistoryEntry {
  id: string;
  timestamp: number;
  operations: Operation[]; // All operations in transaction
  beforeModel: DocumentModel;
  afterModel: DocumentModel;
  beforeSelection: Selection | null;
  afterSelection: Selection | null;
}

class Transaction {
  #operations: Operation[] = [];
  #editor: Editor;
  #beforeModel: DocumentModel;
  #beforeSelection: Selection | null;
  #isCommitted = false;

  constructor(editor: Editor) {
    this.#editor = editor;
    this.#beforeModel = editor.getModel();
    this.#beforeSelection = editor.getSelection();
  }

  add(operation: Operation) {
    if (this.#isCommitted) {
      throw new Error('Cannot add to committed transaction');
    }
    this.#operations.push(operation);
  }

  async commit(): Promise<boolean> {
    if (this.#isCommitted) {
      return false;
    }

    // Validate all operations
    for (const op of this.#operations) {
      if (!this.#editor.canApply(op)) {
        return false;
      }
    }

    // Apply all operations
    try {
      for (const op of this.#operations) {
        this.#editor.applyOperation(op);
      }

      // Record as single history entry
      // Note: Store operations only, not full model snapshots for efficiency
      // See history-management-optimization for details
      const historyEntry: HistoryEntry = {
        id: generateId(),
        timestamp: Date.now(),
        operations: [...this.#operations],
        beforeSelection: this.#beforeSelection,
        afterSelection: this.#editor.getSelection()
        // beforeModel and afterModel are optional - only store if needed
        // For operation-based undo, store inverse operations instead
      };

      this.#editor.getHistory().push(historyEntry);
      this.#isCommitted = true;
      return true;
    } catch (error) {
      // Rollback on error
      this.rollback();
      return false;
    }
  }

  rollback() {
    this.#editor.setModel(this.#beforeModel);
    this.#editor.setSelection(this.#beforeSelection);
    this.#operations = [];
  }
}

Transaction Boundaries

Define transaction boundaries based on user actions:

class Editor {
  #currentTransaction: Transaction | null = null;

  beginTransaction(): Transaction {
    if (this.#currentTransaction) {
      throw new Error('Transaction already in progress');
    }
    this.#currentTransaction = new Transaction(this);
    return this.#currentTransaction;
  }

  async commitTransaction(): Promise<boolean> {
    if (!this.#currentTransaction) {
      return false;
    }
    const success = await this.#currentTransaction.commit();
    this.#currentTransaction = null;
    return success;
  }

  rollbackTransaction() {
    if (this.#currentTransaction) {
      this.#currentTransaction.rollback();
      this.#currentTransaction = null;
    }
  }

  // Helper to add operation to current transaction
  addOperation(operation: Operation) {
    if (this.#currentTransaction) {
      this.#currentTransaction.add(operation);
    } else {
      // No transaction, create one and commit immediately
      const tx = this.beginTransaction();
      tx.add(operation);
      tx.commit();
    }
  }
}

Transaction Lifecycle

Understanding the complete transaction lifecycle is crucial for proper implementation.

Begin, Commit, Rollback

// Example: Formatting operation
async function applyBoldFormatting(editor: Editor, selection: Selection) {
  const tx = editor.beginTransaction();
  
  try {
    // Multiple operations for formatting
    tx.add({
      type: 'format',
      path: selection.start,
      format: 'bold',
      value: true
    });
    
    tx.add({
      type: 'format',
      path: selection.end,
      format: 'bold',
      value: true
    });
    
    // All operations committed as single history entry
    const success = await tx.commit();
    
    if (!success) {
      console.error('Formatting failed');
    }
  } catch (error) {
    tx.rollback();
    throw error;
  }
}

// Example: Paste operation
async function handlePaste(editor: Editor, pastedContent: string) {
  const tx = editor.beginTransaction();
  
  try {
    // Delete selected content
    const selection = editor.getSelection();
    if (!selection.isCollapsed) {
      tx.add({
        type: 'delete',
        path: selection.start,
        length: selection.length
      });
    }
    
    // Insert pasted content
    tx.add({
      type: 'insert',
      path: selection.start,
      content: pastedContent
    });
    
    await tx.commit(); // Single undo entry for paste
  } catch (error) {
    tx.rollback();
    throw error;
  }
}

Transaction State Management

class TransactionManager {
  #activeTransactions: Transaction[] = [];
  #transactionStack: Transaction[] = []; // For nested transactions

  beginTransaction(editor: Editor): Transaction {
    const tx = new Transaction(editor);
    this.#transactionStack.push(tx);
    this.#activeTransactions.push(tx);
    return tx;
  }

  async commitTransaction(tx: Transaction): Promise<boolean> {
    const index = this.#activeTransactions.indexOf(tx);
    if (index === -1) {
      return false;
    }

    const success = await tx.commit();
    
    if (success) {
      this.#activeTransactions.splice(index, 1);
      const stackIndex = this.#transactionStack.indexOf(tx);
      if (stackIndex !== -1) {
        this.#transactionStack.splice(stackIndex, 1);
      }
    }
    
    return success;
  }

  rollbackTransaction(tx: Transaction) {
    tx.rollback();
    const index = this.#activeTransactions.indexOf(tx);
    if (index !== -1) {
      this.#activeTransactions.splice(index, 1);
    }
    const stackIndex = this.#transactionStack.indexOf(tx);
    if (stackIndex !== -1) {
      this.#transactionStack.splice(stackIndex, 1);
    }
  }

  getCurrentTransaction(): Transaction | null {
    return this.#transactionStack.length > 0
      ? this.#transactionStack[this.#transactionStack.length - 1]
      : null;
  }
}

History Integration

Transactions integrate seamlessly with history management to provide atomic undo/redo.

Single Undo Entry

class HistoryManager {
  #undoStack: HistoryEntry[] = [];
  #redoStack: HistoryEntry[] = [];

  recordTransaction(transaction: Transaction) {
    if (!transaction.isCommitted()) {
      return;
    }

    // Store operations only - no full model snapshots for efficiency
    const entry: HistoryEntry = {
      id: generateId(),
      timestamp: Date.now(),
      operations: transaction.getOperations(),
      beforeSelection: transaction.getBeforeSelection(),
      afterSelection: transaction.getAfterSelection()
      // Use inverse operations for undo instead of model snapshots
      // See history-management-optimization for details
    };

    this.#undoStack.push(entry);
    this.#redoStack = []; // Clear redo stack on new action
  }

  undo(): boolean {
    if (this.#undoStack.length === 0) {
      return false;
    }

    const entry = this.#undoStack.pop()!;
    
    // Apply inverse operations
    for (let i = entry.operations.length - 1; i >= 0; i--) {
      const op = entry.operations[i];
      const inverse = this.#getInverseOperation(op);
      this.#editor.applyOperation(inverse);
    }

    // Restore selection
    this.#editor.setSelection(entry.beforeSelection);

    // Move to redo stack
    this.#redoStack.push(entry);
    return true;
  }

  redo(): boolean {
    if (this.#redoStack.length === 0) {
      return false;
    }

    const entry = this.#redoStack.pop()!;
    
    // Re-apply operations
    for (const op of entry.operations) {
      this.#editor.applyOperation(op);
    }

    // Restore selection
    this.#editor.setSelection(entry.afterSelection);

    // Move to undo stack
    this.#undoStack.push(entry);
    return true;
  }
}

Operation Grouping

// Example: User types "Hello" - should be single undo entry
class InputHandler {
  #transaction: Transaction | null = null;
  #lastInputTime = 0;
  #inputTimeout: number | null = null;

  handleInput(e: InputEvent) {
    // Group rapid inputs into single transaction
    const now = Date.now();
    const timeSinceLastInput = now - this.#lastInputTime;

    if (timeSinceLastInput > 500 || !this.#transaction) {
      // Start new transaction
      this.#commitCurrentTransaction();
      this.#transaction = this.#editor.beginTransaction();
    }

    this.#lastInputTime = now;

    // Add operation to transaction
    if (e.inputType === 'insertText') {
      this.#transaction.add({
        type: 'insert',
        path: this.#editor.getSelection().start,
        content: e.data
      });
    }

    // Commit after delay (debounce)
    if (this.#inputTimeout) {
      clearTimeout(this.#inputTimeout);
    }
    this.#inputTimeout = setTimeout(() => {
      this.#commitCurrentTransaction();
    }, 500);
  }

  #commitCurrentTransaction() {
    if (this.#transaction) {
      this.#transaction.commit();
      this.#transaction = null;
    }
  }
}

Transaction Patterns

Common patterns for using transactions in different scenarios.

User Action Transactions

// Pattern: Each user action is a transaction
class UserActionHandler {
  async handleUserAction(action: UserAction) {
    const tx = this.#editor.beginTransaction();

    try {
      switch (action.type) {
        case 'type':
          tx.add({ type: 'insert', path: action.position, content: action.text });
          break;
        case 'delete':
          tx.add({ type: 'delete', path: action.position, length: action.length });
          break;
        case 'format':
          tx.add({ type: 'format', path: action.selection.start, format: action.format });
          break;
      }

      await tx.commit();
    } catch (error) {
      tx.rollback();
      throw error;
    }
  }
}

Formatting Transactions

// Pattern: Formatting operations grouped together
async function applyFormatting(
  editor: Editor,
  selection: Selection,
  format: Format
) {
  const tx = editor.beginTransaction();

  // Apply format to entire selection
  const nodes = editor.getNodesInRange(selection);
  
  for (const node of nodes) {
    tx.add({
      type: 'format',
      path: node.path,
      format: format.name,
      value: format.value
    });
  }

  await tx.commit(); // Single undo for entire formatting
}

// Pattern: Toggle formatting
async function toggleFormatting(
  editor: Editor,
  selection: Selection,
  format: string
) {
  const tx = editor.beginTransaction();
  const hasFormat = editor.hasFormat(selection, format);

  if (hasFormat) {
    // Remove format
    const nodes = editor.getNodesInRange(selection);
    for (const node of nodes) {
      tx.add({
        type: 'format',
        path: node.path,
        format: format,
        value: false
      });
    }
  } else {
    // Apply format
    const nodes = editor.getNodesInRange(selection);
    for (const node of nodes) {
      tx.add({
        type: 'format',
        path: node.path,
        format: format,
        value: true
      });
    }
  }

  await tx.commit();
}

Paste Transactions

// Pattern: Paste as single transaction
async function handlePaste(
  editor: Editor,
  clipboardData: DataTransfer
) {
  const tx = editor.beginTransaction();
  const selection = editor.getSelection();

  try {
    // 1. Delete selected content
    if (!selection.isCollapsed) {
      tx.add({
        type: 'delete',
        path: selection.start,
        length: selection.length
      });
    }

    // 2. Parse and insert pasted content
    const pastedContent = await parseClipboardData(clipboardData);
    
    for (const item of pastedContent) {
      if (item.type === 'text') {
        tx.add({
          type: 'insert',
          path: selection.start,
          content: item.text
        });
      } else if (item.type === 'node') {
        tx.add({
          type: 'insert',
          path: selection.start,
          node: item.node
        });
      }
    }

    await tx.commit(); // Single undo for entire paste
  } catch (error) {
    tx.rollback();
    throw error;
  }
}

Nested Transactions

Support nested transactions for complex operations.

class NestedTransactionManager {
  #transactionStack: Transaction[] = [];

  beginTransaction(editor: Editor): Transaction {
    const tx = new Transaction(editor);
    this.#transactionStack.push(tx);
    return tx;
  }

  async commitTransaction(tx: Transaction): Promise<boolean> {
    const index = this.#transactionStack.indexOf(tx);
    if (index === -1) {
      return false;
    }

    // Only commit if it's the top-level transaction
    if (index === this.#transactionStack.length - 1) {
      const success = await tx.commit();
      if (success) {
        this.#transactionStack.pop();
      }
      return success;
    } else {
      // Nested transaction - just mark as committed
      // Will be committed when parent commits
      return true;
    }
  }

  getCurrentTransaction(): Transaction | null {
    return this.#transactionStack.length > 0
      ? this.#transactionStack[this.#transactionStack.length - 1]
      : null;
  }
}

Best Practices

  • Group related operations: Operations that should be undone together should be in the same transaction
  • User action boundaries: Each user action (keystroke, paste, format) should typically be its own transaction
  • Debounce rapid inputs: Group rapid text input into single transactions for better undo granularity
  • Always rollback on error: Ensure transaction state is restored if commit fails
  • Validate before commit: Check all operations can be applied before committing
  • Preserve selection: Store selection state in transaction for proper undo/redo
  • Avoid nested transactions: Keep transaction structure simple unless necessary