PGlite
PGlite is Postgres itself compiled to WebAssembly. It runs inside your process, so there is no server to start, no container to wait on, and no port to pick. UQL treats it as the Postgres dialect it is: the same SQL, the same JSONB operators, the same RETURNING, the same pgvector.
npm install uql-orm @electric-sql/pgliteimport { PgliteQuerierPool } from 'uql-orm/pglite';
export const pool = new PgliteQuerierPool();That is an in-memory database, discarded when the process exits. Pass a directory to keep it:
const pool = new PgliteQuerierPool('file://./pgdata');Where it earns its place
Section titled “Where it earns its place”Tests. A suite that runs against real Postgres no longer needs docker compose up, a CI service container, or a cleanup step. Point the pool at memory:// and every test file gets a fresh Postgres in milliseconds.
Local development. The dialect your migrations were generated for is the dialect you develop against, which SQLite-for-dev-Postgres-for-prod never gives you.
Persisting to disk
Section titled “Persisting to disk”UQL drives transactions with plain BEGIN and COMMIT statements, which PGlite cannot see, so it flushes to the filesystem after every statement inside one. On a persistent dataDir that is worth turning off:
const pool = new PgliteQuerierPool('file://./pgdata', { relaxedDurability: true,});The write still happens; PGlite just stops waiting on each flush before answering.
Vector search
Section titled “Vector search”pgvector is a separate WASM bundle, and PGlite needs it at construction time rather than through CREATE EXTENSION alone:
npm install @electric-sql/pglite-pgvectorimport { vector } from '@electric-sql/pglite-pgvector';import { PgliteQuerierPool } from 'uql-orm/pglite';
const pool = new PgliteQuerierPool('memory://', { extensions: { vector } });From there vector search works exactly as on a server, halfvec and sparsevec included. Note that @electric-sql/pglite-pgvector pins an exact @electric-sql/pglite version, so the two are upgraded together.
One connection, and what follows from it
Section titled “One connection, and what follows from it”PGlite is single-connection by design. Queriers from a pool each get their own transaction state, but they share the one backend, so two transactions open at the same time are in fact the same transaction:
const a = await pool.getQuerier();const b = await pool.getQuerier();
await a.beginTransaction();await b.beginTransaction(); // joins a's transaction rather than starting its ownawait b.rollbackTransaction(); // and discards a's writes with itGive a unit of work that needs isolation its own PgliteQuerierPool, and therefore its own database. Sequential work through one pool is unaffected, which is what a test suite and a single-user dev session both are.
Two smaller consequences:
$lockemits correct SQL (SELECT ... FOR UPDATE), but no second transaction can exist to contend with, so it cannot hand two workers different rows the way it does on a server. If that is the behaviour you are testing, test it on Postgres.findManyStreamhas no cursor to read from, so it buffers the whole result and then yields it. The API is identical; the memory profile is not.
Releasing the connection
Section titled “Releasing the connection”Every querier releases itself when an await using binding goes out of scope, however the block exits:
await using querier = await pool.getQuerier();const users = await querier.findMany(User, {});Differences from uql-orm/postgres
Section titled “Differences from uql-orm/postgres”Everything above, plus one type detail: a BYTEA column reads back as a Uint8Array rather than a Node Buffer. A Buffer is a Uint8Array, so reading bytes is unchanged, but instanceof Buffer and Buffer-only methods are not available.
Otherwise the two are interchangeable. Migrations generated against one apply to the other, and drift:check works the same way, because both report themselves as the postgres dialect.