Skip to content

Repository files navigation

@perryts/postgres

A pure-TypeScript Postgres driver that speaks the wire protocol directly — no libpq, no native addons, no runtime FFI. It runs unchanged on Node.js, Bun, and Perry, where the same TypeScript source is ahead-of-time compiled to a native binary via LLVM — giving you a real standalone executable that talks to Postgres with no JS runtime attached.

Perry is a TypeScript-to-native compiler: it lowers a strict subset of TS through LLVM into a statically-linked binary. @perryts/postgres is the reference driver used by Tusk (the Perry-native Postgres GUI), and the showcase for Perry's systems capabilities — every socket read, TLS handshake, and crypto op goes through perry-stdlib rather than a Rust or C shim.

bun add @perryts/postgres
npm install @perryts/postgres
pnpm add @perryts/postgres
import{connect,sql}from'@perryts/postgres';constconn=awaitconnect('postgres://alice:secret@db.example.com:5432/myapp');const{ rows }=awaitconn.query<{id: number;name: string}>(sql`SELECT id, name FROM users WHERE active = ${true}`);for(constuserofrows)console.log(user.id,user.name);awaitconn.close();

Why another Postgres driver?

Most Node drivers wrap libpq or ship a platform-specific .node addon. That's a non-starter for two use cases we care about:

  1. Compiling to a native binary with Perry. Perry produces a statically-linked executable via LLVM; there is no Node runtime at execution time, so any driver that assumes V8 / N-API / require('pg-native') is unusable. @perryts/postgres uses only APIs that exist on both Perry's stdlib and Node core (Buffer, net.Socket, crypto.*, tls.connect), so the same TypeScript source runs on a JS runtime and compiles to a native binary.

  2. Driving a GUI (Tusk). ORMs lose column metadata and coerce types for ergonomics. A database client has to render numeric(30,8) exactly, surface attnum / tableOid so users can edit-in-place, and expose NOTICE, ParameterStatus, backend PIDs, and structured ErrorResponse fields. This driver returns raw rows plus full column descriptors, and never silently coerces.

Features

  • Wire protocol v3 — Postgres 13 through 17 in CI; the protocol itself has been stable since 7.4.
  • Authentication — SCRAM-SHA-256, MD5, cleartext, trust.
  • TLSsslmode=disable | require | verify-ca | verify-full, mid-stream upgrade handled transparently on both Perry and Node.
  • Simple and extended queryQuery, Parse / Bind / Execute / Sync, portals, Describe.
  • 20 type codecs — integers, floats, numeric (precision-preserving Decimal), booleans, text family, bytea, uuid, json, jsonb, the full date/time family with microsecond precision, and 1-d arrays of any of the above — both text and binary formats.
  • Structured PgError — every documented ErrorResponse field: SQLSTATE, position, detail, hint, schema/table/column/constraint.
  • Cancel protocol — fresh socket + PID/secret handshake; the connection remains reusable afterwards.
  • EventsNOTICE, ParameterStatus, LISTEN / NOTIFY.
  • Connection pool, transactions, and a sql tagged-template helper.
  • libpq URLs and PG* env varsDATABASE_URL, PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE, PGSSLMODE, PGAPPNAME, PGCONNECT_TIMEOUT all work out of the box.
  • Zero native dependencies. Pure TypeScript over Buffer, node:net, node:tls, and node:crypto. Nothing to rebuild per platform.
  • No lossy numeric coercion. Values land in a Decimal wrapper that round-trips exact string form — none of the 9999999999.99 → 9999999999.989999 drift common to float-backed drivers.

Runtime targets

RuntimeStatusNotes
Perry (AOT → native) ≥ 0.5.24supportedSame source, compiled via LLVM. TLS upgrade uses socket.upgradeToTLS from perry-stdlib. No JS runtime at execution time. Every built-in codec round-trips correctly (verified end-to-end against real Postgres via examples/perry-smoke-codecs.ts).
Node.js ≥ 22supportedUses node:net, node:tls, node:crypto, Buffer.
Bun ≥ 1.3supportedFully works except a known Bun bug in tls.connect({socket}) for in-place upgrade; the corresponding tests run on Node via npm run test:tls:node.

The only API divergence across these runtimes is the mid-stream TLS upgrade. It's isolated in src/transport/upgrade-tls.ts — a ~15-line feature-detect. Nothing else in the driver knows or cares which runtime it's on.

Quickstart

Connecting

import{connect}from'@perryts/postgres';// 1. libpq-format URL.constconn=awaitconnect('postgres://user:pw@host:5432/db?sslmode=verify-full');// 2. Explicit options.constconn=awaitconnect({host: 'localhost',port: 5432,user: 'alice',database: 'myapp',password: 'secret',ssl: {mode: 'verify-full'},applicationName: 'tusk',connectTimeoutMs: 10_000,});// 3. URL with targeted overrides.constconn=awaitconnect({url: process.env.DATABASE_URL!,password: process.env.DB_PASSWORD,// overrides the URL's password});// 4. Bare options + env vars.// PGHOST / PGPORT / PGUSER / PGPASSWORD / PGDATABASE /// PGSSLMODE / PGAPPNAME / PGCONNECT_TIMEOUT fill in missing fields.constconn=awaitconnect({user: 'alice'});

Queries

// Simple query — any DDL / SET / multi-statement text.awaitconn.query('CREATE TEMP TABLE t(id int, name text)');// Parameterized — extended protocol.constr=awaitconn.query<{id: number;name: string|null}>('SELECT id, name FROM users WHERE id = $1',[42]);// Tagged template — the typical app pattern.import{sql}from'@perryts/postgres';constid=42;constr=awaitconn.query(sql` SELECT id, name FROM users WHERE id = ${id}`);// Composable fragments.constwhere=active ? sql`WHERE active` : sql``;constr=awaitconn.query(sql`SELECT * FROM users ${where} LIMIT ${10}`);// Dynamic identifiers. ONLY for caller-controlled values — never user input.import{raw}from'@perryts/postgres';awaitconn.query(sql`SELECT * FROM ${raw(tableName)} WHERE id = ${id}`);

Result shape

constr=awaitconn.query('SELECT id, name FROM users');r.rows;// [{ id: 1, name: 'alice' }, ...] ← decoded objectsr.rowsArray;// [[1, 'alice'], ...] ← decoded positionalr.rowsRaw;// [[<Buffer>, <Buffer>], ...] ← raw wire bytes (GUI-grade)r.fields;// [{ name, typeOid, formatCode, tableOid, attnum, typmod, ... }]r.command;// 'SELECT 2'r.rowCount;// 2

r.rows is the obvious shape for application code. r.rowsRaw is what Tusk's grid uses when it wants to render bytes byte-for-byte without a round-trip through Buffer.

Type fidelity

Postgres typeTypeScript value
int2, int4number
int8bigint — always. int8 exceeds Number.MAX_SAFE_INTEGER.
float4, float8numberNaN, Infinity, -Infinity all round-trip.
numericDecimal — string-backed; .toString(), .toNumber(). Exact.
boolboolean
text, varchar, bpchar, namestring
byteaBuffer — both hex and legacy octal text formats decoded.
uuidcanonical lowercase string with dashes
json, jsonbparsed JS value
date, time, timetz, timestamp, timestamptz, intervaltyped objects with .toString(), .toDate(), microsecond fields
1-d arrays of any of the aboveArray<T> with null for SQL NULLs

Perry-native caveat: wrapper types (Decimal, PgDate, PgTime, PgTimestamp, PgInterval) decode correctly but String(value) / value.toString() currently doesn't dispatch to the wrapper's overridden method on Perry. Read the underlying field directly (v.value for PgDate, v.raw on PgTime / PgTimestamp / PgInterval, v._s for Decimal) — that's what examples/perry-smoke-codecs.ts does. On Node and Bun, toString() works as expected.

import{Decimal}from'@perryts/postgres';constr=awaitconn.query('SELECT $1::numeric',['99999999999999.99']);r.rows[0]['?column?']instanceofDecimal;// trueString(r.rows[0]['?column?']);// '99999999999999.99' — exact

Transactions

constorderId=awaitconn.transaction(async(tx)=>{awaittx.query(sql`INSERT INTO orders (user_id) VALUES (${userId})`);constr=awaittx.query(sql`SELECT currval('orders_id_seq') AS id`);returnr.rows[0].id;});// COMMIT on resolve, ROLLBACK on throw.

Connection pool

import{createPool}from'@perryts/postgres';constpool=createPool({url: process.env.DATABASE_URL,max: 20,// default 10idleTimeoutMs: 30_000,// close idle connections after 30sacquireTimeoutMs: 30_000,// wait at most 30s for a slot when full});// Acquire + query + release in one call.constr=awaitpool.query(sql`SELECT now()`);// Multi-statement — borrow the connection yourself.awaitpool.withConnection(async(conn)=>{awaitconn.query('SET search_path TO app');returnconn.query('SELECT * FROM widgets');});// Pooled transaction.awaitpool.transaction(async(tx)=>{awaittx.query(sql`UPDATE accounts SET balance = balance - ${amt} WHERE id = ${from}`);awaittx.query(sql`UPDATE accounts SET balance = balance + ${amt} WHERE id = ${to}`);});pool.size();// { total, idle, waiting }awaitpool.end();

Cancelling a long query

constlong=conn.query('SELECT pg_sleep(60)');setTimeout(()=>conn.cancel(),1000);try{awaitlong;}catch(e){// PgError with code '57014' (query_canceled).console.error(e.code,e.message);}// `conn` is reusable — ReadyForQuery restored.

Errors

import{PgError}from'@perryts/postgres';try{awaitconn.query('SELECT * FROM nope');}catch(e){if(einstanceofPgError){e.code;// '42P01'e.severity;// 'ERROR'e.message;// 'relation "nope" does not exist'e.position;// '15'e.hint;// ...e.detail;// ...e.schema;// table / column / constraint fields, etc.}}

Notices and notifications

conn.on('notice',(n)=>console.warn(n.severity,n.message));conn.on('parameter',(key,value)=>console.log('PG set',key,'=',value));awaitconn.query('LISTEN job_done');conn.on('notification',(n)=>{console.log('NOTIFY received on',n.channel,':',n.payload);});

Custom type codecs

import{registerType}from'@perryts/postgres';registerType<{x: number;y: number}>(POINT_OID,{oid: POINT_OID,name: 'point',text: {decode(buf){const[x,y]=buf.toString().slice(1,-1).split(',').map(Number);return{x: x,y: y};},encode(v){returnBuffer.from(`(${v.x},${v.y})`);},},});

Architecture

src/
├── protocol/ wire framing + message writer/reader
├── auth/ SCRAM-SHA-256, MD5, cleartext
├── transport/ net.Socket wrapper + Perry-vs-Node TLS upgrade
├── types/ OID → codec registry, 20 built-in codecs
├── error.ts structured PgError
├── notice.ts NoticeResponse parsing
├── cancel.ts fresh-socket CancelRequest
├── url.ts libpq connection-string parser
├── env.ts PG* environment-variable resolver
├── sql.ts `sql` tagged template + `raw()` escape hatch
├── pool.ts Connection pool
├── register-defaults.ts registers every built-in codec; called once from connect()
├── connection.ts Connection: lifecycle, simple + extended query
└── index.ts public barrel exports

Perry AOT constraints

Every source file respects the subset Perry's compiler can lower to LLVM:

  • No ?. or ?? — use explicit if (x === undefined) branching.
  • No obj[variable] dynamic key access.
  • No for...of over arrays — use for (let i = 0; i < arr.length; i++).
  • No regex — indexOf / char-code checks.
  • No { key } shorthand — write { key: key }.
  • No capturing this.method in closures — module-level Map<id, State> holds connection state, with named module-level handlers.

These read like quirks in a JS runtime, but they are what makes the same source compile to a single-binary native executable on Perry.

Related projects

  • PerryTS/perry — the TypeScript-to-native compiler (LLVM backend) and runtime / stdlib.
  • TuskQuery — Tusk, the Perry-native Postgres GUI that consumes this driver.

Performance

Short version: V8 and JSC beat Perry at per-query wall time on a long-running process. Perry wins everywhere else — cold start, memory footprint, deploy size, and the set of platforms you can ship to. This driver's job is to make that trade available; the numbers below are what it looks like.

Hot-path throughput (what most benchmarks measure)

bench/run-all.sh against a local Postgres 16, 50 timed iterations + 5 warmups per workload, p50 wall time. Lower is better:

workload@perryts/postgres node@perryts/postgres bun@perryts/postgres perry-native¹pg (node)pg-native (node)²postgres.js (node)tokio-postgres (rust)
SELECT 182µs87µs2.5 ms82µs77µs85µs80µs
param 1-row138µs63µs2.0 ms132µs77µs137µs80µs
1000 × 203.6 ms3.4 ms11.0 ms2.5 ms4.1 ms3.0 ms2.8 ms
10000 × 2036.0 ms33.3 ms100 ms22.1 ms38.0 ms28.7 ms26.5 ms

Perry-native is ~2.8× Node on bulk decode and ~30× on tiny queries. That's the worst axis for AOT: V8 has had 20 years of trace-JIT engineering specifically to optimise these tight loops, and Perry's arena GC can't match a generational collector for short-lived row objects. If your process runs a while (true) { query; } loop forever, run Node or Bun.

Where Perry actually wins: cold start, memory, disk

The thing you gain by AOT-compiling isn't a faster hot path — it's a process that's ready before V8 is still loading, uses a fraction of the memory, and ships as one file:

Node (+tsx)BunPerry-native
Cold start of a console.log('hi') script150 ms10 ms<10 ms (first run), ~1 ms after
Peak RSS for the same program37 MB7.8 MB1.8 MB
Cold start of the driver's conn.query('SELECT 1')160–240 ms20–30 ms(blocked on flakiness, see note¹)
Driver binary / artifactnode runtime + node_modulesbun runtime + node_modulessingle 4.6 MB static binary
node_modules size to run the driver~40 MB~40 MB0
Deploys to iOS / Android / embedded

For a long-running server — a web app, a queue worker — Node/Bun's JIT wins the per-query game and amortises the startup cost. For a CLI tool, a short cron job, a serverless function that spins up and dies, a container that needs to boot fast, or any mobile / embedded deployment — Perry wins on the things the environment actually measures you on.

That's why this driver is pure TypeScript: the same source compiles three ways. You pick the target that matches what you're shipping.

Where the Perry-native driver fits

ScenarioBest choiceWhy
Long-running web server with warm V8@perryts/postgres on Node / Bun, or pg if you want the absolute floorJIT beats AOT on tight per-query loops
CLI / one-shot tool that opens a connection, runs N queries, exits@perryts/postgres on Perry-native5–20× faster startup, ~50 MB less RSS, single binary to distribute
Serverless function (cold start is the whole game)@perryts/postgres on Perry-native, OR Bun if you can ship a Bun-ABI targetBun is 10ms-class on cold start too; Perry wins on RSS
iOS / Android / watchOS app that needs local Postgres@perryts/postgres on Perry-nativeNode/Bun don't run there at all
Embedded / resource-constrained Linux@perryts/postgres on Perry-nativesingle static binary, no runtime deps
ETL / analytics pipeline that processes millions of rowspg on Node, with parseTypes: 'minimal' on this driver if you want the same APIpg's "strings by default" is the bulk-decode floor

¹ Perry-native runs via perry compile src/…/bench-this.ts. The 2–3 ms per-call floor is the AOT runtime's Promise / async / FFI overhead — Node and Bun amortise that into ~100µs per call via V8's / JSC's deopt-free hot paths. Bulk decode converged to within ~2.8× of Node after the v0.5.30 hidden-class IC work (standalone 10k×20 dynamic-key object build actually beats Node at 3.3 ms vs 8.5 ms; the Postgres-path gap is all protocol-frame decoding + per-cell wrapper allocation, not object-shape cost).

Requires Perry ≥ 0.5.87. Prior fix chain: #32 / #33 / #34 / #35 / #36 / #37 / #68 / #70 / #71 / #72 / #73.

² pg-native is the libpq N-API binding. Only runs on Node in practice: Perry-native can't load dynamically-linked C addons (this driver exists precisely because that's a dead end for AOT targets), and Bun requires a from-source rebuild against its own ABI v137 which isn't in the standard install flow.

Counter-intuitively, pg-native is ~1.8× slower than pg (pure JS) on bulk decode (37 ms vs 21 ms on 10k × 20). The N-API boundary-crossing per row + per-cell marshalling into V8 objects costs more than pg's stay-in-V8 text-format parser. C-speed inner loops don't help when every parsed value still has to become a JS heap object.

Perry-native journey

The Perry-native column changed dramatically across 0.5.29 → 0.5.87:

workload0.5.290.5.87change
SELECT 13.0 ms2.5 ms-17%
param 1-row3.0 ms2.0 ms-33%
1000 × 2042 ms11 ms-74%
10000 × 20764 ms100 ms-87%

The big wins came from:

  1. Hidden-class inline caches for obj[name] = value writes (v0.5.30 closed #37 which I filed during this work). The linear-scan property set was O(N²) per row; the IC transition-cache made it O(N). Alone closes most of the gap.
  2. Escape-analysis scalar replacement of non-escaping object literals (v0.5.76 #66) — 10k row objects that never escape their iteration frame stay on the stack.
  3. GC root-scanning fixes across v0.5.25–v0.5.28 that unblocked iteration past the first query at all.

Notes:

  • pg is fastest by a meaningful margin because it returns int8 / numeric / date / time / timestamp as raw strings unless the caller opts into a parser — no bigint, no Decimal, no PgDate per cell. Most of our remaining gap is the wrapper-allocation cost on those types. We chose to wrap by default because the GUI use case (Tusk) needs them typed; if you don't, those wrappers are pure overhead — pass parseTypes: 'minimal' to connect() to opt out and get the same string-as-default shape pg uses (~5% off our default on mixed workloads, ~20% off on result sets dominated by int8 / numeric / date columns):

    constconn=awaitconnect({host: 'db.example.com',user: 'app',database: 'myapp',parseTypes: 'minimal',// int8/numeric/date/time/timestamp/interval → string});
  • postgres.js sits between us and pg; same reason it's slower than pg (some parsing) and same reason it's faster than us (less parsing). We're within ~1.1–1.3× of it on bulk results — typically faster on Bun, slightly slower on Node.

  • tokio-postgres (Rust release build with rust_decimal) is in the same range as postgres.js on bulk results: 26.7 ms on 10000×20 vs our 34.9 ms (Node) / 32.5 ms (Bun). The bench reads every cell into an owned i64 / String / Decimal / bool so the lazy row.get<T> path doesn't make Rust look unfairly fast. On tiny queries Rust wins by a wide margin (35µs vs ~60µs) because there's no V8 JIT warm-up tax. Bulk decoding, where most consumers live, is dominated by the codec choices — language matters less than what you parse into.

  • Perry-native has a constant ~3 ms per query overhead vs ~100µs on the JS hosts — that's the AOT runtime's promise / async / FFI per-call cost rather than anything driver-level. Bulk decode is currently ~10–20× slower than the JS hosts on bulk results (42 ms vs 3.5 ms on 1000×20; 764 ms vs 36 ms on 10000×20) — every row builds a wrapper-heavy object chain in Perry's arena GC and there's real per-cell cost there the JS JITs optimise out. Correctness landed first across PerryTS/perry #32 / #33 / #34 / #35 / #36 (module-level globals weren't registered as GC roots, so CONN_STATES was getting swept mid-decode). Speed is landing in increments — 0.5.29 shipped a ~14% cut on the 10k-row workload by gating the shape-clone on a GC_FLAG_SHAPE_SHARED bit and moving the accessor-descriptor String allocation to a lazy path. Requires Perry ≥ 0.5.29.

Reproduce: see bench/README.md for the full setup (local Postgres, npm install, optional Rust toolchain) and per-runner invocations. Short version:

cd bench && npm install &&cd -
PGHOST=127.0.0.1 PGPORT=55432 PGUSER=$(whoami) PGDATABASE=bench \
bench/run-all.sh

Examples

Every file under examples/ is a standalone program that compiles under Perry AND runs under Node / Bun unchanged — point at any Postgres with the libpq environment variables and they work. See examples/README.md for the full list. Quick smokes if you just want to verify the driver works for you:

# Node / Bun:
PGHOST=… node --import tsx examples/perry-smoke.ts
PATH=~/.bun/bin:$PATH PGHOST=… bun examples/perry-smoke.ts
# Perry-native:
/path/to/perry compile examples/perry-smoke.ts -o /tmp/perry-smoke
PGHOST=… /tmp/perry-smoke

perry-smoke.ts covers the entire public API surface — connect, auth, simple + extended queries, type codecs, error handling, transactions — in ~100 lines. It's the canonical "does this driver work for me" smoke.

Testing

bun test# everything
bun test tests/unit # pure unit tests (no DB)
bun test tests/integration # docker-compose matrix
npm run test:tls:node # TLS suite on Node (Bun has a known tls.connect bug)

Status

v0.2.0 — pre-1.0. The public surface is stable but not frozen. The driver passes the in-process mock-server matrix end-to-end. A real-Postgres Docker matrix (13 / 14 / 15 / 16 / 17) is the next milestone.

License

MIT.

About

Pure-TypeScript Postgres wire-protocol driver. Zero native dependencies. Runs on Perry, Node.js, and Bun.

Topics

Resources

Stars

8 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages