npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ultimatelemon-eu/drizzle

v1.3.0

Published

Drizzle on node-postgres for UltimateLemon apps: a lazy client, Postgres error helpers, and migrations at container start behind an advisory lock.

Readme

@ultimatelemon-eu/drizzle

Drizzle on node-postgres, the way every UltimateLemon app wires it: a lazy database client, helpers to recognise Postgres errors, and migrations at container start behind an advisory lock.

npm install @ultimatelemon-eu/drizzle drizzle-orm pg

Replaces @ultimatelemon-eu/drizzle-migrate. Migrating from it is one import: @ultimatelemon-eu/drizzle-migrate becomes @ultimatelemon-eu/drizzle/migrate, with the same API.

Client

// src/db/index.ts
import { createDb } from '@ultimatelemon-eu/drizzle';

import * as schema from './schema';

import type { DbOrTx as DbOrTxOf } from '@ultimatelemon-eu/drizzle';

export const { db, getPool, closeDb } = createDb({
  schema,
  casing: 'snake_case'
});

export type DbOrTx = DbOrTxOf<typeof schema>;
const rows = await db.select().from(users);

Importing the module reads no env and opens no connection. The pool is built on the first query, so next build in a Docker or CI job without DATABASE_URL does not fail or hang on an import, and a test file can import the module without a database.

A page that queries during the build still needs a database, or it must be dynamic (export const dynamic = 'force-dynamic').

| Option | Default | Does | | ------------------ | --------------- | ---------------------------------------------------- | | schema | required | Your Drizzle schema, for db.query and types | | connectionString | $DATABASE_URL | Read at the first query, not at createDb | | pool | see below | Any pg PoolConfig, merged over the defaults | | cacheKey | 'default' | Separates two databases in one process | | other | | Passed to drizzle(): casing, logger, and so on |

Pool defaults: max from $DATABASE_POOL_MAX (10 when unset, capped at 20), and connectionTimeoutMillis: 5000. Without that timeout, pg waits forever for a database that does not answer.

A $DATABASE_POOL_MAX above 20 is clamped to 20 with a console.warn naming both the requested and the applied value. Postgres usually runs on its default max_connections = 100, part of which is reserved, and a rolling deploy has several instances alive at once — so 20 per instance is ample, while a typo (10000 instead of 100) cannot starve the server. It warns instead of throwing because the pool is built lazily on the first query: a throw would only show up at some later request, not at boot or on a health check. A project that really needs more sets it explicitly via pool: { max: N }, which is not clamped.

Three more defaults bound how long a connection can sit busy once it is open, so a stuck row lock or a forgotten open transaction cannot hold a pool connection (and the lock it took) forever:

| Setting | Default | Why | | ------------------------------------- | ------- | ---------------------------------------------------------------------------------------- | | lock_timeout | 3000 | How long a statement waits for a row/table lock (e.g. .for('update')) before giving up | | statement_timeout | 10000 | How long any single statement may run | | idle_in_transaction_session_timeout | 10000 | How long a transaction may sit open between statements before Postgres ends the session |

lock_timeout must stay strictly below statement_timeout: otherwise statement_timeout (57014 query_canceled) cancels a lock wait before lock_timeout (55P03 lock_not_available) gets the chance to fire, hiding the more specific error a caller would want to catch.

These are plain pg PoolConfig fields, sent as Postgres startup parameters on every connection, so they apply for the life of the session — including a .for('update') row lock — without any SET/SET LOCAL query to set them. A project overrides or disables any of them the same way it already overrides connectionTimeoutMillis, through the pool option, e.g. pool: { statement_timeout: false } or pool: { lock_timeout: 0 }.

A connection pooler running in transaction-pooling mode (e.g. PgBouncer) may reject or fail to persist unknown startup parameters per client session — that is a property of the pooler, not of this package, and worth checking against your own Postgres infrastructure.

idle_in_transaction_session_timeout makes Postgres terminate the session server-side, which pg surfaces as an 'error' event rather than a query rejection. Left unhandled, that is an unhandled EventEmitter error that crashes the process — for both a client sitting idle in the pool and a client checked out via pool.connect()/a transaction (pg-pool only keeps its own forwarding listener attached while a client is idle in the pool, not while it is checked out). createDb attaches a listener for both cases — pool.on('error', ...) and a per-client listener added on pool's 'connect' event — so this package's defaults cannot introduce that crash.

createDb returns:

| Member | Does | | ----------- | --------------------------------------------------------- | | db | The Drizzle database; builds pool and client on first use | | getDb() | The same client, without the proxy | | getPool() | The pg pool, for LISTEN, raw queries or health checks | | closeDb() | Ends the pool; the next query builds a fresh one |

If a client is still checked out when closeDb() is called (a forgotten pool.connect() without release(), or a transaction that never commits or rolls back), pool.end() would otherwise never settle, since pg/pg-pool only calls back once every checked-out client has been released. closeDb() gives up waiting after an internal timeout instead, logging a console.warn naming the cacheKey and how many clients were still checked out; the abandoned pool keeps draining in the background and the next getDb()/getPool() call for that cacheKey builds a fresh one.

On success, closeDb() ends the pool and the next getDb()/getPool() builds a fresh one, as above. If the underlying pool.end() rejects — in practice this happens when end() is called on a pool that is already ending or has already ended (pg/pg-pool's own "Called end on pool more than once"), for example a closeDb() retry after an earlier attempt on this same pool already failed (a successful attempt clears the cached pool, so there is nothing left to retry against), or something else having called pool.end()/getPool().end() directly — closeDb() rejects with a DbCloseError (the original pg/pg-pool error as cause) instead of leaking that raw error, and deliberately leaves the pool in place: a pg pool can never serve a new connection again once end() has been called on it, so getDb()/getPool() keep returning that same (no longer usable) pool rather than silently opening a second one against the same database. Recovering needs a process restart.

Two closeDb() calls that overlap while the first is still running (e.g. a SIGTERM and a SIGINT handler both calling closeDb() without awaiting one before the other) share the one real pool.end() call and its outcome, rather than the second one calling pool.end() again on the same pool and getting a spurious rejection purely from the overlap.

The pool lives on globalThis, keyed by cacheKey. Next.js dev reloads the module on every change; without this each reload opens another pool until Postgres refuses connections.

Types: Db<typeof schema>, Tx<typeof schema> for the argument of db.transaction, and DbOrTx<typeof schema> for a function that works both inside and outside a transaction.

Errors

import { isUniqueViolation } from '@ultimatelemon-eu/drizzle';

try {
  await db.insert(users).values(input);
} catch (error: unknown) {
  if (isUniqueViolation(error, 'users_email_key')) {
    return { error: 'Dit e-mailadres is al in gebruik.' };
  }
  throw error;
}

| Function | True for | | ------------------------------------------- | --------------------------------------------------------------- | | isUniqueViolation(error, constraint?) | 23505 | | isForeignKeyViolation(error, constraint?) | 23503 | | isPgError(error, code, constraint?) | any SQLSTATE, see PgErrorCode | | getPgError(error) | returns { code, constraint, table, column, detail } or null |

Drizzle wraps the pg error in cause; these walk that chain. Only a five-character SQLSTATE counts, so a connection error (ECONNREFUSED) is not mistaken for one. Pass the constraint name when a table has more than one unique index and the message depends on which one fired.

DbCloseError, thrown by closeDb() (see above), carries the original pg/pg-pool error as cause. getPgError/isPgError walk that same cause chain, so they keep working unchanged through a DbCloseError wrapper — though a pool shutdown failure is typically a plain pg-pool error without a SQLSTATE code, so those helpers will usually return null/false for it, same as for any other non-database error. Check error instanceof DbCloseError to tell a failed shutdown apart from a query error.

Migrations

// src/db/migrate.ts
import { runMigrationsCli } from '@ultimatelemon-eu/drizzle/migrate';

await runMigrationsCli({ lockKey: 4178233902n });
# docker/entrypoint.sh
if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
  node /app/db/migrate.cjs
fi
exec "$@"

Bundle src/db/migrate.ts with esbuild into migrate.cjs so the runtime image needs no tsx and no node_modules for it. It imports only the migrate entry, so the bundle stays small.

| Option | Default | Does | | ------------------ | --------------- | ----------------------------- | | connectionString | $DATABASE_URL | Postgres connection | | migrationsFolder | ./drizzle | Where Drizzle wrote the SQL | | lockKey | 4178233900n | Advisory lock key | | lockTimeoutMs | 300000 | How long to wait for the lock | | logger | console.info | Success line |

runMigrations throws; runMigrationsCli exits 0 or 1, which is what an entrypoint wants.

Why a lock

Two containers starting together would run the same CREATE TABLE. Whoever misses the lock waits and then finds nothing left to do — waiting beats skipping, which would serve traffic against a schema that is not there yet.

The lock sits on its own Client, not a pool: Postgres releases a session-level advisory lock when that connection drops, even on a hard kill. A row in a table would not.

lock_timeout means a container stuck behind a jammed migration fails visibly instead of hanging.

Baselining an existing database

A database that ran under another migration tool has no drizzle.__drizzle_migrations, so Drizzle replays the baseline against tables that already exist. Insert one row to mark it applied instead — the hash is the sha256 of the migration file, the timestamp is when from meta/_journal.json.

Tests

npm test runs the unit tests. With TEST_DATABASE_URL set it also runs a query, a real unique violation, a check that lock_timeout arrives as a session GUC, and a check that a client actually checked out of the pool keeps its own 'error' listener, against that database.