npm.io
0.8.2 • Published 3d ago

@firsttx/tx

Licence
MIT
Version
0.8.2
Deps
1
Size
87 kB
Vulns
0
Weekly
0
Stars
11

@firsttx/tx

Safe optimistic updates with reverse-order compensation

Execute multi-step operations with automatic compensation on failure. Tx provides saga-like rollback semantics; it does not make remote APIs and browser storage one atomic commit.

Why Tx?

Reverse-order compensation for completed optimistic steps.

  • Auto rollback: Declare compensate once, auto-execute in reverse order on failure
  • Step-level retry: Exponential/linear backoff per step (not just whole operation)
  • ViewTransition: Smooth animations during rollback
  • Timeout protection: Prevent hanging transactions with configurable limits
  • Type-safe errors: Structured error classes with getUserMessage() and isRecoverable()
npm install @firsttx/tx

npm version License

Module format: ESM-only. CommonJS users should use import() (dynamic import).


The Problem

// ❌ Without transactions: Manual rollback hell
async function addToCart(item) {
  setCart((prev) => [...prev, item]); // Optimistic update

  try {
    await api.post('/cart', item);
  } catch (error) {
    setCart((prev) => prev.filter((i) => i.id !== item.id)); // Manual rollback
    toast.error('Failed to add item');
  }
}

// What if you have 5 steps? 10 steps?
// What if step 3 fails but step 2's rollback also fails?

The Solution

// ✅ With transactions: Automatic rollback
import { startTransaction } from '@firsttx/tx';

async function addToCart(item) {
  const tx = startTransaction({ transition: true });

  await tx.run(() => setCart((prev) => [...prev, item]), {
    compensate: () => setCart((prev) => prev.filter((i) => i.id !== item.id)),
  });

  await tx.run(() => api.post('/cart', item), { retry: { maxAttempts: 3 } });

  await tx.commit();
}

// Automatic rollback on ANY step failure
// ViewTransition support for smooth UI updates
// Built-in retry logic

Quick Start

Imperative API
import { startTransaction } from '@firsttx/tx';

const tx = startTransaction({ transition: true });

// Step 1: Optimistic update with compensation
await tx.run(() => CartModel.patch((draft) => draft.items.push(item)), {
  compensate: () => CartModel.patch((draft) => draft.items.pop()),
});

// Step 2: Server request with retry
await tx.run(() => fetch('/api/cart', { method: 'POST', body: JSON.stringify(item) }), {
  retry: { maxAttempts: 3, delayMs: 1000 },
});

await tx.commit();
React Hook API
import { useTx } from '@firsttx/tx';

function AddToCartButton({ item }) {
  const { mutate, isPending, isError } = useTx({
    optimistic: async (item) => {
      await CartModel.patch((draft) => draft.items.push(item));
    },
    rollback: async (item) => {
      await CartModel.patch((draft) => {
        draft.items = draft.items.filter((i) => i.id !== item.id);
      });
    },
    request: async (item) => {
      return fetch('/api/cart', {
        method: 'POST',
        body: JSON.stringify(item),
      }).then((r) => r.json());
    },
    retry: { maxAttempts: 3 },
    onSuccess: () => toast.success('Added to cart'),
    onError: (err) => toast.error(err.getUserMessage()),
  });

  return (
    <button onClick={() => mutate(item)} disabled={isPending}>
      {isPending ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}

Features

Automatic Rollback

If any step fails, all completed steps are rolled back in reverse order.

const tx = startTransaction();

await tx.run(() => step1(), { compensate: () => undo1() });
await tx.run(() => step2(), { compensate: () => undo2() });
await tx.run(() => step3(), { compensate: () => undo3() });

// If step3 fails:
// 1. Execute undo3 (if step3 started)
// 2. Execute undo2
// 3. Execute undo1
// 4. Re-throw original error

Retry Logic

Exponential or linear backoff strategies.

await tx.run(() => fetch('/api/data'), {
  retry: {
    maxAttempts: 3,
    delayMs: 100,
    backoff: 'exponential',
  },
});

Retry Presets

Use pre-configured retry strategies:

import { RETRY_PRESETS } from '@firsttx/tx';

const { mutate } = useTx({
  optimistic: async () => {
    /* ... */
  },
  rollback: async () => {
    /* ... */
  },
  request: async () => {
    /* ... */
  },
  retry: RETRY_PRESETS.default,
});

const tx = startTransaction();
await tx.run(() => fetch('/api/data'), {
  retry: RETRY_PRESETS.aggressive,
});

Available Presets:

  • RETRY_PRESETS.default - 2 attempts, 500ms delay, exponential backoff
  • RETRY_PRESETS.aggressive - 5 attempts, 1000ms delay, exponential backoff
  • RETRY_PRESETS.quick - 1 attempt, no delay, linear backoff

ViewTransition Support

Smooth visual transitions during rollback.

const tx = startTransaction({ transition: true });

// On rollback, changes are wrapped in document.startViewTransition()
// Provides smooth fade/slide animations

Timeout Protection

Prevent transactions from hanging indefinitely.

const tx = startTransaction({ timeout: 5000 }); // 5 second timeout

await tx.run(slowOperation); // If takes >5s, automatic rollback

API Reference

startTransaction(options?)

Creates a new transaction.

startTransaction({
  id?: string,         // Custom ID (default: auto-generated UUID)
  transition?: boolean, // Use ViewTransition (default: false)
  timeout?: number     // Timeout in milliseconds (default: 30000)
});

tx.run(fn, options?)

Executes a step with optional compensation and retry.

await tx.run<T>(
  fn: () => Promise<T>,
  options?: {
    compensate?: () => Promise<void>,
    retry?: {
      maxAttempts?: number,
      delayMs?: number,
      backoff?: 'exponential' | 'linear'
    }
  }
);

Parameters

  • fn: () => Promise<T> - Function to execute
  • options?.compensate: () => Promise<void> - Rollback function (executed in reverse order on failure)
  • options?.retry: RetryConfig - Retry configuration
    • maxAttempts?: number - Maximum retry attempts (default: 1)
    • delayMs?: number - Base delay between retries in milliseconds (default: 100)
    • backoff?: 'exponential' | 'linear' - Backoff strategy (default: 'exponential')

Returns Promise<T> - Result of the function

Throws

  • RetryExhaustedError - When all retry attempts fail
  • TransactionTimeoutError - When execution exceeds timeout

tx.commit()

Finalizes the transaction.

await tx.commit();

After commit

  • Transaction becomes immutable
  • No more steps can be added
  • Cleanup tasks are executed

useTx<TVariables, TResult>(config)

React hook for declarative transaction management.

const { mutate, mutateAsync, isPending, isError, isSuccess, error } = useTx({
  optimistic: async (variables: TVariables) => { /* ... */ },
  rollback: async (variables: TVariables) => { /* ... */ },
  request: async (variables: TVariables) => { /* ... */ },
  transition?: boolean,
  retry?: RetryConfig,
  onSuccess?: (result: TResult, variables: TVariables) => void,
  onError?: (error: Error, variables: TVariables) => void
});

Parameters

  • config.optimistic: (variables: TVariables) => Promise<void> - Immediate local update
  • config.rollback: (variables: TVariables) => Promise<void> - Revert local update on failure
  • config.request: (variables: TVariables) => Promise<TResult> - Server request
  • config.transition?: boolean - Use ViewTransition (default: true)
  • config.retry?: RetryConfig - Retry configuration
    • maxAttempts?: number - Default: 1
    • delayMs?: number - Default: 100
    • backoff?: 'exponential' | 'linear' - Default: 'exponential'
  • config.onSuccess?: (result: TResult, variables: TVariables) => void - Success callback
  • config.onError?: (error: Error, variables: TVariables) => void - Error callback

Returns

  • mutate: (variables: TVariables) => void - Fire-and-forget execution
  • mutateAsync: (variables: TVariables) => Promise<TResult> - Promise-based execution
  • isPending: boolean - Whether transaction is running
  • isError: boolean - Whether transaction failed
  • isSuccess: boolean - Whether transaction succeeded
  • error: Error | null - Error object if failed

Advanced Patterns

Conditional Compensation
let didUpdate = false;

await tx.run(
  async () => {
    if (needsUpdate) {
      updateData();
      didUpdate = true;
      await api.save();
    }
  },
  {
    compensate: async () => {
      if (didUpdate) revertData();
    },
  },
);
Partial Rollback
const completed: string[] = [];

await tx.run(
  async () => {
    await step1();
    completed.push('step1');
    await step2();
    completed.push('step2');
    await step3();
    completed.push('step3');
  },
  {
    compensate: async () => {
      for (const step of completed.reverse()) {
        await rollback(step);
      }
    },
  },
);
External State Capture
async function deleteItem(id: string) {
  const tx = startTransaction({ transition: true });

  const itemToDelete = items.find((i) => i.id === id);

  await tx.run(
    async () => {
      setItems((prev) => prev.filter((i) => i.id !== id));
      await api.delete(id);
    },
    {
      compensate: async () => {
        setItems((prev) => [...prev, itemToDelete]);
      },
    },
  );

  await tx.commit();
}

Error Handling

FirstTx provides structured error classes with user-friendly messages and debug information.

Error Base Class

All transaction errors extend TxError:

import { TxError } from '@firsttx/tx';

try {
  await tx.commit();
} catch (error) {
  if (error instanceof TxError) {
    // User-friendly message for UI
    alert(error.getUserMessage());

    // Detailed info for debugging
    console.error(error.getDebugInfo());

    // Check if retryable
    if (error.isRecoverable()) {
      // Offer retry option
    } else {
      // Suggest page refresh
    }
  }
}

CompensationFailedError (Critical)

Thrown when rollback fails, indicating potential data inconsistency.

import { CompensationFailedError } from '@firsttx/tx';

try {
  await tx.commit();
} catch (error) {
  if (error instanceof CompensationFailedError) {
    console.error(`${error.failures.length} rollback(s) failed`);
    console.error(`Completed ${error.completedSteps} steps before failure`);

    // User message: "Failed to undo changes. Your data may be in an inconsistent state. Please refresh the page."
    alert(error.getUserMessage());

    // NOT recoverable - requires page refresh
    if (!error.isRecoverable()) {
      window.location.reload();
    }
  }
}

Fields

  • failures: Error[] - All compensation errors (in reverse order)
  • completedSteps: number - Number of steps that were completed

RetryExhaustedError (Recoverable)

Thrown when a step fails after all retry attempts.

import { RetryExhaustedError } from '@firsttx/tx';

try {
  await tx.run(apiCall, { retry: { maxAttempts: 3 } });
} catch (error) {
  if (error instanceof RetryExhaustedError) {
    console.log(`Step ${error.stepId} failed after ${error.attempts} attempts`);

    // Inspect all attempt errors
    error.errors.forEach((err, i) => {
      console.log(`Attempt ${i + 1}: ${err.message}`);
    });

    // User message: "The operation failed after 3 attempts. Please try again later."
    toast.error(error.getUserMessage(), {
      action: error.isRecoverable()
        ? {
            label: 'Retry',
            onClick: retryOperation,
          }
        : undefined,
    });
  }
}

Fields

  • stepId: string - Step identifier (e.g., "step-0")
  • attempts: number - Total number of attempts made
  • errors: Error[] - All attempt errors (in chronological order)

TransactionTimeoutError (Recoverable)

Thrown when transaction exceeds configured timeout.

import { TransactionTimeoutError } from '@firsttx/tx';

try {
  const tx = startTransaction({ timeout: 5000 });
  await tx.run(slowOperation);
  await tx.commit();
} catch (error) {
  if (error instanceof TransactionTimeoutError) {
    console.log(`Timeout: ${error.timeoutMs}ms (elapsed: ${error.elapsedMs}ms)`);

    // User message: "The operation took too long (over 5000ms). Please try again."
    toast.error(error.getUserMessage());

    // Recoverable - can retry with longer timeout
    if (error.isRecoverable()) {
      const newTx = startTransaction({ timeout: 10000 });
      // ... retry with 10s timeout
    }
  }
}

Fields

  • timeoutMs: number - Configured timeout value
  • elapsedMs: number - Actual elapsed time before timeout

TransactionStateError (Programming Error)

Thrown when attempting invalid operations (e.g., adding steps after commit).

import { TransactionStateError } from '@firsttx/tx';

const tx = startTransaction();
await tx.commit();

try {
  await tx.run(() => {}); // Invalid!
} catch (error) {
  if (error instanceof TransactionStateError) {
    console.error(`Cannot ${error.attemptedAction} in state '${error.currentState}'`);
    console.error(`Transaction ID: ${error.transactionId}`);

    // User message: "This operation is no longer available. The transaction has already completed or failed."
    // NOT recoverable - indicates a bug
  }
}

Fields

  • currentState: string - Current transaction status ("committed", "rolled-back", "failed")
  • attemptedAction: string - What was attempted ("add step", "commit")
  • transactionId: string - Transaction identifier

Practical Error Handling Pattern
import {
  TxError,
  CompensationFailedError,
  RetryExhaustedError,
  TransactionTimeoutError,
  TransactionStateError,
} from '@firsttx/tx';

async function handleTransaction() {
  try {
    const tx = startTransaction({ timeout: 5000 });

    await tx.run(() => updateUI(), { compensate: () => revertUI() });

    await tx.run(() => fetch('/api/update'), { retry: { maxAttempts: 3 } });

    await tx.commit();

    toast.success('Update successful');
  } catch (error) {
    if (error instanceof CompensationFailedError) {
      // Critical: data may be inconsistent
      alert(error.getUserMessage());
      window.location.reload();
    } else if (error instanceof RetryExhaustedError) {
      // Network issue: offer retry
      toast.error(error.getUserMessage(), {
        action: {
          label: 'Retry',
          onClick: () => handleTransaction(),
        },
      });
    } else if (error instanceof TransactionTimeoutError) {
      // Slow operation: suggest waiting
      toast.error(error.getUserMessage());
    } else if (error instanceof TransactionStateError) {
      // Programming error: log for debugging
      console.error(error.getDebugInfo());
      Sentry.captureException(error);
    } else if (error instanceof TxError) {
      // Generic TxError fallback
      toast.error(error.getUserMessage());
      console.error(error.getDebugInfo());
    }
  }
}

Error Methods Reference

All transaction errors provide:

Method Returns Purpose
getUserMessage() string User-friendly message for UI display
getDebugInfo() string Detailed technical information for debugging
isRecoverable() boolean Whether the error can be recovered from (retry-able)

Recoverability

  • RetryExhaustedError: Recoverable (network/temporary issues)
  • TransactionTimeoutError: Recoverable (retry with longer timeout)
  • CompensationFailedError: Not recoverable (data inconsistency)
  • TransactionStateError: Not recoverable (programming error)

Exports

import {
  // Main API
  startTransaction,
  useTx,

  // Error Classes
  TxError, // Base class
  CompensationFailedError,
  RetryExhaustedError,
  TransactionTimeoutError,
  TransactionStateError,

  // Types
  type TxOptions,
  type TxStatus,
  type StepOptions,
  type RetryConfig,
  type UseTxConfig,
  type UseTxResult,

  // Constants
  DEFAULT_RETRY_CONFIG,
} from '@firsttx/tx';

Constraints

1. No Nested Transactions
// ❌ Not supported
const tx1 = startTransaction();
await tx1.run(async () => {
  const tx2 = startTransaction(); // Avoid this
  await tx2.run(...);
});

Workaround Use a single transaction with multiple steps.


2. Compensation Must Be Idempotent

Compensation functions may be called multiple times in edge cases.

// ✅ Good: Idempotent compensation
compensate: () => {
  setCount(0); // Always safe to call multiple times
};

// ❌ Bad: Non-idempotent compensation
compensate: () => {
  setCount(count - 1); // Dangerous if called twice
};

3. Async Compensation Only
// ❌ Sync compensation not supported
compensate: () => {
  updateState();
};

// ✅ Async compensation required
compensate: async () => {
  updateState();
};

FAQ

Q: What happens if compensation fails?

A: CompensationFailedError is thrown with all failure details. The transaction is marked as 'failed', and you should handle it as a critical error (e.g., page refresh).


Q: Can I retry a failed transaction?

A: Yes, just call the same function again. Each transaction is independent.


Q: What's the difference between mutate and mutateAsync?

A:

  • mutate: Fire-and-forget (no return value, errors caught internally)
  • mutateAsync: Returns a Promise (allows await, errors bubble up)

Q: Do I need to use ViewTransition?

A: No, it's optional. Set transition: false to disable. ViewTransition provides smooth animations but requires browser support (Chrome 111+).


Q: How does retry backoff work?

A:

  • Exponential (default): 100ms → 200ms → 400ms → 800ms (delay × 2^attempt)
  • Linear: 100ms → 200ms → 300ms → 400ms (delay × attempt)


License

MIT joseph0926

Keywords