Skip to content

Custom Storage Adapters

Invariant decouples runtime execution semantics from physical database technology. All persistence guarantees are defined via the RuntimeStore interface in @invariant-tech/core.


The RuntimeStore Contract

To create a custom storage adapter (e.g. for MySQL, SQLite, MongoDB, or DynamoDB), implement the RuntimeStore interface:

ts
import {
  validateTransitionCommit,
  type RuntimeStore,
  type TransitionCommit,
  type ExecutionState,
  type RuntimeEvent,
  type Lease,
  type DurableSession,
  type SessionWrite,
} from "@invariant-tech/core";

export class CustomDatabaseStore implements RuntimeStore {
  /**
   * Atomically commits state transition, appended events, generated outbox commands,
   * and command acknowledgements in a single database transaction. Enforces OCC.
   */
  async commitTransition(commit: TransitionCommit): Promise<void> {
    // 1. Validate the complete structural contract before BEGIN / mutation.
    validateTransitionCommit(commit);
    // 2. Check expectedRevision against current database row (OCC check)
    // 3. Insert commit.events into event log table
    // 4. Persist commit.nextState; its revision advances once per appended durable event
    // 5. Insert commit.commands into transactional outbox
    // 6. Update status of processed commands from commit.commandUpdates
    // Must be 100% atomic (all succeed or all rollback cleanly)
  }

  async loadState(runId: string): Promise<ExecutionState | null> {
    // Retrieve latest committed state by runId
  }

  async readEventLog(runId: string): Promise<RuntimeEvent[]> {
    // Read ordered event history ordered by seq
  }

  async acquireLease(runId: string, workerId: string, ttlMs: number): Promise<Lease | null> {
    // Acquire exclusive worker lock for runId
  }

  async renewLease(runId: string, leaseId: string, ttlMs: number): Promise<boolean> {
    // Extend lease expiration
  }

  async releaseLease(runId: string, leaseId: string): Promise<void> {
    // Release worker lock
  }

  async findRunnableExecutions(options: { limit: number }): Promise<string[]> {
    // Query running/cancelling executions where the lease is expired or absent.
    // This does not expose or claim pending commands.
  }

  async saveSession(write: SessionWrite): Promise<void> {
    // Compare-and-swap write.session using write.expectedRevision.
    // Reject an existing row owned by another userId.
  }

  async loadSession(sessionId: string): Promise<DurableSession | null> {
    // Load durable session context
  }
}

Core Invariants for Custom Stores

If you write a custom adapter, it must not break these five fundamental invariants:

  1. Complete Preflight: Call validateTransitionCommit() before opening the transaction. Structurally invalid proposals must cause zero database queries or mutations.
  2. Guaranteed Atomicity: You cannot commit state in one query and outbox in an un-persisted async background call. If the database crashes mid-commit, zero partial state must remain.
  3. Zero Dirty Reads: loadState() must return the state representing the latest committed revision.
  4. Mutual Exclusion: acquireLease() must guarantee that only one worker can process a runId at any instant in time.
  5. Session Authority CAS: saveSession() and commitTransition().session must require the expected Session revision, preserve userId ownership, and advance the next revision exactly once. When a transition includes a Session write, execution and Session OCC must share one transaction.

Recovery contract boundary

RuntimeStore does not include portable command read/claim or arbitrary-runId runnable-work attachment methods. Implementing this interface alone does not provide end-to-end cross-process work recovery. The SDK's Session restoration path can still reconstruct a registered active .wait() boundary from stored Session and execution facts. Conform to the canonical Recovery Contract Matrix when describing adapter guarantees.

Conformance Harness

Adapter tests may use describeRuntimeStoreConformance from the dedicated @invariant-tech/core/testing subpath. It is deliberately absent from the production @invariant-tech/core root export so test-runner globals cannot leak into the normal framework surface.

ts
import { describeRuntimeStoreConformance } from "@invariant-tech/core/testing";

describeRuntimeStoreConformance("custom-store", () => ({
  store: new CustomDatabaseStore(),
}));

Next Steps

Invariant Durable Execution Engine.