@howells/neon
v0.2.0
Published
Hardened Neon Postgres client layer: resilient fetch, narrow transport retries, opt-in IPv4-first, HMR-safe pools, endpoint guards.
Readme
@howells/neon
Hardened Neon Postgres client layer. One package encoding every fix the portfolio learned the hard way: resilient fetch, narrow transport retries, opt-in IPv4-first DNS, HMR-safe pools, and pooled/direct endpoint guards.
pnpm add @howells/neonDrivers are optional peer dependencies — install only what the subpath you use needs: @neondatabase/serverless for /http, pg for /pool and /mastra, drizzle-orm for /http and /pool (not /mastra), drizzle-kit for /kit.
Why
A survey of 19 Neon-backed repos found the same six failure classes fixed ad hoc, over and over:
- IPv6 happy-eyeballs stall — Neon publishes AAAA records some networks can't route to AWS; Node races a dead IPv6 connection and stalls ~14s.
- Autosuspend cold starts vs. tight timeouts — Neon suspends idle computes and terminates their connections; Postgres' 5s default connect timeout can't survive the wake.
- HMR pool leaks —
next devre-evaluates db modules; module-level singletons leak a fresh pool per reload until the connection limit dies. - Idle-drop errors killing the process — a dropped idle
pgclient emits'error'; unhandled, it takes the process down. - Transient
fetch failed— the HTTP driver is one fetch per query with no retry. - Endpoint misuse — apps stacking pools on the direct endpoint exhaust its compute-sized connection limit; migrations through PgBouncer hit transaction-mode gotchas.
This package is those fixes, versioned.
Quickstart
App data access (default) — @howells/neon/http
import { createHttpDb } from "@howells/neon/http";
import * as schema from "./schema";
export const db = createHttpDb({ url: databaseUrl, schema });
export type Db = typeof db;Stateless HTTP driver with a narrow retrying fetch installed and a fresh Drizzle wrapper on every call, so HMR picks up schema changes. Pass the pooled (-pooler) URL — the factory asserts it.
Interactive transactions — @howells/neon/pool
import { createPooledDb } from "@howells/neon/pool";
import * as schema from "./schema";
export const db = createPooledDb({ url: databaseUrl, schema });Hardened pg pool: max 5, 15s connect timeout (cold starts), 20s idle timeout (release before Neon drops server-side), keepalive, an error listener so idle drops can't kill the process, and globalThis caching so HMR reuses one pool. On Vercel Fluid, pass onPoolCreated: attachDatabasePool from @vercel/functions — unless the code relies on SET LOCAL continuity, which it breaks.
Persistent sessions and LISTEN/NOTIFY
Neon's PgBouncer uses transaction pooling and does not support persistent LISTEN sessions (restrictions). Use a direct URL and hold a checked-out client for the listener's lifetime:
import { createNeonPool } from "@howells/neon/pool";
const listenerPool = createNeonPool({ url: directDatabaseUrl, max: 1 });
const listener = await listenerPool.connect();
listener.on("error", handleListenerError); // Application owns reconnect/resubscribe.
listener.on("notification", handleNotification);
try {
await listener.query("LISTEN updates");
await untilShutdown();
} finally {
listener.release(true); // Destroy the session, including its subscriptions.
await listenerPool.end();
}For Drizzle sessions, createPooledDb({ url: directDatabaseUrl, allowDirect: true }) makes the endpoint exception explicit; session state still requires a checked-out client. Ordinary interactive transactions use the pooled URL and db.transaction(...).
Migrations — @howells/neon/kit
// drizzle.config.ts
import { neonKitConfig } from "@howells/neon/kit";
export default neonKitConfig({
directUrl: process.env.DIRECT_DATABASE_URL ?? "",
schema: "./packages/db/src/schema.ts",
});Asserts the direct endpoint — never push schema through PgBouncer.
Mastra storage — @howells/neon/mastra
import { createMastraPool, mastraPoolOptions } from "@howells/neon/mastra";
const storage = new PostgresStore({
id: "app-storage",
pool: createMastraPool({ url }),
});
const vectors = new PgVector({
connectionString: url,
pgPoolOptions: mastraPoolOptions({ url }),
});Pass a URL without SSL or host query parameters to PgVector and mastraPoolOptions; the helper rejects them because pg URL parsing can override its TLS options (pg SSL precedence). Use the same URL for both arguments.
createMastraPool reuses a pool by name/url, forwards onIdleError and onPoolCreated, and joins the closeAll() registry. The first call owns tuning and callbacks. The application owns shared shutdown; do not call pool.end() from a hot-reloaded module or independently close a shared pool. PgVector owns its separate pool and must be shut down through its own lifecycle.
One config, two pools: PgVector accepts no injected pool, so both derive from the same resolver and cannot drift. max is clamped ≥ 2 — a single-client pool deadlocks @mastra/pg batch writes. @mastra/pg silently drops connectionTimeoutMillis from pgPoolOptions-style config, which is why createMastraPool builds the pool itself.
Lint enforcement — @howells/neon/lint
import { createOxlintConfig } from "@howells/neon/lint";Bans drizzle-orm/neon-serverless and Pool/Client from @neondatabase/serverless via no-restricted-imports. Stopgap until the @howells/lint fleet preset carries the same rules.
createOxlintConfig({ restrictPgOutsideDb: true }) additionally bans bare pg imports everywhere except the packages that own a direct Postgres connection (pgAllowedIn, default packages/db/** and packages/mastra/**). The carve-out is a positive-glob override rather than negated globs because oxlint ignores !-negation in override files arrays. Overrides replace the top-level rule config for matching files, so if your own oxlint config declares no-restricted-imports in an override that also matches those packages, merge the fragment's paths rather than clobbering them.
Retry safety — read this before touching the knobs
The default retry matcher (isConnectionError) is narrow: it only retries failures where the request provably never reached the server (refused, unresolved DNS, connect-phase timeout). This applies to a single transport attempt, not an arbitrary callback that may already have completed other writes.
The broad matcher (isTransientNeonError) also retries mid-flight drops (fetch failed, ECONNRESET) — where the server may already have executed the statement. Retrying a non-idempotent write through it can double-apply. Use it only via retryDbRead, or pass it to createResilientFetch only when the workload is read-only or idempotent.
Neither matcher ever retries a real query failure: a non-connection SQLSTATE (constraint violation, syntax error) is authoritative and refuses retry regardless of what the message contains.
withNeonRetry(op) runs once by default. Retrying the whole callback requires withNeonRetry(op, { idempotent: true }); this is your assertion that every operation in it can safely run again. retryDbRead makes that assertion for read-only callbacks.
installResilientFetch is first-writer-wins per actual driver configuration. ESM and CJS driver instances are installed independently. Call it at startup to tune timing; custom global retry classifiers are rejected. createHttpDb and createResilientSql use this global narrow policy and accept no per-client fetch configuration.
Driver decision table
| Situation | Use |
| --- | --- |
| Next.js app code, serverless, Workers, scripts | /http |
| Interactive transactions, long-running services, batch work | /pool + pooled URL |
| Persistent LISTEN/NOTIFY sessions | /pool + direct URL + checked-out client |
| Schema push / migrations | /kit + direct URL |
| Mastra PostgresStore / PgVector | /mastra |
| The Neon WebSocket driver (neon-serverless) | Don't. Idle-socket drops and HMR leaks; the one documented exception is edge runtimes that genuinely need the WS pool (neonConfig.poolQueryViaFetch). |
Caching
TCP pools cache on globalThis under a versioned Symbol.for key, which survives both next dev HMR and the ESM/CJS dual-package hazard. Entries are never evicted — keys must be low-cardinality. Never derive name or url from per-tenant/dynamic values, or each distinct key pins a pool for the life of the process. Call closeAll() in test teardown so cached pools don't hang vitest.
HTTP wrappers are never cached. /pool and /mastra cache connection pools in separate namespaces; schema wrappers are always fresh. closeAll() closes and clears registered pools at application shutdown or test teardown. Raw createNeonPool callers own pool.end() themselves.
Network policy
Factories do not change process-wide DNS ordering or automatic address-family selection. On a Node host with broken IPv6 routing, explicitly call preferIpv4() from @howells/neon at application startup. This changes both settings for the entire process.
0.2.0 release: migrating from 0.1.x
This release tightens retry safety, makes network policy explicit, and unifies pool ownership across ESM and CJS consumers. It changes retry and configuration APIs; review these steps before upgrading.
- Add
idempotent: trueonly to callbacks that are safe to repeat; otherwisewithNeonRetrynow runs once. - Move HTTP timing options to
installResilientFetchat startup. Remove per-clientfetchOptionsand the second argument tocreateResilientSql; broad global retry policies are rejected. - HTTP
nameremains accepted for compatibility but is ignored. Do not rely on wrapper identity. - Mastra pools now share ownership by name/URL; close them with
closeAll()at application shutdown. Remove SSL/host query parameters from the URL used withmastraPoolOptionsandPgVector. - Call
preferIpv4()explicitly if your application needs the previous network policy. - Generic cache helpers
getOrCreateandmarkOnceare internal; use the pool factories andcloseAll()instead.
Environment contract
This package never reads process.env. Bring your own URLs — pair with @howells/envy:
DATABASE_URL— pooled (-pooler) host, for/http,/pool,/mastraDIRECT_DATABASE_URL— direct host, for/kitand admin tooling
Recommended connection-string params (or use withNeonParams): sslmode=verify-full&channel_binding=require&connect_timeout=15.
Per-environment branch selection (a dev Neon branch's URL in local .env, production's in Vercel) is env-layer work, not client-layer — do it in your envy schema, then hand the resolved URL here.
Compatibility
| drizzle-orm | Factories (/http, /pool) | Primitives (.) |
| --- | --- | --- |
| ^0.45 | ✅ | ✅ |
| 1.0.0-rc (relational v2) | ❌ use primitives + your own drizzle({ client, relations }) | ✅ |
The 0.45 peer range is a stopgap: when drizzle 1.x GAs and the fleet moves, the factories grow a real 1.x branch.
Non-goals
No process.env reads. No schema or migration opinions beyond /kit's direct-URL enforcement. No query builders. No WebSocket-driver support. No logging framework — injectable callbacks only.
