@m2k-5f/pgtx 2.6.0 → 2.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +214 -480
- package/dist/batch.d.ts +3 -2
- package/dist/batch.d.ts.map +1 -1
- package/dist/batch.js +9 -9
- package/dist/clauses/abstract.clause.d.ts.map +1 -1
- package/dist/connection.d.ts +42 -175
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +205 -290
- package/dist/error.d.ts +5 -0
- package/dist/error.d.ts.map +1 -1
- package/dist/error.js +5 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/pool.d.ts +35 -192
- package/dist/pool.d.ts.map +1 -1
- package/dist/pool.js +70 -202
- package/dist/protocol/connection-response-reader.d.ts +3 -4
- package/dist/protocol/connection-response-reader.d.ts.map +1 -1
- package/dist/protocol/connection-response-reader.js +4 -2
- package/dist/protocol/socket-authorization.d.ts +1 -10
- package/dist/protocol/socket-authorization.d.ts.map +1 -1
- package/dist/protocol/socket-authorization.js +1 -4
- package/dist/protocol/socket-connector.d.ts +5 -5
- package/dist/protocol/socket-connector.d.ts.map +1 -1
- package/dist/protocol/socket-connector.js +24 -17
- package/dist/query.d.ts +17 -26
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +24 -27
- package/dist/transaction.d.ts +11 -4
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +24 -11
- package/dist/types.d.ts +59 -11
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,544 +1,278 @@
|
|
|
1
|
-
|
|
1
|
+
# Pgtx
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
[](https://github.com/M2K-5F/pgtx/actions/workflows/tests.yaml)
|
|
4
|
+
[](https://www.npmjs.com/package/@m2k-5f/pgtx)
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
A PostgreSQL driver for Node.js that pipelines everything by default, caches prepared statements without asking, and doesn't ship a single dependency.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
**Up to 25% faster than `Postgres.js` and 15.6× faster than `pg` in concurrent pipeline workloads.**
|
|
11
|
-
|
|
12
|
-
---
|
|
13
|
-
|
|
14
|
-
## 📦 Installation
|
|
15
|
-
|
|
16
|
-
```bash
|
|
17
|
-
npm install @m2k-5f/pgtx
|
|
18
|
-
# yarn add @m2k-5f/pgtx
|
|
19
|
-
# pnpm add @m2k-5f/pgtx
|
|
20
|
-
# bun add @m2k-5f/pgtx
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
---
|
|
24
|
-
|
|
25
|
-
## 🚀 Quick Start
|
|
26
|
-
|
|
27
|
-
```typescript
|
|
28
|
-
import { sql, Pool } from "@m2k-5f/pgtx";
|
|
29
|
-
|
|
30
|
-
const pool = new Pool({
|
|
31
|
-
host: 'localhost',
|
|
32
|
-
user: 'postgres',
|
|
33
|
-
password: 'postgres',
|
|
34
|
-
database: 'myapp'
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
// Type-safe query
|
|
38
|
-
const [user] = await pool.query<User>`SELECT * FROM users WHERE id = ${1}`
|
|
39
|
-
|
|
40
|
-
// Bulk insert
|
|
41
|
-
await pool.query`
|
|
42
|
-
INSERT INTO users ${sql.insert([{ name: 'Alice', age: 25 }, { name: 'Bob', age: 30 }])}
|
|
43
|
-
`
|
|
44
|
-
|
|
45
|
-
// Transaction
|
|
46
|
-
await pool.begin(async (tx) => {
|
|
47
|
-
await tx.query`UPDATE accounts SET balance = balance - 100 WHERE id = ${1}`
|
|
48
|
-
await tx.query`UPDATE accounts SET balance = balance + 100 WHERE id = ${2}`
|
|
49
|
-
})
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
---
|
|
53
|
-
|
|
54
|
-
## ✨ Features
|
|
55
|
-
|
|
56
|
-
- **Pipeline queries** — Automatic query multiplexing over PostgreSQL pipeline protocol
|
|
57
|
-
- **Tagged templates** — Natural SQL with type safety
|
|
58
|
-
- **Native Web Streams API** — Memory-efficient data streaming via `pool.stream()`
|
|
59
|
-
- **Transactions & Savepoints** — Nested transactions with rollback
|
|
60
|
-
- **Bulk inserts** — Auto-extract columns from objects
|
|
61
|
-
- **Dynamic updates** — Generate SET clauses from objects
|
|
62
|
-
- **Recursive fragments** — Compose SQL like Lego
|
|
63
|
-
- **Prepared statements** — Automatic prepared statement caching
|
|
64
|
-
- **Connection pool** — Auto-management connections with support for pipeline queries via the pool itself.
|
|
65
|
-
- **Zero dependencies** — Lightweight and blazing
|
|
66
|
-
|
|
67
|
-
---
|
|
68
|
-
|
|
69
|
-
## ⚡ Performance
|
|
70
|
-
|
|
71
|
-
All benchmarks are executed on **GitHub Actions** (Ubuntu, 2 vCPUs) and are fully reproducible. Benchmark sources are included in this repository.
|
|
72
|
-
|
|
73
|
-
### 1. PostgreSQL Pipeline Stress Test
|
|
74
|
-
|
|
75
|
-
**3000 concurrent parameterized `SELECT` queries**
|
|
76
|
-
|
|
77
|
-
* Connection pool: **10 connections**
|
|
78
|
-
* Measured with **mitata**
|
|
79
|
-
|
|
80
|
-
| Driver | Avg Time | Relative Speed | Memory (p75) |
|
|
81
|
-
| :--------------------- | -----------: | -------------------: | -----------: |
|
|
82
|
-
| **Pgtx (Pipeline)** | **24.18 ms** | **Baseline (1.00×)** | **≈2.5 MB** |
|
|
83
|
-
| Postgres.js (Pipeline) | 83.36 ms | **3.45× slower** | ≈7.6 MB |
|
|
84
|
-
| node-postgres (`pg`) | 377.95 ms | **15.63× slower** | ≈11.4 MB |
|
|
85
|
-
|
|
86
|
-
### 2. Real-World HTTP Throughput
|
|
87
|
-
|
|
88
|
-
Simple `node:http` server serving a PostgreSQL-backed endpoint.
|
|
89
|
-
|
|
90
|
-
Measured with:
|
|
8
|
+
It exists because `pg` leaves throughput on the table and `postgres.js`, while fast, still isn't fast enough once you actually saturate a connection pool. Pgtx is built around one idea: batch what can be batched, write to the socket once, and get out of the way.
|
|
91
9
|
|
|
92
10
|
```bash
|
|
93
|
-
|
|
11
|
+
npm install @m2k-5f/pgtx
|
|
94
12
|
```
|
|
95
13
|
|
|
96
|
-
|
|
97
|
-
| ---------------------: | ---------------: | -----------: | --------: |
|
|
98
|
-
| 50 | 5,272 req/s | 5,691 req/s | 0.93× |
|
|
99
|
-
| 200 | **12,918 req/s** | 6,724 req/s | **1.92×** |
|
|
100
|
-
| 1000 | **21,429 req/s** | 8,423 req/s | **2.54×** |
|
|
101
|
-
| 10000 | **22,486 req/s** | 12,764 req/s | **1.76×** |
|
|
102
|
-
|
|
103
|
-
### Why is Pgtx fast?
|
|
104
|
-
|
|
105
|
-
Pgtx is engineered for **throughput**, not for minimizing the latency of individual queries.
|
|
106
|
-
|
|
107
|
-
Instead of optimizing a single request in isolation, Pgtx minimizes per-query overhead under sustained concurrent load by combining:
|
|
108
|
-
|
|
109
|
-
* Pipeline query multiplexing
|
|
110
|
-
* Synchronous PostgreSQL wire protocol encoding
|
|
111
|
-
* Batched socket writes
|
|
112
|
-
* Automatic prepared statement caching
|
|
113
|
-
* Prepared statement deduplication
|
|
114
|
-
* Row description caching
|
|
115
|
-
* Binary protocol support
|
|
116
|
-
* Zero-dependency implementation
|
|
117
|
-
|
|
118
|
-
As concurrency increases, these optimizations significantly reduce protocol overhead, allowing Pgtx to scale more efficiently than traditional PostgreSQL drivers.
|
|
119
|
-
|
|
120
|
-
In the `mitata` benchmark, Pgtx also demonstrated approximately **3× lower memory usage** than Postgres.js while processing the same workload, reducing allocation pressure and improving sustained throughput under heavy load.
|
|
121
|
-
|
|
122
|
-
> **Blazing** isn't just a tagline — it's backed by reproducible benchmarks.
|
|
123
|
-
|
|
124
|
-
---
|
|
125
|
-
|
|
126
|
-
## 🔥 Why Pgtx?
|
|
127
|
-
|
|
128
|
-
| Capability | **Pgtx** | **Postgres.js** | **pg** |
|
|
129
|
-
| -------------------------------- | :------: | :-------------: | :----: |
|
|
130
|
-
| Pipeline queries | ✅ | ✅ | ❌ |
|
|
131
|
-
| Pipeline multiplexing in pool | ✅ | ❌ | ❌ |
|
|
132
|
-
| Tagged template SQL | ✅ | ✅ | ❌ |
|
|
133
|
-
| Automatic prepared statements | ✅ | ✅ | ✅ |
|
|
134
|
-
| Prepared statement deduplication | ✅ | ❌ | ❌ |
|
|
135
|
-
| Transactions | ✅ | ✅ | ✅ |
|
|
136
|
-
| Savepoints | ✅ | ✅ | ❌ |
|
|
137
|
-
| LISTEN / NOTIFY | ✅ | ✅ | ✅ |
|
|
138
|
-
| Connection pool | ✅ | ✅ | ✅ |
|
|
139
|
-
| Zero dependencies | ✅ | ✅ | ❌ |
|
|
140
|
-
|
|
141
|
-
---
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
## 📖 Features
|
|
145
|
-
|
|
146
|
-
### 🎯 Typed Error Handling
|
|
14
|
+
## Thirty seconds
|
|
147
15
|
|
|
148
|
-
|
|
16
|
+
```typescript
|
|
17
|
+
import { sql, Pool } from "@m2k-5f/pgtx"
|
|
149
18
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
19
|
+
const pool = new Pool({
|
|
20
|
+
host: 'localhost',
|
|
21
|
+
user: 'postgres',
|
|
22
|
+
password: 'postgres',
|
|
23
|
+
database: 'myapp'
|
|
24
|
+
})
|
|
153
25
|
|
|
154
|
-
|
|
155
|
-
const users = await pool.query<User>`SELECT * FROM users WHERE id = ${1}`
|
|
156
|
-
.recoverIf(err => err.code === '42P01', []) // undefined_table → []
|
|
157
|
-
.recoverIf(err => err.code === '23505', []) // unique_violation → []
|
|
158
|
-
.tapErr(err => logger.error(err)) // log remaining errors
|
|
159
|
-
```
|
|
26
|
+
const [user] = await pool.query<User>`SELECT * FROM users WHERE id = ${1}`
|
|
160
27
|
|
|
161
|
-
|
|
28
|
+
// returns rows → query, doesn't → execute
|
|
29
|
+
await pool.execute`
|
|
30
|
+
INSERT INTO users ${sql.insert([{ name: 'Alice', age: 25 }, { name: 'Bob', age: 30 }])}
|
|
31
|
+
`
|
|
162
32
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
// 5 queries, only 2 network round-trips
|
|
169
|
-
const {user, posts, ...data} = await Bind({
|
|
170
|
-
user: () => pool.query<User>`...`,
|
|
171
|
-
config: () => pool.query<Config>`...`,
|
|
172
|
-
announcements: () => pool.query<Announcement>`...`
|
|
173
|
-
})
|
|
174
|
-
.bind({
|
|
175
|
-
posts: ({ user }) => pool.query<Post>`...`,
|
|
176
|
-
notifications: ({ user }) => pool.query<Notif>`...`
|
|
177
|
-
})
|
|
178
|
-
```
|
|
179
|
-
> 🚀 Pgtx automatically groups concurrent queries into pipeline batches, reducing network overhead by up to 5x compared to sequential queries.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
---
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
### High-Performance Data Streaming (`pool.stream`)
|
|
186
|
-
|
|
187
|
-
For heavy database lookups (exporting millions of rows, bulk reports, or large analytical dumps), memory accumulation is the ultimate killer of backend stability. Storing rows in a standard JavaScript array causes massive heap pollution and triggers blocking Garbage Collection spikes.
|
|
188
|
-
|
|
189
|
-
Pgtx solves this at the protocol level by introducing `pool.stream()`, which bypasses row aggregation entirely and pipes rows transitively directly into a native Web **`ReadableStream`**.
|
|
190
|
-
|
|
191
|
-
#### 1. Ultra-Low Memory Row Iteration
|
|
192
|
-
You can consume database rows sequentially using standard `for await...of` syntax. Rows are processed and evicted from memory the moment they arrive from the network socket buffer.
|
|
193
|
-
|
|
194
|
-
```typescript
|
|
195
|
-
interface HeavyLog { id: number; data: string; timestamp: Date; }
|
|
196
|
-
|
|
197
|
-
const logStream = pool.stream<HeavyLog>`
|
|
198
|
-
SELECT id, data, timestamp FROM application_logs WHERE level = ${'error'}
|
|
199
|
-
`;
|
|
200
|
-
|
|
201
|
-
for await (const log of logStream) {
|
|
202
|
-
// Each log object is parsed on-the-fly and processed instantly.
|
|
203
|
-
// Zero rows are accumulated in the internal driver state!
|
|
204
|
-
console.log(`[${log.timestamp.toISOString()}] ${log.data}`);
|
|
205
|
-
}
|
|
206
|
-
```
|
|
33
|
+
await pool.begin(async tx => {
|
|
34
|
+
await tx.execute`UPDATE accounts SET balance = balance - 100 WHERE id = ${1}`
|
|
35
|
+
await tx.execute`UPDATE accounts SET balance = balance + 100 WHERE id = ${2}`
|
|
36
|
+
})
|
|
37
|
+
```
|
|
207
38
|
|
|
208
|
-
|
|
209
|
-
Since Pgtx implements the standardized Web Streams API, you can bridge your database query directly into an HTTP response body with absolutely zero intermediate buffers.
|
|
39
|
+
That's most of what you need to know to use it. The rest of this document is for when you want to know *why* it's fast, or you need one of the sharper tools.
|
|
210
40
|
|
|
211
|
-
|
|
212
|
-
import { Pool } from "@m2k-5f/pgtx";
|
|
41
|
+
## Numbers, since it's the first thing everyone asks
|
|
213
42
|
|
|
214
|
-
|
|
43
|
+
Benchmarks run on GitHub Actions (Ubuntu, 2 vCPUs), reproducible, sources in this repo. Take CI numbers with the usual grain of salt — noisy neighbors and all that — but the gap is wide enough that it holds.
|
|
215
44
|
|
|
216
|
-
|
|
217
|
-
port: 3000,
|
|
218
|
-
async fetch(request) {
|
|
219
|
-
const url = new URL(request.url);
|
|
45
|
+
**3000 concurrent parameterized SELECTs, pool of 10, measured with mitata:**
|
|
220
46
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
47
|
+
| Driver | Avg time | Relative | Memory (p75) |
|
|
48
|
+
|---|---:|---:|---:|
|
|
49
|
+
| Pgtx | **24.18 ms** | 1.00× | ≈4.5 MB |
|
|
50
|
+
| Postgres.js | 83.36 ms | 3.45× slower | ≈7.6 MB |
|
|
51
|
+
| node-postgres (`pg`) | 377.95 ms | 15.63× slower | ≈11.4 MB |
|
|
224
52
|
|
|
225
|
-
|
|
226
|
-
headers: {
|
|
227
|
-
"Content-Type": "application/json",
|
|
228
|
-
"Transfer-Encoding": "chunked",
|
|
229
|
-
},
|
|
230
|
-
});
|
|
231
|
-
}
|
|
53
|
+
**Real HTTP throughput, `node:http` serving a PG-backed route, `wrk`:**
|
|
232
54
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
55
|
+
| Concurrency | Pgtx | Postgres.js | Speedup |
|
|
56
|
+
|---:|---:|---:|---:|
|
|
57
|
+
| 50 | 5,272 req/s | 5,691 req/s | 0.93× |
|
|
58
|
+
| 200 | **12,918 req/s** | 6,724 req/s | 1.92× |
|
|
59
|
+
| 1000 | **21,429 req/s** | 8,423 req/s | 2.54× |
|
|
60
|
+
| 10000 | **22,486 req/s** | 12,764 req/s | 1.76× |
|
|
237
61
|
|
|
238
|
-
|
|
62
|
+
At low concurrency Pgtx is roughly a wash with Postgres.js — pipelining has nothing to multiplex yet. The gap opens up as soon as there's real contention, which is the only regime that matters in production.
|
|
239
63
|
|
|
240
|
-
|
|
64
|
+
**How:** everything you fire concurrently against the same connection gets folded into one pipelined write — Parse/Bind/Execute for every query in the batch goes out in a single `socket.write()`, and results get demuxed as they come back, in order, without buffering rows you haven't asked for yet. Prepared statements are cached and deduplicated automatically, row descriptions are cached alongside them, and the binary protocol skips text (de)serialization where it can. None of this requires you to change how you write queries.
|
|
241
65
|
|
|
242
|
-
|
|
243
|
-
await pool.begin(async (tx) => {
|
|
244
|
-
await tx.query`INSERT INTO orders (user_id) VALUES (${userId})`
|
|
245
|
-
|
|
246
|
-
await tx.savepoint('update_stock', async (stx) => {
|
|
247
|
-
await stx.query`UPDATE stock SET count = count - 1 WHERE product_id = ${productId}`
|
|
248
|
-
if (outOfStock) throw new Error('out of stock') // Only savepoint rolls back
|
|
249
|
-
})
|
|
250
|
-
.tapErr(console.log) // Error: out of stock
|
|
251
|
-
})
|
|
252
|
-
```
|
|
66
|
+
## The parts worth knowing about
|
|
253
67
|
|
|
254
|
-
|
|
68
|
+
### Errors you can pattern-match on
|
|
255
69
|
|
|
256
|
-
|
|
70
|
+
Every call returns a `Future<T[], PostgresError>` from [fluent-future](https://www.npmjs.com/package/fluent-future) instead of a bare `Promise`. `await` still works exactly like you'd expect — but you also get typed errors and a way to handle them without a `try/catch` pyramid:
|
|
257
71
|
|
|
258
|
-
|
|
72
|
+
```typescript
|
|
73
|
+
const users = await pool.query<User>`SELECT * FROM users WHERE id = ${1}`
|
|
74
|
+
.recoverIf(err => err.code === '42P01', []) // undefined_table → []
|
|
75
|
+
.recoverIf(err => err.code === '23505', []) // unique_violation → []
|
|
76
|
+
.tapErr(err => logger.error(err))
|
|
77
|
+
```
|
|
259
78
|
|
|
260
|
-
|
|
261
|
-
Notifications are atomic and can be triggered directly from the `Pool` utilizing any available socket:
|
|
262
|
-
```typescript
|
|
263
|
-
await pool.notify('user_events', JSON.stringify({ id: 42, action: 'signup' }))
|
|
264
|
-
```
|
|
79
|
+
### Pipelining is automatic, not opt-in
|
|
265
80
|
|
|
266
|
-
|
|
267
|
-
You can subscribe directly via the `Pool` instance. Pgtx will automatically borrow a dedicated connection from the pool, issue the `LISTEN` command, and seamlessly manage its lifecycle.
|
|
81
|
+
`Bind`/`.bind` from fluent-future group independent queries into the same pipeline batch for you:
|
|
268
82
|
|
|
269
|
-
|
|
83
|
+
```typescript
|
|
84
|
+
// 5 queries, 2 round-trips
|
|
85
|
+
const { user, posts, ...data } = await Bind({
|
|
86
|
+
user: pool.query<User>`...`,
|
|
87
|
+
config: pool.query<Config>`...`,
|
|
88
|
+
announcements: pool.query<Announcement>`...`
|
|
89
|
+
}).bind({
|
|
90
|
+
posts: ({ user }) => pool.query<Post>`...`,
|
|
91
|
+
notifications: ({ user }) => pool.query<Notif>`...`
|
|
92
|
+
})
|
|
93
|
+
```
|
|
270
94
|
|
|
271
|
-
|
|
272
|
-
const onEvent = (payload: string) => {
|
|
273
|
-
console.log(`Received payload: ${payload}`)
|
|
274
|
-
}
|
|
95
|
+
Anything you fire off in the same tick without awaiting in between ends up on the wire together.
|
|
275
96
|
|
|
276
|
-
|
|
277
|
-
const unsubscribe = await pool.listen('user_events', onEvent)
|
|
97
|
+
### Streaming that doesn't buffer
|
|
278
98
|
|
|
279
|
-
|
|
280
|
-
// It automatically sends UNLISTEN and releases the connection back to the pool!
|
|
281
|
-
await unsubscribe()
|
|
282
|
-
```
|
|
99
|
+
`pool.stream()` pipes rows straight from the socket into a `ReadableStream`, no intermediate array, no GC spike from holding a million-row export in memory.
|
|
283
100
|
|
|
284
|
-
|
|
285
|
-
|
|
101
|
+
```typescript
|
|
102
|
+
for await (const log of pool.stream<Log>`SELECT * FROM application_logs WHERE level = ${'error'}`) {
|
|
103
|
+
console.log(log.timestamp, log.data)
|
|
104
|
+
}
|
|
105
|
+
```
|
|
286
106
|
|
|
287
|
-
|
|
288
|
-
const conn = await pool.acquire()
|
|
107
|
+
It's a real Web Streams object, so it drops straight into an HTTP response body:
|
|
289
108
|
|
|
290
|
-
|
|
291
|
-
|
|
109
|
+
```typescript
|
|
110
|
+
export default {
|
|
111
|
+
async fetch(req) {
|
|
112
|
+
const stream = pool.stream`SELECT id, email FROM giant_user_table`
|
|
113
|
+
return new Response(stream, { headers: { "Content-Type": "application/json" } })
|
|
292
114
|
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
293
117
|
|
|
294
|
-
|
|
295
|
-
await conn.listen('user_events', onEvent)
|
|
296
|
-
await conn.listen('user_events', (data) => logToFile(data))
|
|
297
|
-
|
|
298
|
-
// Cleans up callbacks (Sends UNLISTEN only when the channel has zero callbacks left)
|
|
299
|
-
await conn.unlisten('user_events', onEvent)
|
|
300
|
-
|
|
301
|
-
// ⚠️ Manual lifecycle management is strictly required for this pattern!
|
|
302
|
-
// Do NOT release it back to the pool until you are completely done listening.
|
|
303
|
-
this.release(conn)
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
> ⚠️ **Architecture Note:** While `pool.notify` is a fire-and-forget atomic command, subscription states (`LISTEN`/`UNLISTEN`) are strictly tied to specific PostgreSQL backend processes. Using the high-level `pool.listen()` is strongly recommended for application code, as it encapsulates socket management into an elegant, leak-proof callback boundary.
|
|
307
|
-
|
|
308
|
-
---
|
|
118
|
+
### Transactions and savepoints
|
|
309
119
|
|
|
310
|
-
|
|
120
|
+
```typescript
|
|
121
|
+
await pool.begin(async tx => {
|
|
122
|
+
await tx.execute`INSERT INTO orders (user_id) VALUES (${userId})`
|
|
311
123
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
124
|
+
await tx.savepoint('reserve_stock', async stx => {
|
|
125
|
+
await stx.execute`UPDATE stock SET count = count - 1 WHERE product_id = ${productId}`
|
|
126
|
+
if (outOfStock) throw new Error('out of stock') // only the savepoint rolls back
|
|
127
|
+
}).tapErr(console.log)
|
|
128
|
+
})
|
|
129
|
+
```
|
|
317
130
|
|
|
318
|
-
|
|
319
|
-
INSERT INTO users ${sql.insert(users)}
|
|
320
|
-
`
|
|
321
|
-
// INSERT INTO users (name, email) VALUES ($1, $2), ($3, $4)
|
|
322
|
-
```
|
|
131
|
+
### LISTEN / NOTIFY without babysitting a connection
|
|
323
132
|
|
|
324
|
-
|
|
133
|
+
```typescript
|
|
134
|
+
await pool.notify('user_events', JSON.stringify({ id: 42, action: 'signup' }))
|
|
325
135
|
|
|
326
|
-
|
|
136
|
+
const unlisten = await pool.listen('user_events', payload => {
|
|
137
|
+
console.log('got:', payload)
|
|
138
|
+
})
|
|
327
139
|
|
|
328
|
-
|
|
329
|
-
|
|
140
|
+
// later
|
|
141
|
+
await unlisten() // sends UNLISTEN, hands the connection back
|
|
142
|
+
```
|
|
330
143
|
|
|
331
|
-
|
|
332
|
-
UPDATE users SET ${sql.update(data)} WHERE id = ${userId}
|
|
333
|
-
`
|
|
334
|
-
// UPDATE users SET status = $1, last_login = $2 WHERE id = $3
|
|
335
|
-
```
|
|
144
|
+
`pool.listen` borrows a dedicated connection and manages its lifecycle for you. If you need to multiplex several callbacks onto one channel on a connection you're pinning yourself, drop down to `conn.listen`/`conn.unlisten` directly — just don't release that connection back to the pool while you're still using it for that.
|
|
336
145
|
|
|
337
|
-
|
|
146
|
+
### Building queries without string-gluing
|
|
338
147
|
|
|
339
|
-
|
|
148
|
+
```typescript
|
|
149
|
+
// bulk insert — columns inferred from the object
|
|
150
|
+
await pool.execute`INSERT INTO users ${sql.insert(users)}`
|
|
340
151
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const subquery = sql.fragment`(SELECT id FROM roles WHERE name = ${'admin'})`
|
|
152
|
+
// dynamic SET clause
|
|
153
|
+
await pool.execute`UPDATE users SET ${sql.update({ status: 'active', last_login: new Date() })} WHERE id = ${userId}`
|
|
344
154
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
`
|
|
349
|
-
```
|
|
155
|
+
// composable fragments
|
|
156
|
+
const filter = sql.fragment`status = ${'active'} AND age > ${21}`
|
|
157
|
+
await pool.query`SELECT * FROM users WHERE ${filter}`
|
|
350
158
|
|
|
351
|
-
|
|
159
|
+
// clean WHERE from an object, undefined keys just drop out
|
|
160
|
+
await pool.query`SELECT * FROM users WHERE ${sql.where({ role: 'admin', age: undefined, active: true })}`
|
|
352
161
|
|
|
353
|
-
|
|
162
|
+
// conditional fragments
|
|
163
|
+
await pool.query`SELECT * FROM posts ${search ? sql.fragment`WHERE title ILIKE ${search}` : sql.empty}`
|
|
164
|
+
```
|
|
354
165
|
|
|
355
|
-
|
|
356
|
-
const ids = [10, 20, 30]
|
|
357
|
-
await pool.query`
|
|
358
|
-
SELECT * FROM users WHERE id IN (${sql.array(ids)})
|
|
359
|
-
`
|
|
360
|
-
// SELECT * FROM users WHERE id IN ($1, $2, $3)
|
|
166
|
+
`undefined` means `DEFAULT` in an insert, means "skip this field" in an update, and throws if you try to hand it to `VALUES` or an array — it's meant to be a decision point, not a silent `NULL`.
|
|
361
167
|
|
|
362
|
-
|
|
363
|
-
sql.fragment`status = ${'active'}`,
|
|
364
|
-
sql.fragment`age > ${18}`
|
|
365
|
-
]
|
|
366
|
-
await pool.query`
|
|
367
|
-
SELECT * FROM users WHERE ${sql.array(conditions, ' AND ')}
|
|
368
|
-
`
|
|
369
|
-
```
|
|
370
|
-
|
|
371
|
-
---
|
|
372
|
-
|
|
373
|
-
### Clean WHERE Clauses
|
|
374
|
-
|
|
375
|
-
```typescript
|
|
376
|
-
const filters = { role: 'admin', age: undefined, active: true }
|
|
377
|
-
await pool.query`
|
|
378
|
-
SELECT * FROM users WHERE ${sql.where(filters)}
|
|
379
|
-
`
|
|
380
|
-
// SELECT * FROM users WHERE role = $1 AND active = $2
|
|
381
|
-
```
|
|
382
|
-
|
|
383
|
-
---
|
|
384
|
-
|
|
385
|
-
### Conditional Logic
|
|
386
|
-
|
|
387
|
-
```typescript
|
|
388
|
-
const search = ""
|
|
389
|
-
await pool.query`
|
|
390
|
-
SELECT * FROM posts
|
|
391
|
-
${search ? sql.fragment`WHERE title ILIKE ${search}` : sql.empty}
|
|
392
|
-
`
|
|
393
|
-
```
|
|
394
|
-
|
|
395
|
-
---
|
|
396
|
-
|
|
397
|
-
## 🛡️ Security
|
|
398
|
-
|
|
399
|
-
| Pattern | Protection |
|
|
400
|
-
|---------|------------|
|
|
401
|
-
| `sql.ident(name)` | Escapes identifiers: `user` → `"user"` |
|
|
402
|
-
| `sql.literal(value)` | Escapes string literals |
|
|
403
|
-
| Parameter binding | Uses native `$1, $2` placeholders |
|
|
404
|
-
| Template tags | Cannot be injected via user input |
|
|
405
|
-
|
|
406
|
-
```typescript
|
|
407
|
-
// ✅ Safe - parameterized
|
|
408
|
-
await pool.query`SELECT * FROM users WHERE name = ${userInput}`
|
|
409
|
-
|
|
410
|
-
// ⚠️ Unsafe - raw interpolation (DON'T DO THIS)
|
|
411
|
-
await pool.query(`SELECT * FROM users WHERE name = '${userInput}'`)
|
|
412
|
-
|
|
413
|
-
// ✅ Safe - identifiers
|
|
414
|
-
await pool.query`SELECT * FROM ${sql.ident(tableName)}`
|
|
415
|
-
```
|
|
416
|
-
|
|
417
|
-
---
|
|
418
|
-
|
|
419
|
-
## 📊 Null & Undefined Handling
|
|
420
|
-
|
|
421
|
-
| Value | In INSERT | In UPDATE | In VALUES | In Arrays |
|
|
422
|
-
|-------|-----------|-----------|-----------|-----------|
|
|
423
|
-
| `null` | `NULL` | `NULL` | `NULL` | `NULL` |
|
|
424
|
-
| `undefined` | `DEFAULT` | Skipped | `Error` | `Error` |
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
```typescript
|
|
428
|
-
// undefined becomes DEFAULT
|
|
429
|
-
await pool.query`
|
|
430
|
-
INSERT INTO users ${sql.insert({
|
|
431
|
-
name: 'Alice',
|
|
432
|
-
age: undefined, // → DEFAULT
|
|
433
|
-
email: null // → NULL
|
|
434
|
-
})}
|
|
435
|
-
`
|
|
436
|
-
// INSERT INTO users (name, age, email) VALUES ($1, DEFAULT, $2)
|
|
437
|
-
|
|
438
|
-
// undefined fields are skipped in UPDATE
|
|
439
|
-
await pool.query`
|
|
440
|
-
UPDATE users SET ${sql.update({
|
|
441
|
-
name: 'Bob',
|
|
442
|
-
age: undefined // Skipped - age remains unchanged
|
|
443
|
-
})} WHERE id = 1
|
|
444
|
-
`
|
|
445
|
-
// UPDATE users SET name = $1 WHERE id = 1
|
|
446
|
-
```
|
|
447
|
-
|
|
448
|
-
---
|
|
449
|
-
|
|
450
|
-
## 🔧 API Reference
|
|
451
|
-
|
|
452
|
-
### Connection
|
|
453
|
-
```typescript
|
|
454
|
-
class Connection {
|
|
455
|
-
static new(params: ConnectionParams): Promise<Connection>
|
|
456
|
-
|
|
457
|
-
query<T>(strings: TemplateStringsArray, ...values: any[]): Future<T[], PostgresError>
|
|
458
|
-
begin<T>(callback: (tx: Transaction) => Promise<T>): Future<T, Error>
|
|
459
|
-
notify(channelName: string, payload?: string): Future<[], PostgresError>
|
|
460
|
-
listen(channelName: string, callback: (payload: string) => void): Future<[], PostgresError>
|
|
461
|
-
unlisten(channelName: string, callback: (payload: string) => void): Future<[], PostgresError>
|
|
462
|
-
stream<T extends Record<string, any>>(templates: TemplateStringsArray, ...params: any[]): ReadableStream<T>
|
|
463
|
-
get isAlive(): boolean
|
|
464
|
-
close(): void
|
|
465
|
-
}
|
|
168
|
+
## Not doing this
|
|
466
169
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
host: string
|
|
471
|
-
port: number
|
|
472
|
-
database: string
|
|
473
|
-
queryTimeout?: number // default: 30 srconds
|
|
474
|
-
logLevel?: 'none' | 'error' | 'notice' | 'query' // default: "error"
|
|
475
|
-
}
|
|
476
|
-
```
|
|
477
|
-
|
|
478
|
-
### Pool
|
|
479
|
-
|
|
480
|
-
```typescript
|
|
481
|
-
class Pool {
|
|
482
|
-
constructor(config: PoolConfig)
|
|
483
|
-
|
|
484
|
-
query<T>(strings: TemplateStringsArray, ...values: any[]): Future<T[], PostgresError>
|
|
485
|
-
begin<T>(callback: (tx: Transaction) => Promise<T>): Future<T, Error>
|
|
486
|
-
notify(channelName: string, payload?: string): Future<[], PostgresError>
|
|
487
|
-
listen(channel: string, callback: (payload: string) => void): Future<() => Promise<void>, PostgresError>
|
|
488
|
-
stream<T extends Record<string, any>>(templates: TemplateStringsArray, ...args: any[]): ReadableStream<T>
|
|
489
|
-
withAcquire<T>(fn: (conn: Connection) => Promise<T>): Future<T, Error>
|
|
490
|
-
acquire(): Future<Connection, PostgresError>
|
|
491
|
-
release(conn: Connection): void
|
|
492
|
-
close(): void
|
|
493
|
-
|
|
494
|
-
get size(): number
|
|
495
|
-
get total(): number
|
|
496
|
-
}
|
|
170
|
+
```typescript
|
|
171
|
+
// don't
|
|
172
|
+
await pool.query(`SELECT * FROM users WHERE name = '${userInput}'`)
|
|
497
173
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
174
|
+
// do
|
|
175
|
+
await pool.query`SELECT * FROM users WHERE name = ${userInput}`
|
|
176
|
+
await pool.query`SELECT * FROM ${sql.ident(tableName)}`
|
|
177
|
+
```
|
|
502
178
|
|
|
503
|
-
|
|
179
|
+
Everything that goes through a tagged template is bound as `$1, $2, ...`. There's no code path where a template value becomes raw SQL text — if you need a dynamic identifier or literal, `sql.ident`/`sql.literal` exist precisely so you're never tempted to interpolate by hand.
|
|
180
|
+
|
|
181
|
+
## API
|
|
182
|
+
|
|
183
|
+
### `Connection`
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
class Connection {
|
|
187
|
+
static new(config: ConnectionPartialConfig): Future<Connection, PostgresError>
|
|
188
|
+
|
|
189
|
+
query<T>(strings: TemplateStringsArray, ...values: any[]): Future<T[], PostgresError>
|
|
190
|
+
execute(strings: TemplateStringsArray, ...values: any[]): Future<void, PostgresError>
|
|
191
|
+
stream<T>(strings: TemplateStringsArray, ...values: any[]): ReadableStream<T>
|
|
192
|
+
begin<T>(callback: (tx: Transaction) => Promise<T>): Future<T, unknown>
|
|
193
|
+
notify(channelName: string, payload?: string): Future<void, PostgresError>
|
|
194
|
+
listen(channelName: string, callback: (payload: string) => void): Future<void, PostgresError>
|
|
195
|
+
unlisten(channelName: string, callback: (payload: string) => void): Future<void, PostgresError>
|
|
196
|
+
close(): Future<void, PostgresError>
|
|
197
|
+
|
|
198
|
+
get isOpened(): boolean
|
|
199
|
+
get isClosed(): boolean
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
interface ConnectionPartialConfig {
|
|
203
|
+
user: string
|
|
204
|
+
password?: string
|
|
205
|
+
host: string
|
|
206
|
+
port: number
|
|
207
|
+
database: string
|
|
208
|
+
logLevel?: 'error' | 'notice' | 'query' // default 'error'
|
|
209
|
+
int8toBigint?: boolean // default false
|
|
210
|
+
queryTimeout?: number // default 30000
|
|
211
|
+
syncShedule?: 'beforeMicrotask' | 'afterMicrotask' | 'Immediate' // default 'afterMicrotask'
|
|
212
|
+
}
|
|
213
|
+
```
|
|
504
214
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
215
|
+
### `Pool`
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
class Pool {
|
|
219
|
+
constructor(config: PoolPartialConfig)
|
|
220
|
+
|
|
221
|
+
query<T>(strings: TemplateStringsArray, ...values: any[]): Future<T[], PostgresError>
|
|
222
|
+
execute(strings: TemplateStringsArray, ...values: any[]): Future<void, PostgresError>
|
|
223
|
+
stream<T>(strings: TemplateStringsArray, ...values: any[]): ReadableStream<T>
|
|
224
|
+
begin<T>(callback: (tx: Transaction) => Promise<T>): Future<T, unknown>
|
|
225
|
+
notify(channelName: string, payload?: string): Future<void, PostgresError>
|
|
226
|
+
listen(channel: string, callback: (payload: string) => void): Future<() => Future<void, PostgresError>, PostgresError>
|
|
227
|
+
withAcquire<T>(fn: (conn: Connection) => Promise<T>): Future<T, unknown>
|
|
228
|
+
acquire(): Future<Connection, PostgresError>
|
|
229
|
+
release(conn: Connection): void
|
|
230
|
+
close(): Future<void, PostgresError>
|
|
231
|
+
|
|
232
|
+
get size(): number
|
|
233
|
+
get total(): number
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
interface PoolPartialConfig extends ConnectionPartialConfig {
|
|
237
|
+
max?: number // default 20
|
|
238
|
+
}
|
|
239
|
+
```
|
|
511
240
|
|
|
512
|
-
|
|
513
|
-
}
|
|
514
|
-
```
|
|
515
|
-
|
|
516
|
-
### **sql** helper
|
|
517
|
-
```typescript
|
|
518
|
-
const sql: {
|
|
519
|
-
ident<T extends string>(identificator: T): IdentifierClause<T>
|
|
520
|
-
literal<T extends string>(value: T): LiteralClause<T>
|
|
521
|
-
fragment(strings: TemplateStringsArray, ...values: any[]): FragmentClause
|
|
522
|
-
insert<T extends Record<string, any>>(...objects: NoInfer<T>[]): InsertClause<T>
|
|
523
|
-
update<T extends Record<string, any>>(object: T): UpdateClause<T>
|
|
524
|
-
where<T extends Record<string, any>>(whereMap: T): WhereClause<T>
|
|
525
|
-
excluded(fields: string[]): ExcludeUpdateClause
|
|
526
|
-
array(array: any[], separator?: string): ArrayClause
|
|
527
|
-
empty: EmptyClause
|
|
528
|
-
}
|
|
529
|
-
```
|
|
241
|
+
### `Transaction`
|
|
530
242
|
|
|
531
|
-
|
|
243
|
+
```typescript
|
|
244
|
+
class Transaction {
|
|
245
|
+
query<T>(strings: TemplateStringsArray, ...values: any[]): Future<T[], PostgresError>
|
|
246
|
+
commit(): Future<void, PostgresError>
|
|
247
|
+
rollback(): Future<void, PostgresError>
|
|
248
|
+
savepoint<T>(name: string, callback: (tx: Transaction) => Promise<T>): Future<T, unknown>
|
|
532
249
|
|
|
250
|
+
get isActive(): boolean
|
|
251
|
+
}
|
|
252
|
+
```
|
|
533
253
|
|
|
534
|
-
|
|
254
|
+
### `sql`
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
const sql: {
|
|
258
|
+
ident<T extends string>(name: T): IdentifierClause<T>
|
|
259
|
+
literal<T extends string>(value: T): LiteralClause<T>
|
|
260
|
+
fragment(strings: TemplateStringsArray, ...values: any[]): FragmentClause
|
|
261
|
+
insert<T extends Record<string, any>>(...objects: T[]): InsertClause<T>
|
|
262
|
+
update<T extends Record<string, any>>(object: T): UpdateClause<T>
|
|
263
|
+
where<T extends Record<string, any>>(map: T): WhereClause<T>
|
|
264
|
+
excluded(fields: string[]): ExcludeUpdateClause
|
|
265
|
+
array(values: any[], separator?: string): ArrayClause
|
|
266
|
+
empty: EmptyClause
|
|
267
|
+
}
|
|
268
|
+
```
|
|
535
269
|
|
|
536
|
-
|
|
270
|
+
## What this isn't
|
|
537
271
|
|
|
538
|
-
|
|
272
|
+
Not an ORM. No migrations, no model layer, no query builder that hides SQL from you. You write SQL, Pgtx gets it to Postgres as fast as it can and gets the rows back to you with as little overhead as possible. If you want an ORM on top, Pgtx is a fine thing to put underneath one.
|
|
539
273
|
|
|
540
|
-
|
|
274
|
+
## License
|
|
541
275
|
|
|
542
|
-
|
|
276
|
+
MIT © [M2K-5F](https://github.com/M2K-5F)
|
|
543
277
|
|
|
544
|
-
|
|
278
|
+
**Made with ❤️ and a bit of insanity**
|