Runlane
Reference

Postgres Storage

Durable Postgres storage adapter and migration options.

@runlane/postgres-storage is the first-party durable storage adapter for production lanes. It stores run history, materialized run state, leases, schedule occurrence claims, idempotency and singleton ownership, outbox rows, operator read projections, and terminal-run pruning targets in Postgres.

Use it when Postgres should be the durable truth boundary. Pair it with a transport adapter in a lane package; storage does not execute tasks or publish wakeups by itself.

import { postgresStorage } from '@runlane/postgres-storage'

const storage = postgresStorage({
  connectionString: process.env.DATABASE_URL,
  schema: 'runlane',
})

The adapter reports durableState: true and processLocalState: false: state survives process loss and can be reached by any process using the same migrated database and schema.

Capabilities

The adapter reports the full first-party production storage capability set:

CapabilityValueWhat Postgres owns
durableStatetrueCommitted runs, events, outbox rows, ownership rows, and schedule occurrences survive process loss.
processLocalStatefalseApp, worker, maintenance, and CLI processes can share state through the same database and schema.
readsRunHistorytrueOperator run lists and event history are served from indexed Postgres rows.
prunesRunstrueTerminal runs can be deleted in bounded, cursor-resumable batches.
leasesRunstrueWorker lease claims and heartbeats are persisted with optimistic sequence checks.
claimsScheduleOccurrencestrueSchedule fires are claimed by deterministic occurrence id.
persistsOutboxtrueDelivery requests create durable outbox rows in the same transaction as run events.
enforcesIdempotencytrueTask-scoped idempotency ownership is serialized in Postgres.
enforcesSingletontrueActive singleton ownership is serialized in Postgres.
enforcesQueueConcurrencytrueBounded queue capacity is checked transactionally by environment, queue, and concurrency key.

Connection And Schema

postgresStorage() accepts one connection string and an optional schema:

postgresStorage({ connectionString, schema })
OptionRequiredDefaultWhat it controls
connectionStringYesNonePostgres connection string passed to pg after Runlane removes the Prisma-style schema query parameter. Must be a non-empty string using postgres:// or postgresql://.
schemaNo?schema= from the URL, then publicPostgres schema that contains Runlane tables, indexes, and constraints. Must be a valid Postgres identifier.

Schema resolution is deterministic:

  • schema option wins when provided.
  • ?schema=runlane on the connection string is used when schema is omitted.
  • public is used when neither source names a schema.

The adapter rejects unsupported option keys with ConfigurationInvalid. It removes the Prisma-style schema query parameter before creating the pg pool, so the same URL can be shared with tools that understand ?schema= without sending an unsupported startup parameter to Postgres.

Connection strings may point at ordinary Postgres, RDS Proxy, PgBouncer, or another load-balanced endpoint supported by node-postgres. The adapter uses ordinary transactions, row locks, transaction-scoped advisory locks, and FOR UPDATE SKIP LOCKED. Named schemas are schema-qualified in adapter queries; the default public schema uses Postgres's default table resolution because Drizzle treats public as the unqualified schema.

Migrations

Runlane does not auto-migrate at adapter startup. Apply the SQL migration in your normal migration system before starting producers, workers, maintenance, or CLI commands that use this storage.

import { getPostgresStorageMigrationSql } from '@runlane/postgres-storage'

const sql = getPostgresStorageMigrationSql({ schema: 'runlane' })

The package also ships migrations/0001_initial.sql for teams that want to vendor or review the public-schema SQL directly. The generated SQL schema-qualifies table references and creates all tables, constraints, generated columns, and indexes required by the storage contract. The package test suite keeps the shipped migration file byte-for-byte aligned with getPostgresStorageMigrationSql().

This is an initial schema generator, not an online migration runner. postgresStorage().start() probes the migrated runlane_runs table and fails fast when the database, schema, or migration is missing, but it does not create or alter tables for you. Malformed options fail earlier, when the adapter is constructed.

Migration Tooling

Prisma teams have four reasonable ways to apply the Runlane storage schema. The right choice depends on the ownership boundary you want:

  • Prisma owns the migration ledger.
  • Runlane and app schema management stay separate.
  • schema.prisma mirrors Runlane tables.
  • Prisma is only a client generator while another migration tool owns DDL.

Application code should not query Runlane tables through Prisma in any of these layouts. Use the Runlane operator API (runlane.runs.list, runlane.runs.get, runlane.runs.retry, runlane.runs.cancel, etc.) for run history and state.

Snapshot Runlane SQL into a Prisma migration directory and let prisma migrate deploy apply both Runlane and app migrations in one pass. Use a fixed all-zero timestamp prefix so the Runlane directory sorts before any app migration:

mkdir -p prisma/migrations/00000000000000_runlane_init
node -e "import('@runlane/postgres-storage').then(m => process.stdout.write(m.getPostgresStorageMigrationSql({ schema: 'public' })))" \
  > prisma/migrations/00000000000000_runlane_init/migration.sql
pnpm prisma migrate deploy

schema.prisma describes only app tables. One CLI applies both migration streams, while Runlane SQL remains owned by @runlane/postgres-storage.

Generate this snapshot when you create a new database baseline. After Prisma has applied a migration to any shared, staging, or production database, treat that migration directory as immutable; do not overwrite it during a package upgrade. If a future @runlane/postgres-storage release changes the storage schema, apply the new package guidance or add a new migration that performs the upgrade. The @runlane/example-aws reference example follows the initial-snapshot pattern with pnpm db:emit-runlane-sql.

Option 2: Bootstrap Runlane out of band

Apply the Runlane SQL once with a tool that is not tied to Prisma's _prisma_migrations ledger:

pnpm prisma db execute --schema prisma/schema.prisma \
  --file <(node -e "import('@runlane/postgres-storage').then(m => process.stdout.write(m.getPostgresStorageMigrationSql({ schema: 'public' })))")
pnpm prisma migrate deploy

Or run the SQL through psql, Atlas, or the package's vendored migrations/0001_initial.sql. Prisma's migration ledger never tracks the Runlane SQL; you own applying future Runlane storage upgrades through the same out-of-band path. Pick this when you want a strict boundary between Runlane storage and app schema management.

Option 3: Mirror Runlane tables in schema.prisma

Possible, but Prisma's schema language cannot express Runlane's generated columns or CHECK constraints:

  • runnable_available_at
  • delivery_recovery_available_at
  • storage contract CHECK clauses

You will hand-edit each generated migration file to add those clauses, and schema.prisma will lie about the column shape. Drift risk against @runlane/postgres-storage is severe: a future Runlane upgrade that changes the storage schema breaks the adapter probe at lane.start() while Prisma still thinks the schema is current.

If you accept those costs because your operator UI wants Prisma-typed reads of Runlane tables, model them read-oriented and never write through Prisma:

model RunlaneRun {
  environmentKey String   @map("environment_key")
  runId          String   @map("run_id")
  eventSequence  Int      @map("event_sequence")
  runJson        Json     @map("run_json")
  // ...remaining fields...

  @@id([environmentKey, runId])
  @@map("runlane_runs")
}

You almost certainly don't need this. The Runlane operator API exposes the same projections without duplicating the schema in schema.prisma.

Option 4: Skip Prisma's migration tool

Use Drizzle, Atlas, or plain psql for Runlane SQL while Prisma owns only the app-table client (no prisma/migrations/). This is appropriate when your team already standardizes on a non-Prisma migration runner; the Prisma client is then purely a query interface for app tables.

psql "$DATABASE_URL" -f <(node -e "import('@runlane/postgres-storage').then(m => process.stdout.write(m.getPostgresStorageMigrationSql({ schema: 'public' })))")

Connection strings across tools

Use the same Postgres URL for Prisma and Runlane. If the URL includes ?schema=runlane, Prisma will use it, and Runlane will resolve the same schema before passing the sanitized URL to pg. You do not need a separate schema key in application configuration unless you prefer the explicit option.

Conformance Testing

The package includes the shared storage conformance suite and lane composition conformance with an acknowledging test transport. They run only when RUNLANE_POSTGRES_TEST_DATABASE_URL is set:

RUNLANE_POSTGRES_TEST_DATABASE_URL='postgresql://postgres:secret@localhost:5432/runlane-test' pnpm --filter @runlane/postgres-storage test

The test creates a fresh schema named runlane_test_<uuid>, applies the package migration, runs the shared adapter and composition behavior tests, and drops that schema during cleanup. Point the variable at a disposable database where the test user can create and drop schemas.

For local development, use the package script:

pnpm --filter @runlane/postgres-storage test:postgres:local

It defaults to postgresql://postgres:secret@localhost:5432/runlane-test, removes leftover runlane_test_% schemas from failed prior runs, and then runs the package tests with RUNLANE_POSTGRES_TEST_DATABASE_URL set. CI can provide RUNLANE_POSTGRES_TEST_DATABASE_URL explicitly for its disposable Postgres service; the script still requires a database name containing test before it runs cleanup.

Operational Notes

Use one schema per Runlane storage instance. The adapter scopes all data by environmentKey(environment), but schema separation is the operational boundary for migrations, backups, and cleanup.

Runlane stores canonical run and event JSONB payloads and validates persisted rows with Zod-derived schemas at the adapter boundary.

environment_key is the durable scope column and comes from environmentKey(environment). If the Environment identity contract gains new fields, that helper and the corresponding storage migration are the only places that should redefine durable environment identity. SQL table definitions are maintained with Drizzle internally, but that is not part of the public API.

Postgres constraints and ownership tables enforce:

  • task-scoped idempotency owners
  • one idempotency owner per run
  • active singleton owners
  • immutable event sequence identity per run
  • outbox and schedule occurrence row identity

runlane_idempotency_keys stores active owners and retained successful/cancelled terminal owners with the TTL captured at run creation. Failed runs clear their idempotency owner.

getRunByIdempotencyKey() reads that owner table directly so core can return the original active or retained terminal run after duplicate triggers or concurrent insert races. resetIdempotencyKey() deletes retained terminal owners and rejects active owners.

Bounded queue capacity is not a uniqueness constraint. The adapter serializes each capacity partition with pg_advisory_xact_lock(hashtextextended(...)), counts active occupants, and writes the reservation or lease inside the same transaction. Capacity is partitioned by environment, queue, and concurrencyKey; absent concurrency keys share one partition.

Bounded queues use dispatched_at and dispatch_expires_at on runlane_runs as durable dispatch reservations. reserveRunDispatch() appends run.delivery_requested and creates the outbox row in the same transaction after checking queue capacity for the selected queue and concurrency_key partition. Capacity counts queued runs with unexpired dispatch reservations plus running or cancellation-requested runs with unexpired leases.

listRunsNeedingDispatch() uses the same partition predicate for recovery scans. Postgres-only conformance tests run EXPLAIN (ANALYZE, BUFFERS) against realistic queued/running/cancellation-requested rows to keep the runlane_runs_queue_capacity_idx predicate aligned with that hot path. If the reservation expires before a worker claims the run, maintenance can reserve and publish it again.

Outbox rows live in runlane_outbox_messages. claimOutboxMessages() uses FOR UPDATE SKIP LOCKED to claim due pending, failed, or expired claimed rows and returns only rows owned by the new claim. markOutboxMessagesPublished(), markOutboxMessagesFailed(), and markOutboxMessagesDeadLettered() batch-update only rows whose current claim token matches; stale claims reject as storage conflicts.

Operator run lists and event history read indexed columns but return contract projections parsed from canonical JSONB. Run-list cursors include filter and sort scope, and event sequence sorting requires a runId filter. RunSortField.RunAt uses the same generated availability policy as worker scans, so queued, scheduled, released, retrying, and running runs sort by generated availability while terminal runs sort after runnable work.

pruneRuns() deletes only terminal runs whose updated_at is older than the concrete cutoff core passes to storage. It orders by (updated_at, created_at, run_id), deletes at most the requested limit, and returns an opaque cursor when more rows in the same environment/status/cutoff scope remain. Deleting a run cascades its event history, outbox messages, idempotency owner, and singleton owner rows through foreign keys; schedule occurrence rows retain their run_ids array because they are keyed by occurrence, not by run.

Postgres driver and SQLSTATE failures are mapped into structured Runlane errors. Bad URLs, missing schemas, missing tables, and privilege/auth failures become ConfigurationInvalid; connection/resource failures become retryable StorageUnavailable; unique, serialization, and deadlock races become retryable StorageConflict; unknown failures become InternalError.

postgresStorage().start() probes the migrated runs table. An unreachable database, missing schema, or unapplied migration fails during lane.start() instead of waiting for the first run operation. Malformed options such as an invalid URL, unsupported protocol, unsupported key, or invalid schema name fail when postgresStorage() is called.

On this page