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.