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

@vibeorm/adapter-pg

v4.3.0

Published

node-postgres adapter for VibeORM v2 — Bun or Node.js

Readme

@vibeorm/adapter-pg

Part of VibeORM — a type-safe TypeScript ORM for Bun and Node. Prisma-schema or TypeScript-DSL input, a canonical schema IR, a generated client, and a dialect-aware SQL layer over PostgreSQL, PGlite, SQLite and MySQL.

This adapter runs VibeORM on node-postgres (pg) against a PostgreSQL server. It works on both Bun and Node, and pg is a peer dependency you install yourself.

bun add @vibeorm/adapter-pg pg

Stable. 2.0.0, published under the latest dist-tag. The v1 line stays installable as vibeorm@1.

Usage

import { VibeClient } from "./generated/vibeorm/index.js";
import { pgAdapter } from "@vibeorm/adapter-pg";

// The adapter reads no environment variable: pass the address you mean.
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error("DATABASE_URL is not set");
const db = VibeClient({ adapter: pgAdapter({ connectionString, max: 20 }) });

const users = await db.user.findMany({ where: { email: { contains: "@example.com" } }, take: 10 });

await db.$disconnect();

disconnect() ends the adapter for good: work that already started finishes, the pool it created is ended once, and every later call refuses with VIBE_ADAPTER_CLOSED. Create a new adapter to connect again.

To reuse a pool you already own, pass it in. The adapter never ends a pool it did not create: in that mode disconnect() only ends the adapter, and the pool stays usable by its owner and by other adapters:

import pg from "pg";

const pool = new pg.Pool({ connectionString });
const db = VibeClient({ adapter: pgAdapter({ pool }) });

Options

pgAdapter(options: PgAdapterOptions) — connectionString or pool is required. Without either, the factory throws VIBE_CONFIG (meta.reason: "missing-address") before any pool or connection exists. The adapter never reads DATABASE_URL. Give a complete URL: node-postgres fills parts a URL leaves out (host, user, database, …) from the PG* environment variables.

| Option | Type | Default | Meaning | |---|---|---|---| | connectionString | string | — (required without pool) | PostgreSQL connection URL | | pool | PgPoolLike | — (required without connectionString) | An existing pg.Pool to use instead of creating one; it is never ended by the adapter, and it wins when both are given | | max | number | 10 | Maximum pooled connections; ignored when pool is given | | statementTimeout | number (ms) | — | Pool-wide statement_timeout; a per-transaction timeout overrides it | | connectionTimeout | number (ms) | — | Wait for a pool checkout before failing (pg's connectionTimeoutMillis) | | idleTimeout | number (ms) | pg-pool's 10000 | How long a pooled connection may sit idle before the pool closes it (pg's idleTimeoutMillis). Integer milliseconds from 0 through 2147483647; other values throw VIBE_CONFIG. The next query after a quiet spell opens a new connection (TCP, startup, authentication — about 2.4 ms locally, more over TLS); a longer value, or 0 (never close), keeps connections open at the cost of held server connections. Ignored when pool is given | | cancellationConnectionString | string | Owned connectionString; none for an injected pool | Separate control endpoint for running read cancellation; same PostgreSQL server/database, with permission to signal the target role | | preparedStatements | boolean | false | Send ORM-path queries as named prepared statements. Measured −17…−25 % on reads and −6 % on a by-key update (see below). Off by default because of transaction-mode poolers — turn it on for direct connections, session pooling, or PgBouncer 1.21+ with max_prepared_statements > 0 | | stmtCacheMax | number | 1000 | Budget of prepared-statement names per pool. Names never evict (an evicted name stays prepared on the backend), so this bounds server-side retention per connection; a text arriving after the budget is spent runs unnamed | | planCacheMode | "auto" \| "force_custom_plan" \| "force_generic_plan" | "auto" | PostgreSQL plan_cache_mode for every connection of the pool the adapter builds; sent in the options startup parameter, merged with an options the URL already carries (a URL that sets plan_cache_mode itself wins). Refused with VIBE_CONFIG next to a caller-supplied pool |

Prepared statements: the measured trade

Interleaved rounds on this repository's benchmark database (postgres 16, apps/benchmark/scratch/probe-join-agg.ts, p50 of 7 rounds):

| shape | unnamed (default) | preparedStatements: true | + planCacheMode: "force_custom_plan" | |---|---|---|---| | findUnique × 10 | 1.28 ms | 1.01 ms (−21 %) | 1.06 ms (−17 %) | | findMany where + orderBy + take | 0.175 ms | 0.143 ms (−18 %) | 0.144 ms (−18 %) | | update by primary key | 0.82 ms | 0.77 ms (−6 %) | 0.76 ms (−8 %) | | findMany + include (take 50) | 0.52 ms | 0.42 ms (−20 %) | 0.39 ms (−25 %) |

The saving is the server's parse/analyze and the protocol Parse/Describe round on every repeat, not the plan — which is why nearly all of it survives force_custom_plan, the setting that removes the classic downside of prepared statements (a generic plan after ~5 executions picking worse indexes for range, LIKE or skewed filters).

A name is allocated on a text's second sighting, never its first: statement names never evict (an evicted name stays prepared on every backend that saw it), so first-sight allocation let a stream of one-off texts — offset pagination emits a distinct text per page — spend the whole stmtCacheMax budget for the pool's lifetime and leave the genuinely hot statements unnamed. A repeated statement therefore runs unprepared exactly once; a one-off statement costs the server nothing.

Statement names are collision-resistant. A name is vibeorm_ followed by the SHA-256 of the exact statement text in lowercase base32 (60 bytes, inside PostgreSQL's 63-byte identifier limit). The same text gets the same name in every pool, process and adapter version, and two different texts get the same name only if SHA-256 collides. So when a client reaches a backend that another pool prepared, a name can only stand for its own text: the outcome is the correct rows or an error, never another statement's rows (pinned in tests/prepared-backend-switch.live.test.ts). Before this change names were counters (vibeorm_0, vibeorm_1, …), and two independent pools could mean different texts by one name.

Why it stays opt-in: named statements live on the backend connection, and node-postgres sends only Bind/Execute for a name it has parsed before. Supported setups:

  • a direct connection;
  • session pooling (each client keeps one backend for the session);
  • PgBouncer 1.21 or later in transaction mode with max_prepared_statements above 0, which prepares each statement on the backend it links the client to.

Behind any other transaction-mode pooler (PgBouncer before 1.21 or with max_prepared_statements = 0, Supavisor, Neon's pooler) a client that remembers a name from an earlier transaction can be handed a different backend. The query then fails with SQLSTATE 26000 (the backend does not know the name) or 42P05 (the backend already holds the name on Parse). Both are VIBE_ADAPTER errors whose message ends with a hint about these setups, and the adapter discards that connection; no statement is retried. Turn preparedStatements off there.

One case stays silent with every naming scheme: a statement prepared before DDL shadows a table. If a migration creates a table with the same name in a schema that comes earlier in the unchanged search_path, the prepared statement keeps reading the table it was parsed against until the connection is recycled. (A changed search_path itself is safe: PostgreSQL re-parses the statement.) Recycle connections after such a migration, as for the result-shape changes below.

Schema changes with prepared statements

Recycle backend connections after a migration changes a prepared query's result shape. PostgreSQL can reject the retained plan with SQLSTATE 0A000 (cached plan must not change result type), for example after changing the type of a selected column. SELECT * can also be affected by adding a column. planCacheMode: "force_custom_plan" does not prevent result-descriptor invalidation.

The adapter surfaces the failure as VIBE_ADAPTER with meta.sqlstate: "0A000" and the driver error as cause. It does not retry the statement, replay a transaction callback, or issue DEALLOCATE. Inside a transaction, the error leaves PostgreSQL in its aborted state until rollback. SQLSTATE 0A000 also covers other unsupported features, so it is not a safe generic retry signal.

For an incompatible schema deployment, stop admitting work on the old pool, let active transactions settle, apply the migration, then recycle the connections before resuming traffic. If the adapter owns its pool, await disconnect() after draining and create a new adapter (a disconnected adapter does not reconnect). If you supplied pool, its owner must await pool.end() and supply a new pool to a new adapter. Disconnecting or replacing only the adapter leaves an injected pool's retained statements intact. Never run DEALLOCATE ALL behind node-postgres' remembered statement-name registry.

The timings above are historical measurements, not a schema-recovery or current performance guarantee. Prepared statements remain opt-in; applications without a coordinated connection lifecycle should keep the default preparedStatements: false.

Runtime support

| Runtime | Supported | |---|---| | Bun ≥ 1.2 | yes | | Node (with pg installed) | yes |

pg is a pure-JavaScript driver, so nothing native has to build. Peer range: pg >= 8.

Capabilities

This is a postgres-dialect adapter, so the full capability set is available: RETURNING, scalar array columns, native enums, ILIKE, DISTINCT ON, partial indexes, transactional DDL, createManyAndReturn, a real JSON column type, native NULLS FIRST/NULLS LAST, and the "join" relation strategy (LEFT JOIN LATERAL plus JSON aggregation). The generated capability table lives in docs/dialects.md.

Transactions run on one checked-out client; nested transaction() calls become savepoints on that same client. Statements issued at the same time on one transaction (Promise.all on a transaction handle, a statement the callback did not await) wait in the adapter's own queue and reach node-postgres one at a time, in the order they were issued: the adapter never calls client.query() while that client still runs a query (node-postgres' own queue for such calls is deprecated and goes away in pg@9). The top-level transaction handle of a transaction without a deadline or a timeout, on a client without a local query_timeout, and the savepoint handles it opens (a nested tx.$transaction([...])), declare orderedPipeline: the array form of $transaction then sends a count-only write (createMany, updateMany, deleteMany, upsertMany as one statement) and the next operation's first statement in one round trip — [deleteMany, createMany, updateMany] costs 2 round trips instead of 4 — and still sends COMMIT alone. Its lanes keep lane order, never send a statement behind a lane that closed without issuing one, and never send anything behind a failed statement, also when that failure had no server answer (no SQLSTATE 25P02 ever reaches a caller); see DatabaseAdapter.pipeline and Transactions for what a caller can observe. Inside a transaction, raw SQL without bound values that is provably ONE statement (no ;, no $, no comment, not transaction control and not COPY) travels with a pending BEGIN or SAVEPOINT like a bound statement, so a framework's SELECT set_config('lock_timeout', '5000', true) no longer costs a round trip of its own; raw SQL that may hold several statements keeps its own round trip. Raw transaction control (BEGIN / COMMIT / ROLLBACK sent through the raw path, as @vibeorm/migrate does for transactional DDL) pins one client for the duration, so a raw transaction never splits across connections. Concurrent raw transactions on a single adapter are unsupported.

A raw script that produces multiple PostgreSQL results returns only the final command's rows and affected-row count. Earlier results are not concatenated or summed; a final DDL command returns empty rows and zero affected rows.

Parameter serialization: two paths

pg turns a JS array into a PostgreSQL array literal natively, which is what a raw query wants but not what the ORM path can assume. The adapter therefore keeps two helpers:

  • serializeParams (ORM path): plain objects and plain arrays are JSON-stringified, since at that point they can only be json/jsonb values. Scalar-list values are tagged by formatArrayParam with PgArrayParam and unwrapped, reaching the driver as the untouched array.
  • serializeParamsRaw (raw path, $queryRaw* / $executeRaw* / DDL): plain JS arrays pass straight through, so $queryRawUnsafe('… WHERE "id" = ANY($1)', [1, 2, 3]) matches rows. Binding an array into a jsonb column from a raw query means calling JSON.stringify yourself.

A string bound to a ::jsonb slot on the raw path is sent as text and parsed by the server, so pre-encoded JSON text stores the document here, and on @vibeorm/adapter-bun with its default unnamed statements. With adapter-bun's preparedStatements: true the same statement stores a jsonb string, because bun:sql then JSON-encodes whatever is bound to a jsonb slot. Spell the slot ::text::jsonb when a raw statement must run on both drivers; that is what the ORM path renders for Json fields (pinned live in tests/raw-jsonb.live.test.ts on both adapters).

On both paths a Date is spelled as UTC ISO text (2026-03-08T19:38:08.538Z) rather than node-postgres' host-local form, and the result side attaches per-query parsers (never pg.types globally) that read timestamp without time zone, date and their arrays as UTC — pg-types reads them as host-local time. Together they give zone-less columns (Prisma's @db.Timestamp(n) / @db.Date) the UTC wall-clock contract shared with bun:sql, PGlite and Prisma, whatever the process TZ is; timestamptz keeps pg-types' instants. The same per-query parsers decode the plain wire form of timestamptz, timestamp and date (YYYY-MM-DD HH:MM:SS[.ffffff][±HH[:MM]], years 1000–9999) by character codes, about 3× cheaper per cell, and hand every other text (infinity, BC, earlier years, seconds offsets) to the parser that decoded it before; an application that registered its own global timestamptz parser through pg.types keeps it. See docs/dialect-notes.md "DateTime without time zone on postgres".

Caller cancellation of reads

Read execution options are separate from query arguments: signal?: AbortSignal and cancellation?: "before-dispatch" | "running". A signal defaults to "before-dispatch". The adapter accepts them only on execute with effect: "read"; raw SQL, writes and unknown effects are outside this contract. A caller cancellation raises VIBE_CANCELLED with meta.reason: "caller-aborted", a stage and databaseCancelled when its contract is fulfilled. Running requests can instead fail with the control-path refusal described below. Unrelated driver failures keep their mapped codes.

Running support is qualified in this source tree for the configured PostgreSQL scope below. Guarded live witnesses cover running sleep and lock-wait interruption, transaction arrival ordering, swallowed and nested abort cleanup, and response-wins reuse without signalling the next borrower. These witnesses do not claim publication of this feature or a performance improvement. An adapter-owned URL provides its control address. An injected pool requires an explicit address:

const owned = pgAdapter({ connectionString });
const shared = pgAdapter({
  pool,
  cancellationConnectionString: connectionString,
});

Without that explicit configuration, an injected pool declares readCancellation: "before-dispatch" and refuses "running" with VIBE_UNSUPPORTED_CAPABILITY before checkout or SQL. Its credentials are never inferred from pool internals or environment. The adapter creates and closes its own control connection without ending the injected pool. Give complete URLs. Use a direct or session-pinned control endpoint reaching the same PostgreSQL server/database, with the same login role or permission to signal the target role. A wrong endpoint that cannot match the exact target refuses; it does not cancel a backend with a coincidentally matching process ID.

The control URL removes local query_timeout, replaces any existing options with startup statement_timeout and lock_timeout settings, and adds a five-second connect_timeout and statement_timeout. The endpoint must accept these startup parameters. A transaction-mode pooler that refuses them fails the strong read before its user SQL is dispatched, even though the configured adapter declares readCancellation: "running". If the main URL goes through such a pooler, give an explicit suitable cancellationConnectionString; the main connection remains transaction-pinned.

A strong root read runs in a READ ONLY transaction. This pins the backend through transaction-mode poolers, but it also changes what PostgreSQL permits: locking reads such as SELECT FOR UPDATE, or functions that write, can be rejected even if the default path would run them. Reads inside an existing transaction use that transaction's session and access mode. Running mode is refused on a pipelined target or one configured with local query_timeout, because its promise/cleanup lifetime cannot prove SQL settlement. Ordinary reads without execution options keep their existing query path.

The cancellation path uses the official pg.Client query API and PostgreSQL's documented pg_cancel_backend, never deprecated Client.cancel. A separate control connection avoids waiting behind an exhausted main pool. Each strong statement opens one extra control connection, including statements that are never aborted, and closes it after settlement. Five-second time bounds apply to control acquisition/query and post-abort settlement; they are not a concurrency cap. Concurrent strong reads can therefore add one connection each beyond the main pool's max.

A random transaction-local session tag, backend start time, database and role bind the target. The target remains held until the original read and cancellation control settle. Any attempt discards that session and prevents further user SQL or commit in its transaction, even if a callback catches the cancellation. A failed control operation or an unsettled read destroys only the held target; cleanup releases it once.

databaseCancelled: true requires the original server failure 57014 plus this read's own successful control signal. A control boolean alone does not prove that SQL stopped. Forced connection closure does not set that marker.

An abort can race statement start or end: the exact target may be visible but not yet active, or the server may have finished before the client receives its response. The active-target guard cannot distinguish those races from a mismatched control endpoint. If it cannot signal the target, the adapter deliberately reports VIBE_UNSUPPORTED_CAPABILITY with meta.reason: "target-not-signalled" and databaseCancelled: false, discards the exact held session, and does not claim cancellation. The requested stronger guarantee was not fulfilled. Consumers must not treat this refusal as proof that the server statement was cancelled.

Strong root reads still execute transaction lifecycle SQL and a target-witness query; an abort adds cancellation control SQL. Individual graph receipts exclude BEGIN, COMMIT, ROLLBACK, SAVEPOINT (including rollback to a savepoint) and RELEASE on both automatic native-RLS and strong PostgreSQL root paths. Interactive and array transaction lifecycle is also outside the individual graph receipt. The internal onReadControl hook continues to account for the real target-witness, cancellation and read-setup/control statements, including failed dispatched controls with zero returned rows. rows counts physical result records, not affected or scanned rows; control rows do not count as materialized graph records.

Error mapping

Every driver failure becomes a VibeError with the driver error as cause and meta carrying sqlstate plus constraint / table / column when the server reported them. Table (PG_SQLSTATE_ERROR_CODES, frozen and mirrored by tests):

| SQLSTATE | Condition | VibeError code | meta.reason | |---|---|---|---| | 23505 | unique_violation | VIBE_UNIQUE_VIOLATION | | | 23503 | foreign_key_violation | VIBE_FK_VIOLATION | | | 23502 | not_null_violation | VIBE_VALIDATION | | | 23514 | check_violation | VIBE_VALIDATION | | | 22P02 | invalid_text_representation | VIBE_VALIDATION | | | 40001 | serialization_failure | VIBE_TRANSACTION | | | 40P01 | deadlock_detected | VIBE_TRANSACTION | | | 57014 | query_canceled (statement_timeout) | VIBE_TRANSACTION | "timeout" | | anything else, including client-side failures such as ECONNREFUSED | | VIBE_ADAPTER | |

A VibeError raised deeper inside the ORM passes through unchanged.

Links