postgres.js — 写 SQL 但语法层就防注入的 Node 客户端
已复核postgres.js(npm 包名 postgres)是一个以 tagged template 为唯一查询入口的 PostgreSQL 客户端。日常类比:像把乘客和货物分到两条轨道上的火车站——SQL 文本走货运轨,${value} 永远只当参数,不会被拼进语句。
你写:
const rows = await sql`select * from users where id = ${id}`;JS 引擎把 id 单独交给 tag 函数;固定 3.4.9 再把它编成 $n 占位符,走 Parse/Bind/Execute,而不是字符串拼接。零运行时依赖,条件导出覆盖 Node CJS、ESM、Bun 与 Cloudflare workerd。
不理解 postgres.js,下面这些事都没法解释:
- 为什么
sql`...${x}`、sql('users')和sql({name})是三种完全不同的对象 - 为什么默认把
undefined当成错误,而不是悄悄绑成NULL - 为什么事务回调里的
sql必须钉在同一条连接上 - 为什么 LISTEN 和逻辑复制各自再开一个
max: 1的专用实例
主链可以拆成五步:
-
工厂返回
sql:Postgres()按max预建连接(默认 10,Cloudflare 为 3),并用connecting / reserved / closed / ended / open / busy / full七个队列描述状态;唯一迁移点是move(c, queue)。 -
三种调用形态:带
strings.raw的反引号生成Query;单字符串生成Identifier;对象或数组生成Builder。后两种继承NotTagged,await会抛NOT_TAGGED_CALL。 -
惰性执行:
Query是 Promise 子类,只在then/catch/finally/execute/forEach时进入连接池。handleValue()遇到undefined且未配置transform.undefined就抛UNDEFINED_VALUE。 -
协议与 pipeline:默认
prepare: true、max_pipeline: 100。一条连接可在未回包时继续塞查询;写缓冲到 1024 字节或显式 flush 才socket.write。 -
会话型旁路:
listen()另建max: 1且禁用 idle/lifetime 的实例;subscribe()再开复制连接,执行CREATE_REPLICATION_SLOT ... TEMPORARY LOGICAL pgoutput。
案例 1:工厂与 tagged 查询
Section titled “案例 1:工厂与 tagged 查询”import postgres from "postgres";
const sql = postgres("postgres://user:pw@localhost:5432/db");const id = 1;const rows = await sql`select ${id} as one, now() as ts`;await sql.end();postgres(url) 返回的 sql 既是 tag 也是连接池入口。${id} 进入参数数组;sql.end() 会一并结束 listen/subscribe 专用实例。
案例 2:事务与 savepoint
Section titled “案例 2:事务与 savepoint”await sql.begin(async (sql) => { await sql`insert into orders (uid, amount) values (${uid}, ${amt})`; await sql.savepoint(async (sql) => { await sql`update users set balance = balance - ${amt} where id = ${uid}`; });});外层 begin 用 unsafe('begin ...') 占住一条 reserved 连接;回调里的 sql 只暴露 savepoint / prepare,不再带池级 begin。savepoint() 发的是 SAVEPOINT,失败则 ROLLBACK TO。回调返回查询数组时,这些查询会在同一连接上 pipeline。
案例 3:LISTEN / NOTIFY
Section titled “案例 3:LISTEN / NOTIFY”await sql.listen("order_created", (payload) => { console.log("new order:", payload);});await sql.notify("order_created", JSON.stringify({ id: 42 }));listen 对频道名做 identifier 转义后发 LISTEN;断线会按已登记频道重听。notify 走普通池连接执行 select pg_notify($1, $2)。
-
普通函数调用不是查询:
sql('select 1')得到 Identifier。要跑原始字符串必须sql.unsafe(...)。 -
undefined默认拒绝:和pg把undefined转null不同。要绑null必须显式transform: { undefined: null }。 -
bigint/numeric默认是字符串:内置 number parser 只覆盖 oid 21/23/26/700/701。count(*)这类bigint要数值需注册postgres.BigInt;numeric没有内置高精度类型。 -
事务回调里的
sql没有池级begin:嵌套回滚靠savepoint()。再调用外层池的begin()会另占一条连接,不是子事务。 -
默认复制槽是 TEMPORARY:
subscribe()断线重连后从新槽继续,窗口内的变更会丢。生产 CDC 需要自己管理持久 slot 与 LSN。
适用 vs 不适用场景
Section titled “适用 vs 不适用场景”适用:
- 想直接写 SQL、又要把参数和文本在语法层分开的 Node / Bun / Workers 服务
- 中小项目用 LISTEN/NOTIFY 当进程内消息,而不是再挂一套 broker
- 一次性脚本、迁移和报表:工厂即连接池,不用手动 checkout
不适用:
- 需要从 schema 推导返回类型 → 看 drizzle 或 kysely
- 已经深度绑在
pg-pool/pg-cursor工具链上的旧系统 - 不能丢事件的 CDC → 默认临时 slot 不够,要自己管复制进度
- 把作者 README 的“最快”当合同 → 本文没有跑对比 benchmark
固定版本边界
Section titled “固定版本边界”- 本文绑定
porsager/postgres@e7dfa145...,npmpostgres@3.4.9的gitHead与该提交一致。 - 条件导出:
bun/import→src/index.js;workerd→cf/src/index.js;默认 CJS →cjs/src/index.js。engines.node为>=12。 - 默认
max=10(Cloudflare 3)、max_pipeline=100、prepare=true、connect_timeout=30、keep_alive=60、fetch_types=true。 - 许可为 Unlicense。本文未连接数据库、未跑上游测试,状态保持
UNVERIFIED。
- 语法边界比文档警告稳——tag 函数让参数无法回到 SQL 字符串通道。
- 一个
sql名字不够——Query / Identifier / Builder 的分派是新手最容易踩的合同。 - 连接池是状态机——pipeline、reserve 和事务都靠把连接在队列之间搬动。
- 旁路协议另开连接——LISTEN 与逻辑复制不能和普通查询共享同一条忙连接。
await sql('select 1')会发出查询吗?await sql`insert into t values (${undefined})`在默认transform下会怎样?- 事务回调里再调用池对象的
sql.begin(),会得到嵌套 SAVEPOINT 吗?
检查点:
- 不会。单字符串走 Identifier,
await抛NOT_TAGGED_CALL。 - 抛
UNDEFINED_VALUE;只有显式设置transform.undefined才会替换。 - 不会。回调里的
sql只提供savepoint();池级begin()会另占连接。
- 仓库 README:porsager/postgres
- 固定源码:porsager/postgres —— 本文绑定提交
e7dfa14519f363229ccc3ead7b1b2f2051937efb - 协议参考:PostgreSQL Frontend/Backend Protocol
- postgresql —— 服务端协议与类型系统
- drizzle —— 常用它当 SQL-first ORM 的底层 driver
- postgresql —— 客户端的全部协议假设都来自 PG
- prisma —— 重 schema / migration 的另一条答案
- drizzle —— SQL-first ORM,底层常接 postgres.js
- kysely —— type-safe query builder
- redis —— LISTEN/NOTIFY 在轻量 pub/sub 上的常见对照
- bun —— 条件导出矩阵中的一环
- io-uring —— io_uring — Linux 让 N 次 IO 摊销到 1 次 syscall
- cockroach —— CockroachDB — 全球分布式 SQL
- drizzle —— Drizzle ORM — 轻量 SQL-like ORM
- pg-boss-readme —— pg-boss — 只用 Postgres 就能跑的任务队列
- supabase —— Supabase — Firebase 的开源替代