Skip to content

Supabase

Supabase is Postgres, so everything on the PostgreSQL page applies unchanged: same entities, queries, migrations and pg driver. What is Supabase-specific is which endpoint you connect to and how UQL’s tenant scoping lines up with RLS.

Terminal window
npm install uql-orm pg

Postgres holds session state (prepared statements, SET, advisory locks, temp tables) on a connection, and a transaction-mode pooler hands you a different backend connection between statements. So the endpoint is a real choice:

Endpoint Port Use it for
db.<ref>.supabase.co 5432 Migrations, pg_dump, long-lived servers on an IPv6-capable network.
aws-<region>.pooler.supabase.com (session mode) 5432 The same, from an IPv4-only network.
aws-<region>.pooler.supabase.com (transaction mode) 6543 Serverless and anything opening many short-lived connections.

The direct hostname resolves to IPv6 only unless you buy the IPv4 add-on, which is the usual cause of ENETUNREACH from a CI runner or a container with no IPv6 route.

import { setQuerierPool } from 'uql-orm';
import { PgQuerierPool } from 'uql-orm/postgres';
export const pool = new PgQuerierPool({ connectionString: process.env.DATABASE_URL, max: 10 });
setQuerierPool(pool);

Transaction mode rejects prepared statements, since the statement would be prepared on one backend connection and executed on another. UQL issues none of its own, so the common paths work; if you enable them in pg, turn them off for that endpoint.

That is also why migrations belong on the direct endpoint. Point the migrator at it explicitly:

uql.config.ts
import type { Config } from 'uql-orm';
import { PgQuerierPool } from 'uql-orm/postgres';
import { Invoice, Organization } from './entities.js';
export default {
pool: new PgQuerierPool({ connectionString: process.env.DIRECT_DATABASE_URL }),
entities: [Invoice, Organization],
migrationsPath: './migrations',
} satisfies Config;

Your RLS policies are written against auth.uid() and auth.jwt(), which the PostgREST layer populates per request. Connecting with pg bypasses that layer: you are the postgres role, policies do not apply, and nothing scopes your queries. There are two ways to get scoping back, and they stack.

A UQL security filter is the boundary in application code. It is AND-merged into every query the ORM generates, cannot be turned off from the wire, and fails closed when the context is missing:

import { Entity, Filter } from 'uql-orm';
@Filter('tenant', {
condition: (ctx) => (ctx?.orgId != null ? { organizationId: ctx.orgId } : undefined),
security: true,
})
@Entity()
export class Invoice {}

Set the context once per request and every read, write, relation and cascade is scoped. See Multi-tenancy.

Postgres RLS is the backstop underneath, and it covers raw SQL and anything else that skips the ORM. Connect as a role the policies apply to, and set the claim inside the transaction that reads it:

await pool.transaction(async (querier) => {
await querier.run("SELECT set_config('request.jwt.claims', $1, true)", [JSON.stringify(claims)]);
return querier.findMany(Invoice, { $limit: 50 });
});

The third argument is what scopes the setting to the transaction. Drop it and the claim stays on the connection, so the next request to borrow it inherits the previous caller’s identity.

UQL replaces supabase-js for data access only. Auth, Storage, Realtime and Edge Functions talk to their own endpoints and keep working; a common shape is Supabase Auth issuing the JWT, your server verifying it, and the verified claims becoming the UQL context above. pgvector is installed on every project, so semantic search needs nothing beyond the index your migration creates.

Edge Functions run on Deno with no TCP, so pg cannot connect there. For pool placement in functions that freeze, see Serverless.