Skip to content
NewComposite primary keys5 min read

Pool

Every query runs on a pool. It owns the connections, lends one to each unit of work and recycles afterwards, which makes it the only part of UQL that knows anything about your database server: entities, queries and migrations are the same whichever one you build.

Each driver has an entry point of its own, and each takes that driver’s own options verbatim. UQL’s extra options - logger, slowQuery, namingStrategy, default schema, lifecycle listeners - are the last argument on all of them.

Build it once at module scope and export it. That module is also what the uql-migrate CLI reads, so the app and the migrations connect the same way:

uql.config.ts
import type { Config } from 'uql-orm';
import { PgQuerierPool } from 'uql-orm/postgres';
import { Post, User } from './shared/models/index.js';
export const pool = new PgQuerierPool({
connectionString: process.env.DATABASE_URL,
max: 10,
connectionTimeoutMillis: 5_000,
});
export default { pool, entities: [User, Post] } satisfies Config;

A second pool in the same process asks the server for twice the connections while each half sees half the traffic, so import this one rather than constructing another. Two things change that: a dev server that re-runs modules on every hot reload, where the pool is cached on globalThis instead (see Next.js), and functions that freeze between requests, where placement is its own topic.

Constructing a pool opens no socket: it stores the options and stops there. On the pooled drivers (pg, mysql2, mariadb, Bun’s SQL) even pool.getQuerier() borrows nothing - the first statement is what checks a connection out, and release() is what gives it back. So a wrong password or an unreachable host surfaces at the first query rather than at boot. When you would rather find out at boot, run one statement there:

import { pool } from './uql.config.js';
await pool.all('SELECT 1');
Entry point Class First argument
uql-orm/postgres PgQuerierPool pg’s PoolConfig
uql-orm/neon NeonQuerierPool @neondatabase/serverless’s PoolConfig
uql-orm/cockroachdb CrdbQuerierPool pg’s PoolConfig
uql-orm/mysql MySql2QuerierPool mysql2’s PoolOptions
uql-orm/maria MariadbQuerierPool mariadb’s pool config
uql-orm/bunSql BunSqlQuerierPool Bun’s SQL.Options
uql-orm/sqlite Sqlite3QuerierPool, NodeSqliteQuerierPool file path, then the driver’s options
uql-orm/pglite PgliteQuerierPool data directory, then PGlite’s options
uql-orm/libsql LibsqlQuerierPool @libsql/client’s Config
uql-orm/turso TursoQuerierPool Turso Cloud settings, or a client you built
uql-orm/turso/local TursoLocalQuerierPool file path, then the engine’s options
uql-orm/d1 D1QuerierPool the Worker’s D1 binding
uql-orm/mongo MongodbQuerierPool connection URI, then MongoClientOptions

Not all of them pool. SQLite, PGlite and the embedded Turso engine open one connection per database and keep it for the pool’s lifetime; D1 and Turso Cloud reach the database over fetch() and hold nothing between statements. The methods are the same either way. What differs is that queries on a single connection serialize rather than run in parallel, and two queriers there cannot hold independent transactions - a test that needs two open at once needs two databases.

max - connectionLimit on mysql2 and mariadb, maxPoolSize on MongoDB - caps the connections one process opens. Three numbers bound it, and the smallest wins:

  • What the server allows. Postgres’ max_connections divided by the number of processes that connect to it, leaving room for migrations and your own psql.
  • What the process runs at once. A pool larger than the concurrency above it holds idle sockets and nothing else. An HTTP server that answers 40 requests at a time with one query each wants tens; a worker that processes one job at a time wants two or three.
  • What the database can do in parallel, which is roughly cores and disk. Past that, connections queue inside the server rather than in your pool, where they are more expensive and harder to see.

Give it a timeout for acquiring a connection: connectionTimeoutMillis on the pg family, which waits forever without one; connectTimeout on mysql2 and acquireTimeout on mariadb. A database that is asleep, unreachable or out of connections otherwise leaves every request waiting for as long as the runtime allows, and a request that fails in five seconds is worth more than one that hangs.

When the number of processes alone can exhaust the server, the fix is a pooler in front of the database rather than a smaller max: PgBouncer or RDS Proxy for self-managed Postgres, Supavisor on Supabase, Hyperdrive on Workers. All of them are transaction-mode, so session state does not survive between statements: SET LOCAL, advisory locks and temp tables belong inside one transaction callback, and migrations run against the direct endpoint.

pool.end() closes idle connections and waits for the ones still checked out, so call it after the server has stopped accepting requests - a connection comes back only when its unit of work finishes.

import { pool } from './uql.config.js';
process.on('SIGTERM', async () => {
await server.close(); // stop accepting first
await pool.end();
});

NestJS does this for you when app.enableShutdownHooks() is on. D1’s end() is a no-op, and a Turso pool built from a client you passed in will not close a client it does not own. In a serverless function do not call it at all: the platform freezes the process as soon as the response is sent, so the close would not finish, and the database reclaims the connections when the sockets die.

Treat it as the end of that pool’s life. The single-connection pools reopen on the next query, but pg and mysql2 refuse to hand out another connection once ended.

A NAT, a load balancer or the server’s own idle_session_timeout can drop a connection that is sitting in the pool. UQL attaches an error listener to the pg, Neon, CockroachDB and MariaDB driver pools, so that arrives as a logged error and a discarded connection instead of an unhandled 'error' event taking the process down, and the next acquire opens a fresh one.

That covers the pool, not the query that was in flight when the socket went away. Retry that one on a connection error only, and never a write that is not idempotent unless it is wrapped in a transaction. On long-lived remote connections the pg pools also set keepAlive: true by default, which makes the drop less likely rather than impossible.

A querier released with a transaction still open is rolled back and logged rather than handed back as it is. If that rollback fails, the connection carries session state nothing can name, so it is discarded instead of going to the next borrower.

Two cases justify a second pool: a read replica, and a database per tenant.

db.ts
import { PgQuerierPool } from 'uql-orm/postgres';
export const primary = new PgQuerierPool({
connectionString: process.env.DATABASE_URL,
});
export const replica = new PgQuerierPool({
connectionString: process.env.REPLICA_URL,
});

Take the pool as a parameter rather than importing a fixed one. A UniversalQuerier accepts a pool or a querier, so the same function reads from the replica here and joins the caller’s transaction on the primary there:

import type { UniversalQuerier } from 'uql-orm';
import { Invoice } from './shared/models/index.js';
const unpaid = (db: UniversalQuerier, companyId: number) =>
db.findMany(Invoice, { $where: { companyId, paid: false } });
await unpaid(replica, 42);
await primary.transaction((querier) => unpaid(querier, 42));

A database per tenant is a pool per tenant, built on demand and kept in a map. Cap that map and close what you evict: every pool holds its own connections, so a thousand idle tenants is a thousand pools’ worth of sockets. The HTTP handler takes a function for exactly this, and picks the pool per request. Entities are process-wide either way - they are registered once and every pool serves them.

pool.pool is the driver’s own pool on the pg, Neon, CockroachDB, mysql2 and mariadb pools, which is what a library that wants a driver-shaped pool takes: attachDatabasePool from @vercel/functions, or a session store like connect-pg-simple. Bun’s pool exposes a pg-compatible shim under the same name; D1’s binding is pool.db and Bun’s SQL instance is pool.sql.

pool.dialect.dialectName names the backend - 'postgres', 'cockroachdb', 'mysql', 'mariadb', 'sqlite' or 'mongodb' - for the rare code path that has to differ per database.

The pool is the seam. Entities and queries do not change, so a suite can swap the driver for one with no server behind it: PGlite is Postgres itself compiled to WASM, and Sqlite3QuerierPool(':memory:') is a database per pool.

test/db.ts
import { PgliteQuerierPool } from 'uql-orm/pglite';
export const pool = new PgliteQuerierPool();

Build one per suite and end() it afterwards, or a live handle can keep the runner from exiting. Both are single-connection, so tests sharing a pool share a transaction scope; a test that needs a transaction of its own needs a pool of its own.