Skip to content

Postgres persistence

Status: Implemented in v0.2. Package: @orpc-agent/postgres. Peer: @orpc-agent/core only — no database driver dependency. Design bounds: ADR-013, resolving Q8.

Reference implementations of the two durability seams: ApprovalCoordinator (restart-surviving approvals, ADR-006/ADR-007) and AuditSink (durable audit trail, ADR-010). Both behave exactly like their in-memory counterparts — the same behavioral contract suite runs against both.

Usage

ts
import pg from "pg";
import {
  createPgApprovalCoordinator,
  createPgAuditSink,
  APPROVALS_DDL,
  AUDIT_DDL,
} from "@orpc-agent/postgres";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
// One-time schema setup (migration file, deploy hook — your choice):
// await pool.query(APPROVALS_DDL); await pool.query(AUDIT_DDL);

const query = (sql: string, params: unknown[]) => pool.query(sql, params);
const auditSink = createPgAuditSink({ query });

const runtime = createAgentRuntime({
  registry,
  policies,
  approvals: { coordinator: createPgApprovalCoordinator({ query }) },
  audit: { sinks: [auditSink], strict: true },
});

The query function is the whole driver seam: (sql, params) => Promise<{ rows }>. A pg.Pool, a single pg.Client, pglite ((sql, params) => db.query(sql, params)), and serverless drivers all satisfy it in one line. The package never imports a driver — CI enforces that.

Schema

APPROVALS_DDL and AUDIT_DDL are canonical DDL strings for the default table names (orpc_agent_approvals, orpc_agent_audit_events): one table each, with the indexes the auditing guide recommends. There is deliberately no migrations framework — your application owns its schema lifecycle; apply the DDL with whatever runs your other migrations, and adapt the names if you override the table option (names are validated as strict identifiers before interpolation).

Approval coordinator — contract points

BehaviorImplementation
Atomic consumption (T8)markConsumed is a single conditional UPDATE … WHERE status='approved' AND consumed_by_execution_id IS NULL — no transaction, no SELECT … FOR UPDATE; verified by a two-connection race test
DecidingSame compare-and-set shape, WHERE status='pending' AND expires_at > $now
ExpiryLazy, computed on read against the coordinator's clock (now option, passed as a SQL parameter — never the database clock); only pending records expire, post-approval expiry stays the resume pipeline's check
Input storageValidated input stored verbatim (jsonb) so the SI-5 hash check detects drift on resume

The JSON round-trip caveat

Persisted inputs revive as plain JSON: a Date inside an input comes back as an ISO string, so a z.date() input schema fails re-validation at resume. That failure is safe (the approval is not consumed) but not what you want — approval-gated capabilities should use string schemas for temporal fields (z.iso.datetime()), which also hash deterministically. Same for undefined vs null: SQL NULL revives as undefined; an input that is literally null trips the hash re-check instead of silently drifting.

Audit sink — strict mode and batching

By default every event is one awaited INSERT — strict mode (audit: { sinks, strict: true }) works unmodified, because the runtime awaits the sink's promise for capability.started before executing (T12).

batch: { size, flushMs } buffers events into multi-row inserts, with one non-negotiable rule (ADR-013): capability.started always writes through as an individually awaited insert, in every configuration. Buffering the started event would make strict mode's audit-before-effect guarantee silently false. Buffered events' sink promises settle at flush, so the runtime's per-event onSinkError routing keeps working; flush() forces a write and close() flushes on shutdown. Terminal events remain best-effort exactly as core documents — a crashed process can lose a completed row, never an execution without its started.

With three users and modest traffic, skip batch entirely.

Options

createPgApprovalCoordinator(options)

OptionDefault
query— (required)(sql, params) => Promise<{ rows }>
table"orpc_agent_approvals"Optionally schema-qualified; validated identifier
nowsystem clockThe coordinator's clock — drives every expiry comparison

createPgAuditSink(options)

OptionDefault
query— (required)as above
table"orpc_agent_audit_events"as above
batchnone (insert per event){ size?: 50, flushMs?: 250 }; capability.started always bypasses

Testing your integration

The package's own suite runs the shared coordinator contract (test-fixtures/approval-coordinator-contract.ts) against pglite in CI and against a real server when TEST_DATABASE_URL is set — including a genuine two-connection markConsumed race. If you write a custom store instead (the recipe), run the same contract against it.

Independent community project — not affiliated with or endorsed by the oRPC maintainers.