Voltro + Supabase

Connect Voltro to Supabase Postgres — Supavisor pooler vs direct connection, LISTEN/NOTIFY for the reactive engine, sslmode=require, and the transaction-pooler gotcha.

Supabase is managed Postgres, so it uses Voltro's postgres dialect — the reference dialect with the full reactive surface: LISTEN/NOTIFY CDC, advisory-lock cluster workflows, streaming-replication read replicas, and JSONB. Nothing about the schema DSL, queries, or workflows changes.

The one thing to get right is which connection you point CDC at. Supabase ships the Supavisor pooler, and its transaction mode breaks LISTEN/NOTIFY.

Connect

Supabase gives you three connection strings under Project Settings → Database. Pick by use case:

# Direct connection (port 5432) — session-mode, supports LISTEN/NOTIFY.
# Use this when your deployment has a stable, bounded connection count
# (a long-running container, not per-request serverless).
DB_DIALECT=postgres
DB_URL=postgresql://postgres:[PASSWORD]@db.[PROJECT-REF].supabase.co:5432/postgres?sslmode=require
# Supavisor — session mode (port 5432 on the pooler host).
# Pooled BUT keeps one backend per client connection, so LISTEN/NOTIFY works.
DB_DIALECT=postgres
DB_URL=postgresql://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:5432/postgres?sslmode=require
# Supavisor — transaction mode (port 6543). For serverless / high
# connection churn. ⚠ Does NOT support LISTEN/NOTIFY — see below.
DB_DIALECT=postgres
DB_URL=postgresql://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres?sslmode=require

sslmode=require is mandatory — Supabase rejects unencrypted connections.

Enabling CDC

On Postgres, Voltro's change-data-capture path is LISTEN/NOTIFY and it's on by default (CDC=1). There's no extension to install and no flag to flip — the only requirement is that the framework's listener runs over a session-mode / direct connection. Concretely: point DB_URL (or at least the CDC listener) at the direct :5432 connection or Supavisor session mode, and reactivity works immediately. The transaction pooler is the one thing that silently breaks it (below).

Voltro's reactive engine (the thing that pushes a fresh snapshot to subscribers with very low latency) uses Postgres LISTEN/NOTIFY. That requires a long-lived, session-mode connection: the framework opens one dedicated LISTEN connection per process and keeps it open for the process lifetime.

Supavisor transaction mode (:6543) hands each transaction a different backend from the pool. The LISTEN registers on a backend you never see again — so no change events arrive, and subscriptions go silent (no error, just no updates).

Decision rubric:

  • Long-running container / VM (k8s, Fly, Railway, a PM2 box) → use the direct connection (:5432) or Supavisor session mode. Reactivity works out of the box.
  • Serverless functions (per-request lifecycle) → use transaction mode (:6543) for the app's queries, but you lose the listener — so run the framework with CDC=0 to force the inline-emit path (single-process change emission, no cross-process fan-out), or split: point app queries at the pooler and the CDC listener at a direct connection. For a push-reactive Voltro app, serverless is the wrong shape — prefer a long-running process.

SSL + connection limits

  • SSL: always sslmode=require. Supabase's CA is widely trusted; you rarely need a custom sslrootcert.
  • Connection limits: the direct connection caps at the instance's max_connections (small on the free tier — ~60). Move app traffic to the Supavisor pooler and keep only the single CDC listener on the direct connection, or raise the instance's max_connections in the Supabase dashboard. (The framework opens one pool per process; cap its size per process with DB_MAX_CONNECTIONS — maps to the postgres max, mysql connectionLimit, and mssql pool.max.)

Tables in a non-public schema — DB_SCHEMA

If your app's tables live in a schema other than public (e.g. voltro), set DB_SCHEMA:

DB_SCHEMA=voltro

The framework then opens every pooled connection with search_path = voltro (the Postgres connection-startup options parameter, applied server-side before the connection is usable). That one setting makes everything consistent: schema introspection (voltro db plan / auto-migrate use current_schema()), the unqualified DDL the migrator emits, and runtime queries all target voltro.

Without it the connection's search_path stays at the Supabase default (public), so the planner introspects an empty public schema, decides your entire schema is missing, and tries to recreate every table — the classic "the differ wants to create 800 tables that already exist" symptom. DB_SCHEMA is the fix; leave it unset for the normal public-schema case. (Postgres-only — other dialects ignore it.)

db plan / migrate over the pooler — large schemas

voltro db plan / apply / drift / migrate introspect the live schema (every column, index and constraint of every table). On a large schema (hundreds of tables) that introspection is big, and the Supavisor pooler can mis-frame a single huge response — node-postgres' parser reads a garbage field length and the command dies with a raw RangeError [ERR_OUT_OF_RANGE], or you get a bare Connection error / a multi-minute hang at zero output. This is not the LISTEN/NOTIFY issue above: it crashes on session mode too, independent of :5432 vs :6543, because the trigger is response size, not transaction multiplexing. (Small schemas — a few dozen tables — never hit it.)

The introspection is batched by tableVOLTRO_INTROSPECT_BATCH tables per detail query (default 20), so no single response is large enough to trip the pooler. db plan runs over the normal pooler, exactly like the runtime — no direct connection required. If a batch still mis-frames on a very wide schema, lower it:

VOLTRO_INTROSPECT_BATCH=5 voltro db plan      # or 1 — smaller responses, more round-trips

Escape hatch: DB_DIRECT_URL (bypass the pooler)

You can instead point only the migration path at a direct (non-pooler) connection:

DB_URL=postgresql://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:5432/postgres?sslmode=require
# Migration-path-only override — db plan / apply / drift / migrate use this instead:
DB_DIRECT_URL=postgresql://postgres:[PASSWORD]@db.[PROJECT-REF].supabase.co:5432/postgres?sslmode=require

DB_DIRECT_URL (alias DB_MIGRATE_URL) overrides DB_URL for the db-command path only — the runtime keeps DB_URL, and DB_SCHEMA stays pinned on both. Caveat: the Supabase direct host db.<project-ref>.supabase.co is IPv6-only on newer projects, so on an IPv4-only network it needs the paid IPv4 add-on — which is why the batched-pooler path above is usually the practical one. If you do hit the crash, the CLI prints this guidance (lower the batch / use a direct URL) instead of a raw stacktrace.

Verify

After voltro dev (or voltro start) connects, the boot log prints the resolved dialect:

[voltro:dev] sql dialect resolved: postgres — CDC: LISTEN/NOTIFY, RETURNING: native
[voltro:dev] read replicas: 0 configured (DB_REPLICA_URLS empty) — all queries → primary
[voltro:dev] workflow engine: cluster-sql, dialect=postgres

Confirm with voltro logs --tail 50. If you connected through the transaction pooler, subscriptions will deliver their first snapshot but never update — that's the LISTEN/NOTIFY symptom; switch to a session-mode connection.

See also

  • Postgres dialect — the full reactive surface: LISTEN/NOTIFY internals, advisory-lock workflows, read replicas, JSONB.
  • Neon — the other major serverless Postgres, same pooled-vs-direct split.