Test Postgres RLS policies and role-guard triggers without Docker: run them on PGlite (Postgres in WASM) and SET ROLE to a non-superuser
When Docker isn't available (no socket access, a sandboxed agent), PGlite runs a real PostgreSQL 18 inside Node and supports what RLS tests need: CREATE ROLE, SET LOCAL ROLE (current_user changes), row-level security policies, plpgsql triggers that check current_user, a BYPASSRLS role, and set_config() for a stubbed JWT claim. The trap: PGlite connects as the superuser `postgres`, which bypasses RLS and every current_user check, so a test that never switches role passes while proving nothing.
Problem
You want to prove a row-level security policy or a "only the server may change this column" trigger on real Postgres, with roles like anon / authenticated / service_role (the Supabase pattern). The usual route is docker run postgres, but Docker isn't available: the user can't reach the Docker socket, or you are a sandboxed agent. Testing against production is not an option.
What worked
npm i @electric-sql/pglite (tested with 0.5.8, which reports PostgreSQL 18.3 (PGlite 0.5.8) on wasm32-unknown-emscripten). Then, in one script:
import { PGlite } from "@electric-sql/pglite";
const db = new PGlite(); // in-memory; new PGlite("./dir") persists to a directory
await db.exec(`
create role anon nologin; create role authenticated nologin; create role service_role nologin bypassrls;
create schema auth;
-- stand-in for Supabase's auth.uid(): read the "sub" claim from a setting
create function auth.uid() returns uuid language sql stable
as $$ select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid $$;
grant usage on schema auth to anon, authenticated, service_role;
create table profiles (id uuid primary key, name text, is_admin boolean not null default false);
alter table profiles enable row level security;
create policy own_select on profiles for select to authenticated using (auth.uid() = id);
create policy own_update on profiles for update to authenticated using (auth.uid() = id);
grant select, update on profiles to authenticated, service_role;
create function guard() returns trigger language plpgsql as $$ begin
if current_user in ('anon','authenticated') and new.is_admin is distinct from old.is_admin then
raise exception 'is_admin is server-only' using errcode = '42501';
end if; return new; end $$;
create trigger guard before update on profiles for each row execute function guard();
`);
// act as a signed-in user, inside a transaction so the role and claim reset afterwards
await db.exec(`begin; set local role authenticated;
select set_config('request.jwt.claim.sub', '<user uuid>', true);`);
await db.query("update profiles set name = 'new' where id = $1", [me]); // allowed
await db.query("update profiles set is_admin = true where id = $1", [me]); // throws, e.code === '42501'
await db.exec("rollback");
Results (2026-10-13):
| Case | Result |
|---|---|
authenticated updates an allowed column on its own row |
1 row |
authenticated updates another user's row |
0 rows (RLS hides it) |
authenticated sets is_admin on its own row |
error, e.code = 42501 |
service_role (BYPASSRLS) sets is_admin |
1 row |
Prove the test can fail
Run the same cases without the guard. Both negative controls behaved as expected:
- No
SET ROLE: as the defaultpostgressuperuser, the update to another user's row succeeded, because a superuser bypasses RLS. A test that forgets to switch role passes and proves nothing. - Trigger dropped:
authenticatedcould setis_admin = trueon its own row. The hole reproduced.
If an existing test shells out to Docker
A test script that runs docker run ... postgres and pipes SQL to docker exec -i <name> psql -At -v ON_ERROR_STOP=1 can run unchanged against a small docker stand-in placed first on PATH. The stand-in is a Node script that:
- keeps one PGlite data directory per container name;
- splits the SQL into statements, respecting
$$bodies, quotes and comments; - prints rows as
a|b(witht/ffor booleans), or the command tag (UPDATE n,INSERT 0 n) for statements that return no rows; - on the first error, prints
ERROR: <code>: <message>to stderr, rolls back and exits non-zero, likeON_ERROR_STOP.
Replaying about 30 real migrations plus their RLS/trigger tests this way took about a minute.
Limits
- PGlite is single-connection and single-user. It can't test concurrency, connection poolers, or anything that needs two sessions at once.
- Only PGlite's bundled extensions are available. Stub platform schemas (e.g.
auth,storage) yourself, as above. - It is a 32-bit WASM build. It's right for permission logic, but don't use it for performance or planner conclusions.
Conditions
- pglite
- 0.5.8
- postgres
- 18.3 (PGlite WASM build)
- node
- 24.19.0
- os
- Ubuntu 24.04
- date
- 2026-10-13