Prisma Next — Supabase
Edit your data contract. Prisma handles the rest.
This skill covers using Prisma Next against a Supabase project end-to-end: composing the Supabase extension pack, referencing Supabase-owned tables from your contract, authoring row-level-security (RLS) policies, and running role-bound queries through the supabase() runtime.
When to Use
- User has a Supabase project (or wants one) and is wiring Prisma Next into it.
- User wants RLS policies on their tables (
policy_select, @@rls, auth.uid()).
- User wants per-request role binding (
asUser(jwt), asAnon(), asServiceRole()).
- User wants a foreign key into
auth.users (cross-space FK).
- User wants to read Supabase-internal tables (
auth.*, storage.*) as an admin.
- User mentions: supabase, RLS, row level security, policy, anon, authenticated, service_role, auth.users, auth.uid(), JWT, jwtSecret, jwksUrl, SUPABASE.JWT_INVALID, RoleBoundDb, session pooler.
When Not to Use
- General contract editing (models, fields, relations) →
prisma-next-contract.
- Non-Supabase
db.ts wiring, middleware, teardown → prisma-next-runtime.
- General query shapes (filtering, includes, aggregates) →
prisma-next-queries — everything there applies to a role-bound db too.
- Migration planning / applying →
prisma-next-migrations.
Key Concepts
- The pack is an
external contract space. @prisma-next/extension-supabase/pack ships a complete, introspection-generated contract of everything Supabase owns — the auth and storage schemas, their native enum types, and the platform roles (anon, authenticated, service_role) — all with control policy external. Composed via extensions, it means: the migration planner emits no DDL for those objects (Supabase manages them), and db verify confirms they exist in the live database. Your own tables stay managed as usual.
- Roles come from the pack; you never declare them. RLS
roles = [authenticated] identifiers resolve against the composed contract. Pointing the runtime at a non-Supabase Postgres fails verify with a not-found issue naming the missing role — the common "wrong database" misconfiguration surfaces before queries run.
- The runtime is role-first.
supabase() returns a SupabaseDb with no top-level query surface — there is no db.sql / db.orm until you bind a role. await db.asUser(jwt) / db.asAnon() / db.asServiceRole() each return a RoleBoundDb exposing .sql, .orm, .raw, .execute(plan), and .transaction(fn). This is deliberate: in a Supabase app there is no meaningful "no role" execution context, and defaulting to the connection's login role is a silent-RLS-bypass footgun.
- Role binding is below middleware and cannot leak. Each role-bound query runs on a connection that had
set_config('role', …) and set_config('request.jwt.claims', …) applied beneath the user-middleware chain, with RESET ALL on release. Postgres-side auth.uid() / auth.jwt() read those session vars — RLS enforcement is Postgres's job; the runtime's job is binding the context.
- RLS is enforced by policies and grants. Policies filter rows;
GRANT controls table access. Prisma Next authors and migrates the policies; it does not author grants (see What Prisma Next doesn't do yet). A role with policies but no GRANT gets a permission error, not filtered rows. On Supabase your public tables already carry the platform-role grants via default privileges — the grant that is actually missing out of the box is service_role's on auth.* / storage.* (see Workflow — Grants).
- JWT validation is eager and configurable — current Supabase projects need
jwksUrl. asUser(jwt) verifies the token (via jose) before any connection is acquired: signature + expiry against jwksUrl (asymmetric signing keys — the default on current Supabase projects, which sign ES256) xor jwtSecret (the symmetric HS256 secret — legacy projects only). Both or neither → a structured error with code SUPABASE.CONFIG_INVALID. Bad tokens throw a structured error with code SUPABASE.JWT_INVALID and a typed meta.reason — including a mismatch between the token's algorithm and the configured key source (an ES256 token against a jwtSecret client names the problem and tells you to switch to jwksUrl). The Postgres role is derived from the token's role claim (defaults to authenticated). Note: supabase status still prints a JWT_SECRET even on projects that sign ES256 — its presence does not mean your project uses it.
- Admin access to
auth.* / storage.* is a secondary root on service_role only — and needs a one-time grant. db.asServiceRole().supabase exposes the pack's own contract (.sql, .orm, .nativeEnums, .execute). The root exists only on service_role by design, but a real Supabase project grants service_role no table privileges on auth.* / storage.* (only schema USAGE; only postgres holds table grants). Before the admin root can read a Supabase-internal table, run the narrow grant once (see Workflow — Grants). asUser / asAnon have no .supabase, and the primary asServiceRole().sql / .orm stay scoped to your contract.
Workflow — Wire the pack into the config
The concept: the pack registers the Supabase contract space so your contract can reference it and the planner/verifier know what Supabase owns. The extension has no /control subpath yet, so it can't go through the target façade's defineConfig({ extensions: [...] }) — it wires into the low-level config's extensions (see What Prisma Next doesn't do yet). The low-level imports below are a deliberate exception to the façade-only import rule, forced by that gap; the block mirrors examples/supabase/prisma-next.config.ts verbatim — copy it rather than composing your own:
// prisma-next.config.ts
import postgresAdapter from '@prisma-next/adapter-postgres/control';
import { defineConfig } from '@prisma-next/cli/config-types';
import postgresDriver from '@prisma-next/driver-postgres/control';
import supabasePack from '@prisma-next/extension-supabase/pack';
import sql from '@prisma-next/family-sql/control';
import { prismaContract } from '@prisma-next/sql-contract-psl/provider';
import postgres from '@prisma-next/target-postgres/control';
import postgresPackRef from '@prisma-next/target-postgres/pack';
import { postgresCreateNamespace } from '@prisma-next/target-postgres/types';
export default defineConfig({
family: sql,
target: postgres,
adapter: postgresAdapter,
driver: postgresDriver,
extensions: [supabasePack],
contract: prismaContract('./src/contract.prisma', {
output: 'src/contract.json',
target: postgresPackRef,
createNamespace: postgresCreateNamespace,
}),
migrations: { dir: 'migrations' },
});
Workflow — Contract: FK into auth.users + RLS policies
The concept: your models live in your namespaces (public); Supabase's live in the pack's (auth, storage). A relation field typed supabase:auth.AuthUser is a cross-space FK — the planner emits REFERENCES "auth"."users"("id"), and the target table is verified, never migrated. RLS policies are top-level policy_<operation> blocks in the same namespace as their target model, and the target model must opt in with @@rls. Mirror examples/supabase/src/contract.prisma:
namespace public {
model Profile {
id Uuid @id @default(uuid())
username String
userId Uuid @unique
user supabase:auth.AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("profile")
@@rls
}
// authenticated may read only their own profile.
policy_select profile_owner_read {
target = Profile
roles = [authenticated]
using = "\"userId\"::uuid = auth.uid()"
}
// anon may read every profile (a public directory listing).
policy_select profile_public_read {
target = Profile
roles = [anon]
using = "true"
}
// authenticated may update only their own profile, and may not
// reassign it to another owner (WITH CHECK).
policy_update profile_owner_write {
target = Profile
roles = [authenticated]
using = "\"userId\"::uuid = auth.uid()"
withCheck = "\"userId\"::uuid = auth.uid()"
}
}
The Uuid constructor selects native UUID storage in type position. The legacy @db.Uuid spelling is removed; rewrite any String @db.Uuid alias or field as Uuid before emitting.
The pieces:
- Per-operation policy blocks:
policy_select, policy_insert, policy_update, policy_delete, policy_all. Body is key = value: target (a model in this namespace), roles (resolve against the composed contract — the pack supplies anon / authenticated / service_role), using, and (for write operations) withCheck. Multiple permissive policies per (target, operation) are valid — Postgres ORs them.
@@rls is required on policy targets. A policy_* block whose target model lacks @@rls fails emit with PSL_EXTENSION_TARGET_MODEL_MISSING_ATTRIBUTE. A model with @@rls and no policies is also meaningful: RLS enabled, deny-all.
- Predicates are verbatim SQL strings. Quote camelCase column names inside them (
\"userId\"), and cast where needed — auth.uid() returns uuid. Renames in your contract do not rewrite predicate bodies.
- TS-builder parity exists.
@prisma-next/postgres/contract-builder exports policySelect / policyInsert / policyUpdate / policyDelete / policyAll, rlsEnabled(Model), and role('anon') — mirroring the PSL lowering key-for-key (identical emitted wire names). PSL is the canonical path shown here.
Emit + migrate as usual (prisma-next contract emit, then prisma-next-migrations). The plan creates your table, its FK, ENABLE ROW LEVEL SECURITY, and the CREATE POLICY statements — and no DDL for auth.*.
Workflow — db.ts with the supabase() factory
The concept: instead of the stock postgres() factory, a Supabase app builds its client with supabase() from the extension's /runtime subpath. The factory is async (it prepares JWT key material — including the one-time JWKS fetch when jwksUrl is set), and the result is role-first.
// src/prisma/db.ts
import { supabase } from '@prisma-next/extension-supabase/runtime';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
export const db = await supabase<Contract>({
contractJson,
url: process.env['DATABASE_URL'], // direct Postgres connection — see pitfalls
jwksUrl: process.env['SUPABASE_JWKS_URL'], // https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json
// Legacy HS256 projects use jwtSecret: process.env['SUPABASE_JWT_SECRET'] instead — exactly one of the two.
});
Options beyond the basics: middleware (same composition as postgres() — see prisma-next-runtime; middleware never sees the role-binding set_config calls), poolOptions, pg (BYO pg.Pool / pg.Client instead of url). Teardown is await db.close() / await using exactly as in prisma-next-runtime — the same script-hang rules apply.
Workflow — Role-bound queries
The concept: bind the role that should execute the request, then query through the returned RoleBoundDb — every query surface from prisma-next-queries works, RLS-filtered by Postgres.
// A signed-in user: rows are RLS-scoped to the JWT's auth.uid().
const userDb = await db.asUser(jwt); // async — rejects with code SUPABASE.JWT_INVALID on a bad/expired token
const mine = await userDb.orm.public.Profile.select('id', 'username').all();
// The anon role: sees what anon policies permit.
const listing = await db.asAnon().orm.public.Profile.select('id', 'username').all();
// service_role: BYPASSRLS — sees everything in YOUR contract.
const all = await db.asServiceRole().orm.public.Profile.select('id', 'username').all();
// Writes ride the same surfaces; RLS filters them too. An UPDATE against
// another owner's row affects 0 rows; a withCheck violation raises an error.
const updated = await userDb.orm.public.Profile
.where({ userId: me })
.updateAndCount({ username: 'new-name' });
Notes: asAnon() / asServiceRole() are sync; only asUser is async. Multi-namespace contracts address models by coordinate (orm.public.Profile, sql.public.profile) — see prisma-next-queries § Namespace-aware accessors. RoleBoundDb.transaction(fn) wraps work in a transaction on the role-bound session.
Workflow — Admin reads of auth.* / storage.*
The concept: Supabase-internal tables are not part of your contract, so they are not on your query surfaces. The service_role binding carries a secondary root — db.asServiceRole().supabase — which is the pack's contract surface:
const admin = db.asServiceRole();
// SQL builder over the pack contract:
const users = await admin.supabase
.execute(admin.supabase.sql.auth.users.select('id', 'email').build())
.toArray();
// ORM over the pack contract:
const sessions = await admin.supabase.orm.auth.AuthSession.select('id', 'aal').all();
// Native enum values (e.g. auth.aal_level) come typed:
type AalLevel = (typeof admin.supabase.nativeEnums.auth.AalLevel)['Value'];
The admin root needs a one-time grant. A real Supabase project gives service_role no table privileges on auth.* / storage.* — out of the box, the reads above fail with permission denied for table users (sqlState 42501). Grant exactly what you read, narrowly:
GRANT USAGE ON SCHEMA auth TO service_role;
GRANT SELECT ON TABLE auth.users TO service_role;
Other boundaries to respect: asUser / asAnon have no .supabase; the admin root has no .transaction (it is a separate contract-bound runtime sharing the pool — a transaction spanning both roots is out of scope); and for user management (creating users, password resets) prefer the GoTrue Admin API — Supabase-internal schemas can drift across platform upgrades; direct service_role SQL is for ad-hoc admin reads.
Workflow — Grants
The concept: RLS policies are row filters on top of ordinary table privileges — a role with policies but no GRANT gets permission denied, not filtered rows. On Supabase the two directions are easy to get backwards:
- Your own
public tables need nothing. Supabase ships ALTER DEFAULT PRIVILEGES on public, so tables created by prisma-next db init / migrate inherit full grants for anon / authenticated / service_role automatically — the same as dashboard-created tables. RLS policies are what actually protect the rows; do not add per-table grants, and do not narrow the defaults unless you have a reason.
- The one grant you do need is for admin reads of Supabase-internal tables —
service_role has no table privileges on auth.* / storage.* (see Admin reads above for the narrow GRANT USAGE / GRANT SELECT pair).
Run grants via the Supabase SQL editor or psql. Symptom of a missing grant: permission denied for table … (sqlState 42501) instead of an empty result.
Workflow — Connecting to a real Supabase project
The concept: the runtime needs a direct, session-capable Postgres connection — it binds roles with session-scoped set_config + RESET ALL.
- Session pooler (
aws-0-<region>.pooler.supabase.com:5432, username postgres.<project-ref>) — works everywhere, IPv4. The default choice.
- Direct connection (
db.<project-ref>.supabase.co:5432) — works, but is IPv6-only on new projects; from IPv4-only environments it fails DNS/connect.
- Transaction pooler (port 6543) — do not use. Transaction pooling breaks session GUCs; role binding will misbehave.
.env carries DATABASE_URL and the JWT key source. For current projects that is SUPABASE_JWKS_URL — https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json (local stack: http://127.0.0.1:54321/auth/v1/.well-known/jwks.json). Only legacy HS256 projects use SUPABASE_JWT_SECRET (Project Settings → API → JWT Secret) — and note supabase status prints a JWT_SECRET even on ES256 projects, so don't infer the mode from its presence; check the JWKS endpoint or a token's header alg.
Common Pitfalls
- Using the transaction pooler (port 6543). Session GUC role binding requires a session-capable connection — use the session pooler (5432) or the direct connection.
- Wiring
jwtSecret because supabase status prints a JWT_SECRET. Current projects sign ES256; asUser then throws SUPABASE.JWT_INVALID explaining the token is ES256 and the client needs jwksUrl. Configure SUPABASE_JWKS_URL; reserve jwtSecret for legacy HS256 projects.
- Grants in the wrong direction. Your
public tables need no grants (Supabase's default privileges cover them; RLS protects the rows) — the grant you need is the narrow auth.* pair for service_role admin reads. permission denied (42501) means a missing grant, not a filtered result.
- Expecting
db.sql / db.orm on the top-level db. The Supabase db is role-first; bind a role, query the RoleBoundDb.
- Forgetting
await — on the supabase() factory and on asUser(jwt). Both are async; asAnon() / asServiceRole() are not.
- Expecting
.supabase on asUser / asAnon. Admin access to auth.* is service_role-only by construction — and even service_role needs the one-time narrow grant first.
- A
policy_* block whose target lacks @@rls. Emit fails with PSL_EXTENSION_TARGET_MODEL_MISSING_ATTRIBUTE — add @@rls to the model.
- Unquoted camelCase columns or missing casts in predicates. Predicates are verbatim SQL:
"userId" needs quotes; compare uuid to auth.uid() with a ::uuid cast where the column isn't already uuid.
- Passing both
jwksUrl and jwtSecret (or neither) — the supabase() promise rejects with SUPABASE.CONFIG_INVALID. It's an async factory, so the misconfiguration surfaces as a rejection (await / .catch), not a synchronous throw.
- Treating an RLS-filtered write as an error. An
UPDATE against a row the role can't see affects 0 rows (no exception); only withCheck violations raise.
What Prisma Next doesn't do yet
- No
/control subpath on the extension — it can't register through the target façade's defineConfig({ extensions: [...] }); wiring goes through the low-level config's extensions as shown above. File interest via prisma-next-feedback.
GRANT authoring. Table privileges are not contract elements; the one grant a Supabase app needs (the service_role auth.* pair for admin reads) is run once by hand (SQL editor / psql). If you want grants managed by the contract, file via prisma-next-feedback.
- Transactions spanning the app root and the
.supabase admin root. The two roots are separate contract-bound runtimes sharing one pool; a cross-root transaction is not supported.
- Triggers / functions as contract elements. The classic "create a profile row on signup"
auth.users trigger is authored as raw SQL against your database, not in the contract. auth.uid() etc. appear only inside opaque policy predicate strings.
- Supabase Realtime, storage uploads, PostgREST /
@supabase/supabase-js interop, edge runtimes. Out of scope for the extension — it speaks Postgres directly (Node.js / Bun).
Reference Files
examples/supabase — the canonical runnable app: config, contract, db.ts, acceptance tests, README.
packages/3-extensions/supabase/README.md — package-level reference (JWT modes, role-binding model, unsupported scope).
packages/3-extensions/supabase/src/runtime/supabase.ts — the authoritative options/type surface (SupabaseOptions, RoleBoundDb, ServiceRoleDb).
Checklist
1---2name: prisma-next-supabase3description: Use Prisma Next with a Supabase project via `@prisma-next/extension-supabase` — wire `extensions: [supabasePack]`, declare cross-space FKs to `supabase:auth.AuthUser`, author RLS policies (`policy_select` / `policy_update` / `@@rls`, `auth.uid()` predicates), build `db.ts` with the `supabase()` factory, bind roles per request (`asUser(jwt)` / `asAnon()` / `asServiceRole()`), query `auth.*` / `storage.*` via the `db.asServiceRole().supabase` admin root, and validate JWTs (`jwksUrl` for current projects / `jwtSecret` for legacy HS256). Use for supabase, RLS, row level security, policy, role binding, anon, authenticated, service_role, auth.users, auth.uid(), JWT, JWKS, SUPABASE_JWKS_URL, SUPABASE_JWT_SECRET, SUPABASE.JWT_INVALID, SUPABASE.CONFIG_INVALID, RoleBoundDb, session pooler, supabase:auth.AuthUser, @prisma-next/extension-supabase.4---5
6# Prisma Next — Supabase
7
8> **Edit your data contract. Prisma handles the rest.**
9
10This skill covers using Prisma Next against a **Supabase** project end-to-end: composing the Supabase extension pack, referencing Supabase-owned tables from your contract, authoring row-level-security (RLS) policies, and running role-bound queries through the `supabase()` runtime.
11
12## When to Use
13
14- User has a Supabase project (or wants one) and is wiring Prisma Next into it.
15- User wants RLS policies on their tables (`policy_select`, `@@rls`, `auth.uid()`).
16- User wants per-request role binding (`asUser(jwt)`, `asAnon()`, `asServiceRole()`).
17- User wants a foreign key into `auth.users` (cross-space FK).
18- User wants to read Supabase-internal tables (`auth.*`, `storage.*`) as an admin.
19- User mentions: *supabase, RLS, row level security, policy, anon, authenticated, service_role, auth.users, auth.uid(), JWT, jwtSecret, jwksUrl, SUPABASE.JWT_INVALID, RoleBoundDb, session pooler*.
20
21## When Not to Use
22
23- General contract editing (models, fields, relations) → `prisma-next-contract`.
24- Non-Supabase `db.ts` wiring, middleware, teardown → `prisma-next-runtime`.
25- General query shapes (filtering, includes, aggregates) → `prisma-next-queries` — everything there applies to a role-bound `db` too.
26- Migration planning / applying → `prisma-next-migrations`.
27
28## Key Concepts
29
30- **The pack is an `external` contract space.** `@prisma-next/extension-supabase/pack` ships a complete, introspection-generated contract of everything Supabase owns — the `auth` and `storage` schemas, their native enum types, and the platform roles (`anon`, `authenticated`, `service_role`) — all with control policy `external`. Composed via `extensions`, it means: the migration planner **emits no DDL** for those objects (Supabase manages them), and `db verify` **confirms they exist** in the live database. Your own tables stay `managed` as usual.
31- **Roles come from the pack; you never declare them.** RLS `roles = [authenticated]` identifiers resolve against the composed contract. Pointing the runtime at a non-Supabase Postgres fails verify with a `not-found` issue naming the missing role — the common "wrong database" misconfiguration surfaces before queries run.
32- **The runtime is role-first.** `supabase()` returns a `SupabaseDb` with **no top-level query surface** — there is no `db.sql` / `db.orm` until you bind a role. `await db.asUser(jwt)` / `db.asAnon()` / `db.asServiceRole()` each return a `RoleBoundDb` exposing `.sql`, `.orm`, `.raw`, `.execute(plan)`, and `.transaction(fn)`. This is deliberate: in a Supabase app there is no meaningful "no role" execution context, and defaulting to the connection's login role is a silent-RLS-bypass footgun.
33- **Role binding is below middleware and cannot leak.** Each role-bound query runs on a connection that had `set_config('role', …)` and `set_config('request.jwt.claims', …)` applied beneath the user-middleware chain, with `RESET ALL` on release. Postgres-side `auth.uid()` / `auth.jwt()` read those session vars — RLS enforcement is Postgres's job; the runtime's job is binding the context.
34- **RLS is enforced by policies *and* grants.** Policies filter *rows*; `GRANT` controls *table access*. Prisma Next authors and migrates the policies; it does not author grants (see *What Prisma Next doesn't do yet*). A role with policies but no `GRANT` gets a permission error, not filtered rows. On Supabase your `public` tables already carry the platform-role grants via default privileges — the grant that is actually missing out of the box is `service_role`'s on `auth.*` / `storage.*` (see *Workflow — Grants*).
35- **JWT validation is eager and configurable — current Supabase projects need `jwksUrl`.** `asUser(jwt)` verifies the token (via `jose`) *before* any connection is acquired: signature + expiry against `jwksUrl` (asymmetric signing keys — **the default on current Supabase projects**, which sign ES256) **xor** `jwtSecret` (the symmetric HS256 secret — legacy projects only). Both or neither → a structured error with code `SUPABASE.CONFIG_INVALID`. Bad tokens throw a structured error with code `SUPABASE.JWT_INVALID` and a typed `meta.reason` — including a mismatch between the token's algorithm and the configured key source (an ES256 token against a `jwtSecret` client names the problem and tells you to switch to `jwksUrl`). The Postgres role is derived from the token's `role` claim (defaults to `authenticated`). Note: `supabase status` still prints a `JWT_SECRET` even on projects that sign ES256 — its presence does not mean your project uses it.
36- **Admin access to `auth.*` / `storage.*` is a secondary root on `service_role` only — and needs a one-time grant.** `db.asServiceRole().supabase` exposes the pack's own contract (`.sql`, `.orm`, `.nativeEnums`, `.execute`). The root exists only on `service_role` by design, but a real Supabase project grants `service_role` **no table privileges** on `auth.*` / `storage.*` (only schema `USAGE`; only `postgres` holds table grants). Before the admin root can read a Supabase-internal table, run the narrow grant once (see *Workflow — Grants*). `asUser` / `asAnon` have no `.supabase`, and the primary `asServiceRole().sql` / `.orm` stay scoped to *your* contract.
37
38## Workflow — Wire the pack into the config
39
40The concept: the pack registers the Supabase contract space so your contract can reference it and the planner/verifier know what Supabase owns. The extension has no `/control` subpath yet, so it can't go through the target façade's `defineConfig({ extensions: [...] })` — it wires into the low-level config's `extensions` (see *What Prisma Next doesn't do yet*). The low-level imports below are a **deliberate exception** to the façade-only import rule, forced by that gap; the block mirrors `examples/supabase/prisma-next.config.ts` verbatim — copy it rather than composing your own:
41
42```typescript
43// prisma-next.config.ts
44import postgresAdapter from '@prisma-next/adapter-postgres/control';
45import { defineConfig } from '@prisma-next/cli/config-types';
46import postgresDriver from '@prisma-next/driver-postgres/control';
47import supabasePack from '@prisma-next/extension-supabase/pack';
48import sql from '@prisma-next/family-sql/control';
49import { prismaContract } from '@prisma-next/sql-contract-psl/provider';
50import postgres from '@prisma-next/target-postgres/control';
51import postgresPackRef from '@prisma-next/target-postgres/pack';
52import { postgresCreateNamespace } from '@prisma-next/target-postgres/types';
53
54export default defineConfig({
55 family: sql,
56 target: postgres,
57 adapter: postgresAdapter,
58 driver: postgresDriver,
59 extensions: [supabasePack],
60 contract: prismaContract('./src/contract.prisma', {
61 output: 'src/contract.json',
62 target: postgresPackRef,
63 createNamespace: postgresCreateNamespace,
64 }),
65 migrations: { dir: 'migrations' },
66});
67```
68
69## Workflow — Contract: FK into `auth.users` + RLS policies
70
71The concept: your models live in your namespaces (`public`); Supabase's live in the pack's (`auth`, `storage`). A relation field typed `supabase:auth.AuthUser` is a **cross-space FK** — the planner emits `REFERENCES "auth"."users"("id")`, and the target table is verified, never migrated. RLS policies are top-level `policy_<operation>` blocks in the same namespace as their target model, and the target model must opt in with `@@rls`. Mirror `examples/supabase/src/contract.prisma`:
72
73```prisma
74namespace public {
75 model Profile {
76 id Uuid @id @default(uuid())
77 username String
78 userId Uuid @unique
79 user supabase:auth.AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)
80 @@map("profile")
81 @@rls
82 }
83
84 // authenticated may read only their own profile.
85 policy_select profile_owner_read {
86 target = Profile
87 roles = [authenticated]
88 using = "\"userId\"::uuid = auth.uid()"
89 }
90
91 // anon may read every profile (a public directory listing).
92 policy_select profile_public_read {
93 target = Profile
94 roles = [anon]
95 using = "true"
96 }
97
98 // authenticated may update only their own profile, and may not
99 // reassign it to another owner (WITH CHECK).
100 policy_update profile_owner_write {
101 target = Profile
102 roles = [authenticated]
103 using = "\"userId\"::uuid = auth.uid()"
104 withCheck = "\"userId\"::uuid = auth.uid()"
105 }
106}
107```
108
109The `Uuid` constructor selects native UUID storage in type position. The legacy `@db.Uuid` spelling is removed; rewrite any `String @db.Uuid` alias or field as `Uuid` before emitting.
110
111The pieces:
112
113- **Per-operation policy blocks**: `policy_select`, `policy_insert`, `policy_update`, `policy_delete`, `policy_all`. Body is `key = value`: `target` (a model in this namespace), `roles` (resolve against the composed contract — the pack supplies `anon` / `authenticated` / `service_role`), `using`, and (for write operations) `withCheck`. Multiple permissive policies per `(target, operation)` are valid — Postgres ORs them.
114- **`@@rls` is required on policy targets.** A `policy_*` block whose target model lacks `@@rls` fails emit with `PSL_EXTENSION_TARGET_MODEL_MISSING_ATTRIBUTE`. A model with `@@rls` and *no* policies is also meaningful: RLS enabled, deny-all.
115- **Predicates are verbatim SQL strings.** Quote camelCase column names inside them (`\"userId\"`), and cast where needed — `auth.uid()` returns `uuid`. Renames in your contract do not rewrite predicate bodies.
116- **TS-builder parity exists.** `@prisma-next/postgres/contract-builder` exports `policySelect` / `policyInsert` / `policyUpdate` / `policyDelete` / `policyAll`, `rlsEnabled(Model)`, and `role('anon')` — mirroring the PSL lowering key-for-key (identical emitted wire names). PSL is the canonical path shown here.
117
118Emit + migrate as usual (`prisma-next contract emit`, then `prisma-next-migrations`). The plan creates your table, its FK, `ENABLE ROW LEVEL SECURITY`, and the `CREATE POLICY` statements — and **no DDL for `auth.*`**.
119
120## Workflow — `db.ts` with the `supabase()` factory
121
122The concept: instead of the stock `postgres()` factory, a Supabase app builds its client with `supabase()` from the extension's `/runtime` subpath. The factory is **async** (it prepares JWT key material — including the one-time JWKS fetch when `jwksUrl` is set), and the result is role-first.
123
124```typescript
125// src/prisma/db.ts
126import { supabase } from '@prisma-next/extension-supabase/runtime';
127import type { Contract } from './contract.d';
128import contractJson from './contract.json' with { type: 'json' };
129
130export const db = await supabase<Contract>({
131 contractJson,
132 url: process.env['DATABASE_URL'], // direct Postgres connection — see pitfalls
133 jwksUrl: process.env['SUPABASE_JWKS_URL'], // https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json
134 // Legacy HS256 projects use jwtSecret: process.env['SUPABASE_JWT_SECRET'] instead — exactly one of the two.
135});
136```
137
138Options beyond the basics: `middleware` (same composition as `postgres()` — see `prisma-next-runtime`; middleware never sees the role-binding `set_config` calls), `poolOptions`, `pg` (BYO `pg.Pool` / `pg.Client` instead of `url`). Teardown is `await db.close()` / `await using` exactly as in `prisma-next-runtime` — the same script-hang rules apply.
139
140## Workflow — Role-bound queries
141
142The concept: bind the role that should execute the request, then query through the returned `RoleBoundDb` — every query surface from `prisma-next-queries` works, RLS-filtered by Postgres.
143
144```typescript
145// A signed-in user: rows are RLS-scoped to the JWT's auth.uid().
146const userDb = await db.asUser(jwt); // async — rejects with code SUPABASE.JWT_INVALID on a bad/expired token
147const mine = await userDb.orm.public.Profile.select('id', 'username').all();
148
149// The anon role: sees what anon policies permit.
150const listing = await db.asAnon().orm.public.Profile.select('id', 'username').all();
151
152// service_role: BYPASSRLS — sees everything in YOUR contract.
153const all = await db.asServiceRole().orm.public.Profile.select('id', 'username').all();
154
155// Writes ride the same surfaces; RLS filters them too. An UPDATE against
156// another owner's row affects 0 rows; a withCheck violation raises an error.
157const updated = await userDb.orm.public.Profile
158 .where({ userId: me })
159 .updateAndCount({ username: 'new-name' });
160```
161
162Notes: `asAnon()` / `asServiceRole()` are sync; only `asUser` is async. Multi-namespace contracts address models by coordinate (`orm.public.Profile`, `sql.public.profile`) — see `prisma-next-queries` § *Namespace-aware accessors*. `RoleBoundDb.transaction(fn)` wraps work in a transaction on the role-bound session.
163
164## Workflow — Admin reads of `auth.*` / `storage.*`
165
166The concept: Supabase-internal tables are not part of your contract, so they are not on your query surfaces. The `service_role` binding carries a **secondary root** — `db.asServiceRole().supabase` — which is the *pack's* contract surface:
167
168```typescript
169const admin = db.asServiceRole();
170
171// SQL builder over the pack contract:
172const users = await admin.supabase
173 .execute(admin.supabase.sql.auth.users.select('id', 'email').build())
174 .toArray();
175
176// ORM over the pack contract:
177const sessions = await admin.supabase.orm.auth.AuthSession.select('id', 'aal').all();
178
179// Native enum values (e.g. auth.aal_level) come typed:
180type AalLevel = (typeof admin.supabase.nativeEnums.auth.AalLevel)['Value'];
181```
182
183**The admin root needs a one-time grant.** A real Supabase project gives `service_role` no table privileges on `auth.*` / `storage.*` — out of the box, the reads above fail with `permission denied for table users` (sqlState `42501`). Grant exactly what you read, narrowly:
184
185```sql
186GRANT USAGE ON SCHEMA auth TO service_role;
187GRANT SELECT ON TABLE auth.users TO service_role;
188```
189
190Other boundaries to respect: `asUser` / `asAnon` have **no** `.supabase`; the admin root has **no** `.transaction` (it is a separate contract-bound runtime sharing the pool — a transaction spanning both roots is out of scope); and for user *management* (creating users, password resets) prefer the GoTrue Admin API — Supabase-internal schemas can drift across platform upgrades; direct `service_role` SQL is for ad-hoc admin reads.
191
192## Workflow — Grants
193
194The concept: RLS policies are row filters on top of ordinary table privileges — a role with policies but no `GRANT` gets `permission denied`, not filtered rows. On Supabase the two directions are easy to get backwards:
195
196- **Your own `public` tables need nothing.** Supabase ships `ALTER DEFAULT PRIVILEGES` on `public`, so tables created by `prisma-next db init` / `migrate` inherit full grants for `anon` / `authenticated` / `service_role` automatically — the same as dashboard-created tables. RLS policies are what actually protect the rows; do not add per-table grants, and do not narrow the defaults unless you have a reason.
197- **The one grant you do need is for admin reads of Supabase-internal tables** — `service_role` has no table privileges on `auth.*` / `storage.*` (see *Admin reads* above for the narrow `GRANT USAGE` / `GRANT SELECT` pair).
198
199Run grants via the Supabase SQL editor or `psql`. Symptom of a missing grant: `permission denied for table …` (sqlState `42501`) instead of an empty result.
200
201## Workflow — Connecting to a real Supabase project
202
203The concept: the runtime needs a **direct, session-capable** Postgres connection — it binds roles with session-scoped `set_config` + `RESET ALL`.
204
205- **Session pooler** (`aws-0-<region>.pooler.supabase.com:5432`, username `postgres.<project-ref>`) — works everywhere, IPv4. The default choice.
206- **Direct connection** (`db.<project-ref>.supabase.co:5432`) — works, but is **IPv6-only** on new projects; from IPv4-only environments it fails DNS/connect.
207- **Transaction pooler (port 6543) — do not use.** Transaction pooling breaks session GUCs; role binding will misbehave.
208
209`.env` carries `DATABASE_URL` and the JWT key source. For current projects that is `SUPABASE_JWKS_URL` — `https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json` (local stack: `http://127.0.0.1:54321/auth/v1/.well-known/jwks.json`). Only legacy HS256 projects use `SUPABASE_JWT_SECRET` (Project Settings → API → JWT Secret) — and note `supabase status` prints a `JWT_SECRET` even on ES256 projects, so don't infer the mode from its presence; check the JWKS endpoint or a token's header `alg`.
210
211## Common Pitfalls
212
2131. **Using the transaction pooler (port 6543).** Session GUC role binding requires a session-capable connection — use the session pooler (5432) or the direct connection.
2142. **Wiring `jwtSecret` because `supabase status` prints a `JWT_SECRET`.** Current projects sign ES256; `asUser` then throws `SUPABASE.JWT_INVALID` explaining the token is ES256 and the client needs `jwksUrl`. Configure `SUPABASE_JWKS_URL`; reserve `jwtSecret` for legacy HS256 projects.
2153. **Grants in the wrong direction.** Your `public` tables need no grants (Supabase's default privileges cover them; RLS protects the rows) — the grant you need is the narrow `auth.*` pair for `service_role` admin reads. `permission denied` (42501) means a missing grant, not a filtered result.
2164. **Expecting `db.sql` / `db.orm` on the top-level `db`.** The Supabase db is role-first; bind a role, query the `RoleBoundDb`.
2175. **Forgetting `await`** — on the `supabase()` factory and on `asUser(jwt)`. Both are async; `asAnon()` / `asServiceRole()` are not.
2186. **Expecting `.supabase` on `asUser` / `asAnon`.** Admin access to `auth.*` is `service_role`-only by construction — and even `service_role` needs the one-time narrow grant first.
2197. **A `policy_*` block whose target lacks `@@rls`.** Emit fails with `PSL_EXTENSION_TARGET_MODEL_MISSING_ATTRIBUTE` — add `@@rls` to the model.
2208. **Unquoted camelCase columns or missing casts in predicates.** Predicates are verbatim SQL: `"userId"` needs quotes; compare uuid to `auth.uid()` with a `::uuid` cast where the column isn't already `uuid`.
2219. **Passing both `jwksUrl` and `jwtSecret`** (or neither) — the `supabase()` promise rejects with `SUPABASE.CONFIG_INVALID`. It's an async factory, so the misconfiguration surfaces as a rejection (`await` / `.catch`), not a synchronous throw.
22210. **Treating an RLS-filtered write as an error.** An `UPDATE` against a row the role can't see affects **0 rows** (no exception); only `withCheck` violations raise.
223
224## What Prisma Next doesn't do yet
225
226- **No `/control` subpath on the extension** — it can't register through the target façade's `defineConfig({ extensions: [...] })`; wiring goes through the low-level config's `extensions` as shown above. File interest via `prisma-next-feedback`.
227- **`GRANT` authoring.** Table privileges are not contract elements; the one grant a Supabase app needs (the `service_role` `auth.*` pair for admin reads) is run once by hand (SQL editor / `psql`). If you want grants managed by the contract, file via `prisma-next-feedback`.
228- **Transactions spanning the app root and the `.supabase` admin root.** The two roots are separate contract-bound runtimes sharing one pool; a cross-root transaction is not supported.
229- **Triggers / functions as contract elements.** The classic "create a profile row on signup" `auth.users` trigger is authored as raw SQL against your database, not in the contract. `auth.uid()` etc. appear only inside opaque policy predicate strings.
230- **Supabase Realtime, storage uploads, PostgREST / `@supabase/supabase-js` interop, edge runtimes.** Out of scope for the extension — it speaks Postgres directly (Node.js / Bun).
231
232## Reference Files
233
234- `examples/supabase` — the canonical runnable app: config, contract, `db.ts`, acceptance tests, README.
235- `packages/3-extensions/supabase/README.md` — package-level reference (JWT modes, role-binding model, unsupported scope).
236- `packages/3-extensions/supabase/src/runtime/supabase.ts` — the authoritative options/type surface (`SupabaseOptions`, `RoleBoundDb`, `ServiceRoleDb`).
237
238## Checklist
239
240- [ ] `extensions: [supabasePack]` in the low-level `defineConfig` (no `/control` subpath exists).
241- [ ] Cross-space FK typed `supabase:auth.AuthUser` with explicit `fields` / `references` (+ `onDelete` if wanted).
242- [ ] Every policy target model carries `@@rls`; predicates quote camelCase columns and cast for `auth.uid()`.
243- [ ] `db.ts` uses `await supabase<Contract>({ contractJson, url, jwksUrl | jwtSecret })` — exactly one JWT key source; `jwksUrl` for current projects, `jwtSecret` only for legacy HS256.
244- [ ] Queries go through a `RoleBoundDb` from `asUser` / `asAnon` / `asServiceRole`; `asUser` is awaited.
245- [ ] `auth.*` / `storage.*` reads go through `asServiceRole().supabase` only, after the one-time narrow grant (`GRANT USAGE ON SCHEMA auth` + `GRANT SELECT` on the tables you read).
246- [ ] No per-table grants added for your own `public` tables — Supabase default privileges cover them; RLS does the protecting.
247- [ ] Connection is session-capable: session pooler or direct connection — never the 6543 transaction pooler.
248- [ ] Did NOT confabulate a `/control` subpath, a top-level `db.sql`, `.supabase` on non-service roles, or grant authoring in the contract.