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:
| Capability | Value | What Postgres owns |
|---|---|---|
durableState | true | Committed runs, events, outbox rows, ownership rows, and schedule occurrences survive process loss. |
processLocalState | false | App, worker, maintenance, and CLI processes can share state through the same database and schema. |
readsRunHistory | true | Operator run lists and event history are served from indexed Postgres rows. |
prunesRuns | true | Terminal runs can be deleted in bounded, cursor-resumable batches. |
leasesRuns | true | Worker lease claims and heartbeats are persisted with optimistic sequence checks. |
claimsScheduleOccurrences | true | Schedule fires are claimed by deterministic occurrence id. |
persistsOutbox | true | Delivery requests create durable outbox rows in the same transaction as run events. |
enforcesIdempotency | true | Task-scoped idempotency ownership is serialized in Postgres. |
enforcesSingleton | true | Active singleton ownership is serialized in Postgres. |
enforcesQueueConcurrency | true | Bounded 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 })| Option | Required | Default | What it controls |
|---|---|---|---|
connectionString | Yes | None | Postgres connection string passed to pg after Runlane removes the Prisma-style schema query parameter. Must be a non-empty string using postgres:// or postgresql://. |
schema | No | ?schema= from the URL, then public | Postgres schema that contains Runlane tables, indexes, and constraints. Must be a valid Postgres identifier. |
Schema resolution is deterministic:
schemaoption wins when provided.?schema=runlaneon the connection string is used whenschemais omitted.publicis 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.prismamirrors 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.
Option 1: Prisma migrate runs both (recommended)
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 deployschema.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 deployOr 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_atdelivery_recovery_available_at- storage contract
CHECKclauses
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 testThe 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:localIt 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.