@howells/neon
v0.1.1
Published
Hardened Neon Postgres client layer: resilient fetch, write-safe retries, 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, write-safe retries, 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 the factories, 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 retrying fetch installed, IPv4-first DNS, and a cached instance. Pass the pooled (-pooler) URL — the factory asserts it.
Sessions and 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.
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 }),
});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). That makes retries safe for writes by construction.
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.
installResilientFetch is process-global and first-writer-wins — neonConfig.fetchFunction is a driver-level singleton, so per-instance retry tuning is deliberately not offered.
Driver decision table
| Situation | Use |
| --- | --- |
| Next.js app code, serverless, Workers, scripts | /http |
| Interactive transactions, LISTEN/NOTIFY, long-running services, batch work | /pool |
| 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
Factories 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.
For /http the cache is an ergonomics nicety (the HTTP driver holds no connections — per-request clients are officially fine). For /pool it is load-bearing: it's the fix for the HMR pool-leak death spiral.
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.
