Overview#
A Supabase project is a full
PostgreSQL database, so SupabaseDialect extends PostgresDialect and inherits
all of it: the pg driver, the SQL, and RLS policies. On top it handles three
things: the connection modes, TLS, and the auth schema that RLS policies are
written against.| Property | Value |
|---|
| Dialect name | supabase |
| Extends | PostgresDialect |
| Driver | pg |
| Default port | 5432 (6543 for the transaction pooler) |
| Cloud SDK | none |
| Local runtime | none — hosted service only |
Quick start#
Install#
$ npm install ts-prorm-orm pg
$ pnpm add ts-prorm-orm pg
$ yarn add ts-prorm-orm pg
$ bun add ts-prorm-orm pg
pg is the same driver the postgres dialect uses. pg-copy-streams is optional: only the features that need it load it.Connect#
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'supabase',
host: 'aws-0-us-east-1.pooler.supabase.com',
port: 6543, // the transaction pooler
database: 'postgres',
username: 'postgres.abcdefghijklmnop',
password: process.env.SUPABASE_DB_PASSWORD,
});
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'supabase',
url: 'supabase://postgres.abcdefghijklmnop:secret@aws-0-us-east-1.pooler.supabase.com:6543/postgres',
});
A URL is folded into host, port, database, username and password before the dialect is built; any of those passed alongside it wins.Define a model#
import { DataTypes } from 'ts-prorm-orm';
const User = db.define('User', {
id: { type: DataTypes.INTEGER(), primaryKey: true, autoIncrement: true },
email: { type: DataTypes.STRING(255), allowNull: false, unique: true },
name: { type: DataTypes.STRING(100) },
meta: { type: DataTypes.JSONB() },
});
await db.sync(); // CREATE TABLE — see Generated SQL below
meta is JSONB, which the containment query below needs.Query#
import { Op } from 'ts-prorm-orm';
await User.create({ email: 'ada@example.com', name: 'Ada' });
const page = await User.findAll({
where: { name: { [Op.like]: 'A%' } },
order: [['id', 'ASC']],
limit: 20,
offset: 40,
});
Generated SQL#
Each block is the statement the model call above it hands the dialect's query(), captured with the driver stubbed out. The model layer inlines the values.Create table#
db.sync() for the model in the Quick start:CREATE TABLE
Insert, update and delete#
await User.create({ email: 'ada@example.com', name: 'Ada' });
await User.update({ name: 'Grace' }, { where: { email: 'ada@example.com' } });
await User.destroy({ where: { id: 1 } });
Upsert#
await User.upsert({ id: 1, email: 'ada@example.com', name: 'Ada Lovelace' });
upsert() also reads the row by primary key before and after the write, to return the instance.await User.findAll({
where: { name: { [Op.like]: 'A%' } },
order: [['id', 'ASC']],
limit: 20,
offset: 40,
});
JSON#
Containment on the meta column:import { Op } from 'ts-prorm-orm';
await User.findAll({ where: { meta: { [Op.contains]: { plan: 'pro' } } } });
Transaction with a savepoint#
await db.transaction(async (t) => {
await User.create({ email: 'grace@example.com', name: 'Grace' }, { transaction: t });
// A nested transaction is a SAVEPOINT; its failure undoes only its own work.
await db
.transaction(async (sp) => {
await User.destroy({ where: { id: 1 }, transaction: sp });
throw new Error('keep the row');
}, { transaction: t })
.catch(() => {});
});
Skip locked rows (SKIP LOCKED)#
A worker that claims rows from a queue table should pass over rows another transaction holds rather than wait for them. lock with skipLocked inside a transaction:await db.transaction(async (t) => {
await User.findAll({ where: { name: 'Ada' }, lock: { level: 'UPDATE', skipLocked: true }, transaction: t });
});
Type mapping for the portable descriptors: STRING → VARCHAR(255), auto-increment INTEGER → SERIAL, BOOLEAN → BOOLEAN, DATE → TIMESTAMP, UUID → UUID, JSON → JSON. The type comparison lines the major engines up side by side.Connection modes#
mode | Host | Port | User |
|---|
direct | db.<project-ref>.supabase.co | 5432 | postgres |
session | aws-<n>-<region>.pooler.supabase.com | 5432 | postgres.<project-ref> |
transaction | aws-<n>-<region>.pooler.supabase.com | 6543 | postgres.<project-ref> |
If you omit mode, the dialect detects it: port 6543 means transaction, and
*.pooler.supabase.com means session.
The direct connection is IPv6-only unless the project has the IPv4 add-on. The
shared pooler (Supavisor) uses IPv4.
Paid plans also have a dedicated PgBouncer pooler at
db.<project-ref>.supabase.co:6543, which runs in transaction mode only.
On the shared pooler, a user without the .<project-ref> suffix is refused, and
the error explains why.
Transaction mode and prepared statements#
Transaction mode is meant for serverless and edge functions. A server connection is
held for one transaction only, so session state does not carry over, and prepared
statements are not supported.The dialect never creates a prepared statement. The inherited query path inlines
escaped values and calls pool.query(text) with no parameters and no name, so
pg sends the simple-query protocol. Do not send a named pg query config through
getConnection() in this mode. listen() needs a session, so it is refused in
transaction mode.What differs from Postgres#
SupabaseDialect extends PostgresDialect (postgres) and
overrides 8 of its methods (Object.getOwnPropertyNames(SupabaseDialect.prototype),
computed by the scratch test); everything else is inherited unchanged.| Area | Overridden |
|---|
| Connection and execution | listen, resolvePoolConfig |
| DDL and schema | buildAlterUserQuery, buildCreateRoleQuery, buildCreateUserQuery, createOwnerPolicy |
| Other | tlsPosture, warnUnverifiedTlsOnce |
TLS#
Supabase recommends SSL mode require, which encrypts the connection without
checking the certificate. If you do not pass ssl, the dialect sends
ssl: { rejectUnauthorized: false }. To verify the server (verify-full), download
the project's server CA certificate from the dashboard and pass it as sslRootCert.
An explicit ssl value always wins.new Prorm({
dialect: 'supabase',
host: 'aws-0-us-east-1.pooler.supabase.com',
port: 6543,
username: 'postgres.abcdefghijklmnop',
password: process.env.SUPABASE_DB_PASSWORD,
database: 'postgres',
dialectOptions: { sslRootCert: fs.readFileSync('prod-ca-2021.crt', 'utf8') },
});
TLS: default vs. strict#
By default (tls: 'prefer-verify', the implicit default) that unverified
fallback is logged once per dialect instance (through tlsLogger, default
console.warn):[prorm supabase] Connection is encrypted but the server certificate is not verified
(ssl.rejectUnauthorized is false), matching Supabase's sslmode=require guidance. To
verify the server, download the project's server CA certificate from the dashboard
and pass it as `sslRootCert`. To require verification instead of falling back, set
tls: 'verify'.
Set tls: 'verify' to refuse that fallback outright: with no sslRootCert
and no explicit ssl, connecting throws before any network I/O, naming
sslRootCert as what to set. An explicit ssl always wins over either mode.new SupabaseDialect({
host: 'db.abcdefghijklmnop.supabase.co',
tls: 'verify', // throws unless you also pass `sslRootCert` or `ssl`
});
dialect.tlsPosture() reports the resulting posture without connecting —
'verified' (sslRootCert, or an explicit ssl that verifies),
'unverified' (the default fallback), or 'none' (an explicit ssl that
does not encrypt, or tls: 'verify' would refuse the connection outright).The postgres role is not a superuser#
postgres is the project's admin role, but it is not SUPERUSER. The superuser is
the internal supabase_admin role. Because postgres is not a superuser, it cannot
create or promote one, so buildCreateUserQuery, buildAlterUserQuery and
buildCreateRoleQuery refuse superuser: true.Row-level security with Supabase Auth#
Policies use the inherited createPolicy() and enableRLS(). The dialect adds the
Supabase-specific pieces:// Exported from src/dialects/supabase, not from the package root.
import { supabaseAuth, SUPABASE_ROLES } from 'ts-prorm-orm/dialects/supabase';
const dialect = db.getDialectInstance() as SupabaseDialect;
await dialect.enableRLS('profiles');
await dialect.createOwnerPolicy({ table: 'profiles', command: 'UPDATE' });
// CREATE POLICY "profiles_update_own" ON "public"."profiles" FOR UPDATE TO authenticated
// USING ("user_id" = (select auth.uid())) WITH CHECK ("user_id" = (select auth.uid()))
await dialect.createPolicy({
name: 'Same org',
tableName: 'projects',
roles: [SUPABASE_ROLES.authenticated],
using: { org_id: supabaseAuth.jwt('app_metadata', 'org_id', { asText: true }) },
});
// ... USING ("org_id" = (select auth.jwt() -> 'app_metadata' ->> 'org_id'))
supabaseAuth.uid() and supabaseAuth.jwt(...claims) are where values. Each is
wrapped in (select ...), as Supabase recommends, so Postgres evaluates it once
per statement, not once per row. Claim names are checked.
supabaseOwnerPolicy() sets USING for SELECT and DELETE, WITH CHECK for
INSERT, and both for UPDATE and ALL. It defaults to the authenticated role and
the user_id column.
The roles are anon (not signed in), authenticated (signed in) and
service_role. service_role bypasses RLS, so never give its key to a client.
References#
Capabilities#
Computed from the code — capabilitiesOf() (src/transaction-support.ts),
whereSupportOf() (src/query-builders/expression-render.ts),
sqlObjectCapabilitiesOf() (src/sql-objects/capabilities.ts) and the
builders — by the scratch test described in the
capability matrix.| Capability | supabase |
|---|
| Transactions atomic | yes |
| Savepoints | yes |
| Isolation | up to SERIALIZABLE |
| Transaction modes | read-only yes; deferrable yes; constraint deferral yes |
| RETURNING | RETURNING on INSERT |
| Upsert | INSERT … ON CONFLICT (…) DO UPDATE |
| JSON column | JSON (JSONB → JSONB) |
| Vector column | VECTOR(3) |
| Array column | INTEGER[] |
| WHERE builder | 62 operator keys; $and/$or/$not; $exists: yes; $in subquery: yes; expression operands: yes |
| SQL objects | family postgres: triggers yes; procedures yes; functions multiStatement; materialized views native; sequences yes; enums named; RLS policy |
| Streaming | queryStream() present; LISTEN present; createExtension() present |
Testing#
tests/dialects/supabase.test.ts exercises the dialect with a mocked driver, so nothing touches a server.See also#
postgres — the parent dialect this one extends Modified at 2026-10-04 00:40:16