reltype 0.1.4 → 0.1.6

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.
Files changed (4) hide show
  1. package/CHANGELOG.md +83 -3
  2. package/README.ko.md +517 -623
  3. package/README.md +511 -623
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -6,97 +6,160 @@
6
6
  [![node](https://img.shields.io/node/v/reltype.svg)](https://www.npmjs.com/package/reltype)
7
7
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue.svg)](https://www.typescriptlang.org/)
8
8
 
9
- **Type-first relational modeling for PostgreSQL in TypeScript.**
9
+ > 한국어 문서 [README.ko.md](./README.ko.md)
10
10
 
11
- Define your PostgreSQL tables in TypeScript code and get fully-typed query results automatically.
11
+ **The PostgreSQL query library that gets out of your way.**
12
12
 
13
- - **Type-safe** INSERT / SELECT / UPDATE types are automatically inferred from your schema
14
- - **camelCase ↔ snake_case** Automatic conversion between DB column names and TypeScript variables
15
- - **Fluent query builder** — Chain `WHERE`, `OR`, `JOIN`, `GROUP BY`, `LIMIT`, `paginate`, `calculate`, `stream` and more
16
- - **Large data optimization** — Cursor pagination, batch processing, AsyncGenerator streaming
17
- - **Error classification** — `DbError` automatically classifies PostgreSQL errors into 13 distinct kinds
18
- - **Hook system** — Before/after query lifecycle hooks for monitoring and APM integration
13
+ No Prisma schema. No decorators. No code generation. No migrations.
14
+ Just TypeScriptdefine your table once, get fully-typed queries instantly.
19
15
 
20
- > 한국어 문서는 [README.ko.md](./README.ko.md) 를 참고하세요.
16
+ ```ts
17
+ // Define once
18
+ const usersTable = defineTable('users', {
19
+ id: col.serial().primaryKey(),
20
+ firstName: col.varchar(255).notNull(),
21
+ email: col.text().notNull(),
22
+ isActive: col.boolean().default(),
23
+ createdAt: col.timestamptz().defaultNow(),
24
+ });
25
+
26
+ // Use everywhere — fully typed, zero boilerplate
27
+ const page = await userRepo
28
+ .select({ isActive: true })
29
+ .where({ email: { operator: 'ILIKE', value: '%@gmail.com' } })
30
+ .orderBy([{ column: 'createdAt', direction: 'DESC' }])
31
+ .paginate({ page: 1, pageSize: 20 });
32
+ // → { data: User[], count: 150, page: 1, pageSize: 20, nextAction: true, previousAction: false }
33
+ ```
34
+
35
+ ---
36
+
37
+ ## Why reltype?
38
+
39
+ ### The problem with existing tools
40
+
41
+ | | Prisma | TypeORM | Drizzle | **reltype** |
42
+ |---|---|---|---|---|
43
+ | Schema definition | `schema.prisma` file | Decorators on class | TS schema | **TS schema** |
44
+ | Code generation required | ✅ Yes | ❌ No | ❌ No | **❌ No** |
45
+ | Migration CLI required | ✅ Yes | Optional | Optional | **❌ Never** |
46
+ | camelCase ↔ snake_case | Manual config | Manual config | Manual config | **Automatic** |
47
+ | Raw SQL support | Limited | Yes | Yes | **Yes** |
48
+ | Bundle size | Heavy | Heavy | Light | **Minimal** |
49
+ | Large data streaming | Plugin needed | Custom | Custom | **Built-in** |
50
+
51
+ ### What makes reltype different
52
+
53
+ **1. Define once, types everywhere**
54
+ Write your schema in TypeScript. `INSERT`, `SELECT`, and `UPDATE` types are automatically inferred — no duplicated interfaces, no `@Entity`, no `model User {}`.
55
+
56
+ **2. camelCase ↔ snake_case is fully automatic**
57
+ Your DB has `first_name`, `created_at`, `is_active`. Your TypeScript has `firstName`, `createdAt`, `isActive`. reltype handles the mapping in both directions, always, for free.
58
+
59
+ **3. No build step, no CLI, no migration files**
60
+ `npm install reltype` and start writing queries. That's it.
61
+
62
+ **4. Large-scale production ready**
63
+ Cursor-based pagination, AsyncGenerator streaming, batch processing, connection pool monitoring, structured error classification, and lifecycle hooks — all built in.
21
64
 
22
65
  ---
23
66
 
24
67
  ## Installation
25
68
 
26
69
  ```bash
27
- # Install reltype
28
- npm install reltype
29
-
30
- # pg is a peerDependency — install it separately
31
- npm install pg
70
+ npm install reltype pg
32
71
  npm install --save-dev @types/pg
33
72
  ```
34
73
 
35
- > Requires `pg` version 8.0.0 or higher.
74
+ > `pg` (node-postgres) is a peer dependency. Version 8.0.0+ required.
36
75
 
37
76
  ---
38
77
 
39
- ## Environment Variables
78
+ ## 2-Minute Quick Start
40
79
 
41
- Create a `.env` file in your project root.
80
+ ### Step 1 Environment Variables
42
81
 
43
82
  ```env
44
- # ── Required (either CONNECTION_STRING or DB_NAME must be set) ───────────────
45
-
46
- # Option 1: Connection String (takes priority)
47
- DB_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/mydb
48
-
49
- # Option 2: Individual settings
83
+ # .env
50
84
  DB_HOST=127.0.0.1
51
85
  DB_PORT=5432
52
86
  DB_NAME=mydb
53
87
  DB_USER=postgres
54
88
  DB_PASSWORD=postgres
89
+ DB_MAX=10
90
+ DB_CONNECTION_TIMEOUT=3000
91
+ ```
55
92
 
56
- # ── Optional ─────────────────────────────────────────────────────────────────
57
-
58
- DB_SSL=false # Enable SSL
59
- DB_MAX=10 # Max connection pool size
60
- DB_IDLE_TIMEOUT=30000 # Idle connection timeout (ms)
61
- DB_CONNECTION_TIMEOUT=2000 # Connection acquisition timeout (ms)
62
- DB_ALLOW_EXIT_ON_IDLE=false # Allow process exit when idle
63
- DB_STATEMENT_TIMEOUT=0 # SQL statement timeout (ms, 0 = unlimited)
64
- DB_QUERY_TIMEOUT=0 # Query timeout (ms, 0 = unlimited)
65
- DB_APPLICATION_NAME=my-app # App name shown in pg_stat_activity
66
- DB_KEEP_ALIVE=true # Enable TCP keep-alive
67
- DB_KEEP_ALIVE_INITIAL_DELAY=10000 # Initial keep-alive delay (ms)
68
-
69
- # ── Logging ───────────────────────────────────────────────────────────────────
93
+ Or use a connection string:
70
94
 
71
- LOGGER=true # Enable logger (true / false)
72
- LOG_LEVEL=info # Log level (debug / info / log / warn / error)
95
+ ```env
96
+ DB_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/mydb
73
97
  ```
74
98
 
75
- ---
99
+ ### Step 2 — Load dotenv at entry point
100
+
101
+ ```ts
102
+ // index.ts — must be the very first line
103
+ import 'dotenv/config';
76
104
 
77
- ## Quick Start
105
+ import { getPool } from 'reltype';
106
+ ```
78
107
 
79
- ### 1. Define a Table Schema
108
+ ### Step 3 — Define a table schema
80
109
 
81
110
  ```ts
111
+ // schema/usersTable.ts
82
112
  import { defineTable, col } from 'reltype';
83
113
 
84
114
  export const usersTable = defineTable('users', {
85
- id: col.serial().primaryKey(), // SERIAL PRIMARY KEY (optional on INSERT)
86
- firstName: col.varchar(255).notNull(), // VARCHAR(255) NOT NULL
87
- lastName: col.varchar(255).nullable(), // VARCHAR(255) NULL (optional on INSERT)
88
- email: col.text().notNull(), // TEXT NOT NULL
89
- isActive: col.boolean().default(), // BOOLEAN DEFAULT ... (optional on INSERT)
90
- createdAt: col.timestamptz().defaultNow(), // TIMESTAMPTZ DEFAULT NOW() (optional on INSERT)
115
+ id: col.serial().primaryKey(),
116
+ firstName: col.varchar(255).notNull(),
117
+ lastName: col.varchar(255).nullable(),
118
+ email: col.text().notNull(),
119
+ isActive: col.boolean().default(),
120
+ createdAt: col.timestamptz().defaultNow(),
91
121
  });
122
+
123
+ // Types are automatically available — no extra code needed
124
+ // InferRow<typeof usersTable> → full SELECT result type
125
+ // InferInsert<typeof usersTable> → INSERT input (required/optional by modifier)
126
+ // InferUpdate<typeof usersTable> → UPDATE input (PK excluded, all optional)
127
+ ```
128
+
129
+ ### Step 4 — Create a repository and query
130
+
131
+ ```ts
132
+ import { createRepo } from 'reltype';
133
+ import { usersTable } from './schema/usersTable';
134
+
135
+ export const userRepo = createRepo(usersTable);
136
+
137
+ // SELECT
138
+ const users = await userRepo.select({ isActive: true })
139
+ .orderBy([{ column: 'createdAt', direction: 'DESC' }])
140
+ .limit(10);
141
+
142
+ // INSERT
143
+ const user = await userRepo.create({ firstName: 'Alice', email: 'alice@example.com' });
144
+
145
+ // UPDATE
146
+ const updated = await userRepo.update(user.id, { isActive: false });
147
+
148
+ // DELETE
149
+ const deleted = await userRepo.delete(user.id);
92
150
  ```
93
151
 
94
- ### 2. Automatic Type Inference
152
+ Done. You now have a fully-typed, production-ready data layer.
153
+
154
+ ---
155
+
156
+ ## Type Inference — The Core Magic
157
+
158
+ Define your schema once. reltype infers all types automatically:
95
159
 
96
160
  ```ts
97
161
  import { InferRow, InferInsert, InferUpdate } from 'reltype';
98
162
 
99
- // SELECT result type
100
163
  type User = InferRow<typeof usersTable>;
101
164
  // {
102
165
  // id: number;
@@ -107,528 +170,420 @@ type User = InferRow<typeof usersTable>;
107
170
  // createdAt: Date;
108
171
  // }
109
172
 
110
- // INSERT input type (optional columns automatically excluded)
111
173
  type CreateUser = InferInsert<typeof usersTable>;
112
- // { firstName: string; email: string; lastName?: string | null; isActive?: boolean; createdAt?: Date }
174
+ // {
175
+ // firstName: string; ← required (notNull, no default)
176
+ // email: string; ← required
177
+ // lastName?: string | null; ← optional (nullable)
178
+ // isActive?: boolean; ← optional (has DB default)
179
+ // createdAt?: Date; ← optional (defaultNow)
180
+ // }
181
+ // id is excluded — serial auto-generates it
113
182
 
114
- // UPDATE input type (PK excluded, all fields optional)
115
183
  type UpdateUser = InferUpdate<typeof usersTable>;
116
- // { firstName?: string; lastName?: string | null; email?: string; isActive?: boolean; createdAt?: Date }
117
- ```
118
-
119
- ### 3. Load dotenv at Application Entry Point
120
-
121
- `reltype` only reads `process.env`. Load your `.env` file **at the application entry point**.
122
-
123
- ```ts
124
- // Application entry point (index.ts / server.ts / app.ts)
125
- import 'dotenv/config'; // Must be placed before other imports
126
-
127
- // Then import reltype
128
- import { getDatabaseConfig, getPool } from 'reltype';
184
+ // {
185
+ // firstName?: string;
186
+ // lastName?: string | null;
187
+ // email?: string;
188
+ // isActive?: boolean;
189
+ // createdAt?: Date;
190
+ // }
191
+ // id is excluded — it's the lookup key
129
192
  ```
130
193
 
131
- ### 4. Create a Repository
132
-
133
- ```ts
134
- import { createRepo } from 'reltype';
135
- import { usersTable } from './schema';
136
-
137
- export const userRepo = createRepo(usersTable);
138
- ```
194
+ If you change a column in the schema, TypeScript will immediately catch every call site that's now incorrect. **Your schema is the single source of truth.**
139
195
 
140
196
  ---
141
197
 
142
198
  ## Repository API
143
199
 
144
- ### Method Summary
145
-
146
- | Method | Return Type | Description |
200
+ | Method | Returns | Description |
147
201
  |---|---|---|
148
- | `create(data)` | `Promise<T>` | INSERT a single row |
202
+ | `create(data)` | `Promise<T>` | INSERT one row |
149
203
  | `update(id, data)` | `Promise<T \| null>` | UPDATE by primary key |
150
204
  | `delete(id)` | `Promise<boolean>` | DELETE by primary key |
151
- | `upsert(data, col?)` | `Promise<T>` | INSERT or UPDATE |
152
- | `bulkCreate(rows)` | `Promise<T[]>` | INSERT multiple rows |
153
- | `select(where?)` | `QueryBuilder<T>` | Start a fluent query builder |
154
- | `selectOne(where)` | `Promise<T \| null>` | Fetch a single row |
205
+ | `upsert(data, col?)` | `Promise<T>` | INSERT or UPDATE on conflict |
206
+ | `bulkCreate(rows)` | `Promise<T[]>` | INSERT multiple rows in one query |
207
+ | `select(where?)` | `QueryBuilder<T>` | Start a fluent query |
208
+ | `selectOne(where)` | `Promise<T \| null>` | Fetch one row |
155
209
  | `raw(sql, params?)` | `Promise<R[]>` | Execute raw SQL |
156
- | `findAll(opts?)` | `Promise<T[]>` | Static full query |
157
- | `findById(id)` | `Promise<T \| null>` | Fetch single row by PK |
158
- | `findOne(where)` | `Promise<T \| null>` | Fetch single row by condition |
159
- | `useHooks(h)` | `this` | Register global hooks |
160
-
161
- ---
162
-
163
- ## create
164
-
165
- INSERT a single row. Columns with `serial`, `default`, or `nullable` modifiers are optional.
166
-
167
- ```ts
168
- const user = await userRepo.create({
169
- firstName: 'John',
170
- email: 'john@example.com',
171
- // lastName, isActive, createdAt → optional (DB default or nullable)
172
- });
173
- // → User
174
- ```
175
-
176
- ---
177
-
178
- ## update
179
-
180
- UPDATE only the specified columns by primary key. Returns `null` if the row does not exist.
181
-
182
- ```ts
183
- // Partial update
184
- const updated = await userRepo.update(1, {
185
- firstName: 'Jane',
186
- isActive: false,
187
- });
188
- // → User | null
189
-
190
- if (!updated) {
191
- throw new Error('User not found.');
192
- }
193
- ```
194
-
195
- ---
196
-
197
- ## delete
198
-
199
- DELETE by primary key. Returns `true` if a row was deleted, `false` if not found.
200
-
201
- ```ts
202
- const deleted = await userRepo.delete(1);
203
- // → boolean
204
-
205
- if (!deleted) {
206
- throw new Error('User not found.');
207
- }
208
- ```
209
-
210
- ---
211
-
212
- ## upsert
213
-
214
- INSERT or UPDATE based on a conflict column.
215
-
216
- ```ts
217
- // By PK (id) — default
218
- const user = await userRepo.upsert({
219
- id: 1,
220
- firstName: 'John',
221
- email: 'john@example.com',
222
- });
223
-
224
- // By another unique column (snake_case)
225
- const user = await userRepo.upsert(
226
- { firstName: 'John', email: 'john@example.com' },
227
- 'email',
228
- );
229
- // → User
230
- ```
231
-
232
- ---
233
-
234
- ## bulkCreate
235
-
236
- Insert multiple rows with a single `INSERT` query.
237
-
238
- ```ts
239
- const created = await userRepo.bulkCreate([
240
- { firstName: 'Alice', email: 'alice@example.com' },
241
- { firstName: 'Bob', email: 'bob@example.com' },
242
- ]);
243
- // → User[]
244
- ```
210
+ | `findAll(opts?)` | `Promise<T[]>` | Simple query with filter/sort/limit |
211
+ | `findById(id)` | `Promise<T \| null>` | Fetch by primary key |
212
+ | `findOne(where)` | `Promise<T \| null>` | Fetch by equality conditions |
213
+ | `useHooks(h)` | `this` | Register global lifecycle hooks |
245
214
 
246
215
  ---
247
216
 
248
- ## select — Fluent Query Builder
217
+ ## Fluent Query Builder
249
218
 
250
- `repo.select(where?)` returns a `QueryBuilder`.
251
- Chain methods and then `await` or call `.exec()` to execute.
219
+ `repo.select(where?)` returns a `QueryBuilder`. Chain methods freely, then `await` to execute.
252
220
 
253
- ### Basic Query
254
-
255
- ```ts
256
- // Fetch all (await directly — thenable)
257
- const users = await userRepo.select();
258
-
259
- // With initial WHERE condition
260
- const users = await userRepo.select({ isActive: true });
261
- ```
262
-
263
- ### WHERE — AND Conditions
221
+ ### Filtering (WHERE / OR)
264
222
 
265
223
  ```ts
266
224
  // Simple equality
267
- const users = await userRepo.select().where({ isActive: true });
268
-
269
- // Comparison operator
270
- const users = await userRepo.select()
271
- .where({ createdAt: { operator: '>=', value: new Date('2024-01-01') } });
272
-
273
- // IN
274
- const users = await userRepo.select()
275
- .where({ id: { operator: 'IN', value: [1, 2, 3] } });
225
+ const users = await userRepo.select({ isActive: true });
276
226
 
277
- // IS NULL
227
+ // Operators: =, !=, >, <, >=, <=, LIKE, ILIKE, IN, NOT IN, IS NULL, IS NOT NULL
278
228
  const users = await userRepo.select()
279
- .where({ deletedAt: { operator: 'IS NULL' } });
229
+ .where({ createdAt: { operator: '>=', value: new Date('2024-01-01') } })
230
+ .where({ id: { operator: 'IN', value: [1, 2, 3] } });
280
231
 
281
- // LIKE / ILIKE (case-insensitive)
282
- const users = await userRepo.select()
283
- .where({ email: { operator: 'ILIKE', value: '%@gmail.com' } });
284
- ```
285
-
286
- Supported operators: `=` `!=` `>` `<` `>=` `<=` `LIKE` `ILIKE` `IN` `NOT IN` `IS NULL` `IS NOT NULL`
287
-
288
- ### OR — OR Conditions
289
-
290
- Each `.or()` call adds an OR clause.
291
- When AND conditions are present, the result is `WHERE (AND conditions) OR (OR conditions)`.
292
-
293
- ```ts
294
- // firstName ILIKE '%john%' OR email ILIKE '%john%'
232
+ // OR conditions
295
233
  const users = await userRepo.select({ isActive: true })
296
234
  .or({ firstName: { operator: 'ILIKE', value: '%john%' } })
297
235
  .or({ email: { operator: 'ILIKE', value: '%john%' } });
298
236
  // → WHERE (is_active = true) OR (first_name ILIKE '%john%') OR (email ILIKE '%john%')
237
+
238
+ // NULL check
239
+ const unverified = await userRepo.select()
240
+ .where({ verifiedAt: { operator: 'IS NULL' } });
299
241
  ```
300
242
 
301
- ### ORDER BY
243
+ ### Sorting, Paging, Grouping
302
244
 
303
245
  ```ts
304
- const users = await userRepo.select()
305
- .orderBy([{ column: 'createdAt', direction: 'DESC' }]);
306
-
307
- // Multiple sort columns
308
246
  const users = await userRepo.select()
309
247
  .orderBy([
310
248
  { column: 'isActive', direction: 'DESC' },
311
249
  { column: 'createdAt', direction: 'ASC' },
312
- ]);
313
- ```
314
-
315
- ### LIMIT / OFFSET
316
-
317
- ```ts
318
- const users = await userRepo.select()
319
- .orderBy([{ column: 'id', direction: 'ASC' }])
250
+ ])
320
251
  .limit(20)
321
- .offset(40);
322
- // Page 3 (0-indexed offset)
323
- ```
324
-
325
- ### GROUP BY
252
+ .offset(40); // Page 3
326
253
 
327
- ```ts
328
- const result = await userRepo.select()
254
+ // GROUP BY + aggregate
255
+ const stats = await userRepo.select()
329
256
  .groupBy(['isActive'])
330
- .calculate([
331
- { fn: 'COUNT', alias: 'count' },
332
- ]);
333
- // → { count: '42' }
257
+ .calculate([{ fn: 'COUNT', alias: 'count' }]);
334
258
  ```
335
259
 
336
260
  ### JOIN
337
261
 
338
262
  ```ts
339
- // LEFT JOIN
340
263
  const result = await userRepo.select({ isActive: true })
341
264
  .join({ table: 'orders', on: 'users.id = orders.user_id', type: 'LEFT' })
342
- .columns(['users.id', 'users.email'])
265
+ .columns(['users.id', 'users.email', 'COUNT(orders.id) AS orderCount'])
343
266
  .groupBy(['users.id', 'users.email'])
344
- .orderBy([{ column: 'id', direction: 'ASC' }])
345
- .exec();
346
- ```
347
-
348
- JOIN types: `INNER` `LEFT` `RIGHT` `FULL`
349
-
350
- ### Column Selection (columns)
351
-
352
- ```ts
353
- const users = await userRepo.select()
354
- .columns(['id', 'email', 'firstName'])
355
267
  .exec();
356
268
  ```
357
269
 
358
- ---
359
-
360
- ## selectOne
361
-
362
- Shorthand for `select(where).one()`. Returns the first matching row.
363
-
364
- ```ts
365
- const user = await userRepo.selectOne({ email: 'john@example.com' });
366
- // → User | null
367
-
368
- const user = await userRepo.selectOne({ id: 1 });
369
- if (!user) throw new Error('not found');
370
- ```
371
-
372
- ---
270
+ > JOIN types: `INNER` · `LEFT` · `RIGHT` · `FULL`
373
271
 
374
- ## calculateAggregate Functions
375
-
376
- Runs `COUNT`, `SUM`, `AVG`, `MIN`, `MAX` aggregations.
272
+ ### DebugPreview SQL before running
377
273
 
378
274
  ```ts
379
- // Total count
380
- const result = await userRepo.select().calculate([{ fn: 'COUNT', alias: 'count' }]);
381
- const total = parseInt(String(result.count), 10);
275
+ const { sql, params } = userRepo.select({ isActive: true })
276
+ .orderBy([{ column: 'createdAt', direction: 'DESC' }])
277
+ .limit(20)
278
+ .toSQL();
382
279
 
383
- // Multiple aggregations
384
- const stats = await userRepo.select({ isActive: true })
385
- .calculate([
386
- { fn: 'COUNT', alias: 'count' },
387
- { fn: 'AVG', column: 'score', alias: 'avgScore' },
388
- { fn: 'MAX', column: 'score', alias: 'maxScore' },
389
- ]);
390
- // → { count: '42', avgScore: '87.5', maxScore: '100' }
280
+ console.log(sql);
281
+ // SELECT * FROM users WHERE is_active = $1 ORDER BY created_at DESC LIMIT $2
282
+ console.log(params);
283
+ // [ true, 20 ]
391
284
  ```
392
285
 
393
286
  ---
394
287
 
395
- ## paginate — OFFSET Pagination
288
+ ## Pagination
396
289
 
397
- Runs COUNT and DATA queries in parallel.
290
+ ### OFFSET pagination for standard lists
398
291
 
399
292
  ```ts
400
293
  const result = await userRepo.select({ isActive: true })
401
294
  .orderBy([{ column: 'createdAt', direction: 'DESC' }])
402
295
  .paginate({ page: 1, pageSize: 20 });
403
296
 
404
- // result shape
405
297
  // {
406
- // data: User[], // Current page data
407
- // count: 150, // Total matching rows
298
+ // data: User[],
299
+ // count: 150, total matching rows (COUNT query runs automatically)
408
300
  // page: 1,
409
301
  // pageSize: 20,
410
- // nextAction: true, // Next page exists
411
- // previousAction: false, // Previous page exists
302
+ // nextAction: true, has next page
303
+ // previousAction: false, no previous page
412
304
  // }
413
305
  ```
414
306
 
415
- > For tables with millions of rows, use `cursorPaginate()` instead.
307
+ ### Cursor pagination for massive tables
416
308
 
417
- ---
418
-
419
- ## cursorPaginate — Cursor-based Pagination (Large Data)
420
-
421
- Uses `WHERE id > last_id` instead of OFFSET scanning.
422
- Assigning an indexed column as `cursorColumn` ensures consistent speed even with tens of millions of rows.
309
+ OFFSET gets slower with every page. Cursor pagination doesn't.
310
+ `WHERE id > last_id` scans no extra rows, regardless of how deep you are.
423
311
 
424
312
  ```ts
425
- // First page
313
+ // Page 1
426
314
  const p1 = await userRepo.select({ isActive: true })
427
315
  .cursorPaginate({ pageSize: 20, cursorColumn: 'id' });
316
+ // → { data: [...], nextCursor: 'eyJpZCI6MjB9', pageSize: 20, hasNext: true }
428
317
 
429
- // p1 = { data: [...], nextCursor: 'xxx', pageSize: 20, hasNext: true }
430
-
431
- // Next page
432
- if (p1.hasNext) {
433
- const p2 = await userRepo.select({ isActive: true })
434
- .cursorPaginate({ pageSize: 20, cursorColumn: 'id', cursor: p1.nextCursor });
435
- }
318
+ // Page 2 pass the cursor
319
+ const p2 = await userRepo.select({ isActive: true })
320
+ .cursorPaginate({ pageSize: 20, cursorColumn: 'id', cursor: p1.nextCursor });
436
321
 
437
- // Descending cursor (createdAt DESC)
438
- const result = await userRepo.select()
322
+ // Descending (newest first)
323
+ const latest = await userRepo.select()
439
324
  .cursorPaginate({ pageSize: 20, cursorColumn: 'createdAt', direction: 'desc' });
440
325
  ```
441
326
 
442
- | `paginate` | `cursorPaginate` |
443
- |---|---|
444
- | Provides total count | No total count |
445
- | Navigate by page number | Next / previous only |
446
- | Slows down on large tables | Consistent speed always |
327
+ | | `paginate` | `cursorPaginate` |
328
+ |---|---|---|
329
+ | Total count | Yes | ❌ No |
330
+ | Page number navigation | ✅ Yes | Next/Prev only |
331
+ | Performance at row 1,000,000 | ❌ Slow | Constant speed |
332
+ | Best for | Admin tables, standard lists | Feeds, logs, large exports |
447
333
 
448
334
  ---
449
335
 
450
- ## forEach Batch Processing
336
+ ## Large Data Processing
337
+
338
+ ### Batch processing (forEach)
451
339
 
452
- Processes data in chunks without loading everything into memory.
453
- Ideal for large-scale ETL, bulk email sending, and data migration.
340
+ Load 10 million rows without crashing your server. Processes in chunks, never holds everything in memory.
454
341
 
455
342
  ```ts
343
+ // Send email to every active user — without loading all users at once
456
344
  await userRepo.select({ isActive: true })
457
345
  .orderBy([{ column: 'id', direction: 'ASC' }])
458
346
  .forEach(async (batch) => {
459
- // batch: User[] (default 500 rows per chunk)
460
- await sendEmailBatch(batch);
347
+ await sendEmailBatch(batch); // batch: User[] (200 rows at a time)
461
348
  }, { batchSize: 200 });
462
349
  ```
463
350
 
464
- ---
351
+ ### Streaming (AsyncGenerator)
465
352
 
466
- ## stream Streaming (AsyncGenerator)
467
-
468
- Iterates rows one by one with `for await...of`.
469
- Internally fetches in batches to keep memory usage low.
353
+ Row-by-row processing with `for await...of`. Perfect for real-time pipelines.
470
354
 
471
355
  ```ts
472
- // Direct for await...of (Symbol.asyncIterator supported)
473
356
  for await (const user of userRepo.select({ isActive: true })) {
474
- await processRow(user);
357
+ await processRow(user); // one row at a time, low memory usage
475
358
  }
476
359
 
477
- // With custom batch size
360
+ // Custom batch size for internal fetching
478
361
  for await (const user of userRepo.select().stream({ batchSize: 1000 })) {
479
- await processRow(user);
362
+ await writeToFile(user);
480
363
  }
481
364
  ```
482
365
 
483
- ---
366
+ ### EXPLAIN — query plan analysis
367
+
368
+ ```ts
369
+ // Check if your index is being used
370
+ const plan = await userRepo.select({ isActive: true })
371
+ .orderBy([{ column: 'createdAt', direction: 'DESC' }])
372
+ .explain(true); // true = EXPLAIN ANALYZE (actually runs the query)
484
373
 
485
- ## raw — Raw SQL Execution
374
+ console.log(plan);
375
+ // Index Scan using users_created_at_idx on users ...
376
+ ```
377
+
378
+ ---
486
379
 
487
- Write SQL directly when complex queries are needed.
488
- Result column names are automatically converted from `snake_case` to `camelCase`.
380
+ ## Aggregate Functions
489
381
 
490
382
  ```ts
491
- // repo.raw()
492
- const users = await userRepo.raw<UserRow>(
493
- 'SELECT * FROM users WHERE first_name ILIKE $1 ORDER BY created_at DESC',
494
- ['%john%'],
495
- );
383
+ // Single aggregation
384
+ const result = await userRepo.select().calculate([{ fn: 'COUNT', alias: 'count' }]);
385
+ const total = parseInt(String(result.count), 10); // 1042
496
386
 
497
- // QueryBuilder.raw() standalone, no repository needed
498
- import { QueryBuilder } from 'reltype';
387
+ // Multiple aggregations with filter
388
+ const stats = await userRepo.select({ isActive: true })
389
+ .calculate([
390
+ { fn: 'COUNT', alias: 'total' },
391
+ { fn: 'AVG', column: 'score', alias: 'avgScore' },
392
+ { fn: 'MAX', column: 'score', alias: 'maxScore' },
393
+ ]);
394
+ // → { total: '850', avgScore: '72.4', maxScore: '100' }
395
+ ```
499
396
 
500
- const rows = await QueryBuilder.raw(
501
- `SELECT u.id, u.email, COUNT(o.id) AS order_count
397
+ ---
398
+
399
+ ## Raw SQL
400
+
401
+ When the query builder isn't enough, drop into raw SQL. You still get camelCase conversion.
402
+
403
+ ```ts
404
+ // Via repository
405
+ const users = await userRepo.raw<{ id: number; orderCount: number }>(
406
+ `SELECT u.id, COUNT(o.id) AS order_count
502
407
  FROM users u
503
408
  LEFT JOIN orders o ON u.id = o.user_id
504
409
  WHERE u.is_active = $1
505
- GROUP BY u.id, u.email`,
410
+ GROUP BY u.id`,
506
411
  [true],
507
412
  );
413
+ // → [{ id: 1, orderCount: 5 }, ...] ← order_count → orderCount automatically
414
+
415
+ // Standalone (no repository)
416
+ import { QueryBuilder } from 'reltype';
417
+
418
+ const rows = await QueryBuilder.raw(
419
+ 'SELECT * FROM users WHERE first_name ILIKE $1',
420
+ ['%john%'],
421
+ );
508
422
  ```
509
423
 
510
424
  ---
511
425
 
512
- ## explain — Query Plan Analysis
426
+ ## CRUD Methods
513
427
 
514
- Inspect index usage and identify performance bottlenecks.
428
+ ### create
515
429
 
516
430
  ```ts
517
- // EXPLAIN
518
- const plan = await userRepo.select({ isActive: true }).explain();
519
- console.log(plan);
431
+ const user = await userRepo.create({
432
+ firstName: 'Alice',
433
+ email: 'alice@example.com',
434
+ // isActive, createdAt → optional (DB handles defaults)
435
+ });
436
+ // → User (full row returned via RETURNING *)
437
+ ```
520
438
 
521
- // EXPLAIN ANALYZE (includes actual execution statistics)
522
- const plan = await userRepo.select({ isActive: true })
523
- .orderBy([{ column: 'createdAt', direction: 'DESC' }])
524
- .explain(true);
439
+ ### update
440
+
441
+ ```ts
442
+ // Only updates the fields you pass
443
+ const updated = await userRepo.update(1, {
444
+ firstName: 'Alicia',
445
+ isActive: true,
446
+ });
447
+ // → User | null (null if ID not found)
525
448
  ```
526
449
 
527
- ---
450
+ ### delete
528
451
 
529
- ## toSQL — Preview SQL (Debugging)
452
+ ```ts
453
+ const ok = await userRepo.delete(1);
454
+ // → true if deleted, false if not found
455
+ ```
530
456
 
531
- Returns the generated SQL and params without executing the query.
457
+ ### upsert
532
458
 
533
459
  ```ts
534
- const { sql, params } = userRepo.select({ isActive: true })
535
- .orderBy([{ column: 'createdAt', direction: 'DESC' }])
536
- .limit(20)
537
- .toSQL();
460
+ // Conflict on primary key (default)
461
+ await userRepo.upsert({ id: 1, firstName: 'Bob', email: 'bob@example.com' });
538
462
 
539
- console.log(sql);
540
- // SELECT * FROM users WHERE is_active = $1 ORDER BY created_at DESC LIMIT $2
541
- console.log(params);
542
- // [ true, 20 ]
463
+ // Conflict on another unique column
464
+ await userRepo.upsert(
465
+ { firstName: 'Bob', email: 'bob@example.com' },
466
+ 'email', // snake_case column name
467
+ );
468
+ ```
469
+
470
+ ### bulkCreate
471
+
472
+ ```ts
473
+ const users = await userRepo.bulkCreate([
474
+ { firstName: 'Alice', email: 'alice@example.com' },
475
+ { firstName: 'Bob', email: 'bob@example.com' },
476
+ { firstName: 'Carol', email: 'carol@example.com' },
477
+ ]);
478
+ // → User[] (single INSERT query, RETURNING *)
543
479
  ```
544
480
 
545
481
  ---
546
482
 
547
- ## hooks — Query Lifecycle Hooks
483
+ ## Lifecycle Hooks
548
484
 
549
- ### Per-query Hooks
485
+ Monitor every query, integrate APM, or log slow queries — without touching your business logic.
486
+
487
+ ### Per-query hooks
550
488
 
551
489
  ```ts
552
490
  const users = await userRepo.select({ isActive: true })
553
491
  .hooks({
554
- beforeExec: ({ sql, params }) => logger.debug('About to run SQL:', sql),
555
- afterExec: ({ rows, elapsed }) => metrics.record('db.query.duration', elapsed),
556
- onError: ({ err, sql }) => alerting.send({ err, sql }),
492
+ beforeExec: ({ sql, params }) => {
493
+ console.log('[SQL]', sql);
494
+ },
495
+ afterExec: ({ rows, elapsed }) => {
496
+ if (elapsed > 500) console.warn('Slow query:', elapsed, 'ms');
497
+ metrics.record('db.query.duration', elapsed);
498
+ },
499
+ onError: ({ err, sql }) => {
500
+ alerting.send({ message: err.message, sql });
501
+ },
557
502
  })
558
503
  .paginate({ page: 1, pageSize: 20 });
559
504
  ```
560
505
 
561
- ### Repository-level Global Hooks
506
+ ### Repository-level global hooks
562
507
 
563
- Automatically applied to all `select()` builders on this repository.
508
+ Set once, applied to every `select()` on this repository automatically.
564
509
 
565
510
  ```ts
566
511
  userRepo.useHooks({
567
512
  beforeExec: ({ sql }) => logger.debug('SQL:', sql),
568
513
  afterExec: ({ elapsed }) => metrics.histogram('db.latency', elapsed),
569
- onError: ({ err }) => logger.error('DB error', { message: err.message }),
514
+ onError: ({ err }) => logger.error('DB error', { kind: err.kind }),
570
515
  });
571
-
572
- // All subsequent select() calls will use the hooks
573
- const users = await userRepo.select({ isActive: true }).exec();
574
516
  ```
575
517
 
576
518
  ---
577
519
 
578
- ## Static CRUD (findAll / findById / findOne)
579
-
580
- Use static methods for simple queries.
581
-
582
- ```ts
583
- // Fetch all
584
- const users = await userRepo.findAll();
585
-
586
- // With filter, sort, and pagination
587
- const users = await userRepo.findAll({
588
- where: { isActive: true },
589
- orderBy: [{ col: 'createdAt', dir: 'DESC' }],
590
- limit: 10,
591
- offset: 0,
592
- });
520
+ ## Error Handling
593
521
 
594
- // Single row by PK
595
- const user = await userRepo.findById(1); // User | null
522
+ ### DbError structured PostgreSQL error classification
596
523
 
597
- // Single row by condition (equality only)
598
- const user = await userRepo.findOne({ email: 'john@example.com' }); // User | null
599
- ```
524
+ Every DB error is automatically wrapped in a `DbError`. It separates what's safe to show users from what stays in your logs.
600
525
 
601
- > For operators like `LIKE`, `IN`, or `OR`, use `repo.select()` instead.
526
+ ```ts
527
+ import { DbError } from 'reltype';
602
528
 
603
- ---
529
+ try {
530
+ await userRepo.create({ firstName: 'Alice', email: 'alice@example.com' });
531
+ } catch (err) {
532
+ if (err instanceof DbError) {
533
+ // ✅ Safe to send to the client
534
+ res.status(409).json(err.toUserPayload());
535
+ // → { error: 'A duplicate value already exists.', kind: 'uniqueViolation', isRetryable: false }
604
536
 
605
- ## Column Types
537
+ // 🔒 Internal details — never expose these
538
+ logger.error('db error', err.toLogContext());
539
+ // → { pgCode: '23505', table: 'users', constraint: 'users_email_key', detail: '...' }
606
540
 
607
- | Method | PostgreSQL Type | TypeScript Type |
608
- |---|---|---|
609
- | `col.serial()` | `SERIAL` | `number` |
610
- | `col.integer()` | `INTEGER` | `number` |
611
- | `col.bigint()` | `BIGINT` | `bigint` |
612
- | `col.numeric()` | `NUMERIC` | `number` |
613
- | `col.varchar(n?)` | `VARCHAR(n)` | `string` |
614
- | `col.text()` | `TEXT` | `string` |
615
- | `col.boolean()` | `BOOLEAN` | `boolean` |
616
- | `col.timestamp()` | `TIMESTAMP` | `Date` |
617
- | `col.timestamptz()` | `TIMESTAMPTZ` | `Date` |
618
- | `col.date()` | `DATE` | `Date` |
619
- | `col.uuid()` | `UUID` | `string` |
620
- | `col.jsonb<T>()` | `JSONB` | `T` (default `unknown`) |
541
+ // Retry logic for transient errors
542
+ if (err.isRetryable) await retry(operation);
543
+ }
544
+ }
545
+ ```
621
546
 
622
- ### Column Modifiers
547
+ ### Express integration example
623
548
 
624
549
  ```ts
625
- col.text().notNull() // NOT NULL (default state)
626
- col.text().nullable() // Allow NULL, optional on INSERT
627
- col.integer().primaryKey() // PRIMARY KEY, optional on INSERT
628
- col.boolean().default() // DB DEFAULT, optional on INSERT
629
- col.timestamptz().defaultNow() // DEFAULT NOW(), optional on INSERT
550
+ app.post('/users', async (req, res) => {
551
+ try {
552
+ const user = await userRepo.create(req.body);
553
+ res.status(201).json(user);
554
+ } catch (err) {
555
+ if (err instanceof DbError) {
556
+ const status =
557
+ err.kind === 'uniqueViolation' ? 409 :
558
+ err.kind === 'notNullViolation' ? 400 :
559
+ err.kind === 'foreignKeyViolation' ? 422 :
560
+ err.isRetryable ? 503 : 500;
561
+ res.status(status).json(err.toUserPayload());
562
+ } else {
563
+ res.status(500).json({ error: 'Unexpected error.' });
564
+ }
565
+ }
566
+ });
630
567
  ```
631
568
 
569
+ ### Error kind reference
570
+
571
+ | Kind | PostgreSQL Code | Description | isRetryable |
572
+ |---|---|---|---|
573
+ | `uniqueViolation` | 23505 | UNIQUE constraint violated | false |
574
+ | `foreignKeyViolation` | 23503 | FK constraint violated | false |
575
+ | `notNullViolation` | 23502 | NOT NULL constraint violated | false |
576
+ | `checkViolation` | 23514 | CHECK constraint violated | false |
577
+ | `deadlock` | 40P01 | Deadlock detected | **true** |
578
+ | `serializationFailure` | 40001 | Serialization failure | **true** |
579
+ | `connectionFailed` | 08xxx | Connection failure | **true** |
580
+ | `tooManyConnections` | 53300 | Pool exhausted | **true** |
581
+ | `queryTimeout` | 57014 | Query timed out | false |
582
+ | `undefinedTable` | 42P01 | Table not found | false |
583
+ | `undefinedColumn` | 42703 | Column not found | false |
584
+ | `invalidInput` | 22xxx | Invalid data format | false |
585
+ | `unknown` | other | Unclassified error | false |
586
+
632
587
  ---
633
588
 
634
589
  ## Transaction
@@ -636,12 +591,13 @@ col.timestamptz().defaultNow() // DEFAULT NOW(), optional on INSERT
636
591
  ```ts
637
592
  import { runInTx } from 'reltype';
638
593
 
639
- const result = await runInTx(async (client) => {
640
- await userRepo.create({ firstName: 'Alice', email: 'alice@example.com' });
641
- await userRepo.create({ firstName: 'Bob', email: 'bob@example.com' });
642
- return 'done';
594
+ await runInTx(async (client) => {
595
+ // Both operations run in the same transaction
596
+ const user = await userRepo.create({ firstName: 'Alice', email: 'alice@example.com' });
597
+ const order = await orderRepo.create({ userId: user.id, total: 9900 });
598
+ return { user, order };
643
599
  });
644
- // Automatically rolls back if any operation fails
600
+ // Automatically ROLLBACK if any operation throws
645
601
  ```
646
602
 
647
603
  ---
@@ -649,119 +605,109 @@ const result = await runInTx(async (client) => {
649
605
  ## Connection Pool
650
606
 
651
607
  ```ts
652
- import { getPool, withClient, closePool } from 'reltype';
653
-
654
- // Direct pool access
655
- const pool = getPool();
656
-
657
- // Borrow a client and run a raw query
658
- const rows = await withClient(async (client) => {
659
- const result = await client.query('SELECT NOW()');
660
- return result.rows;
661
- });
608
+ import { getPool, getPoolStatus, checkPoolHealth, closePool } from 'reltype';
662
609
 
663
- // On application shutdown
664
- await closePool();
665
- ```
666
-
667
- ---
668
-
669
- ## Raw Query Builders
670
-
671
- Build queries directly without a repository.
610
+ // Real-time pool metrics
611
+ const status = getPoolStatus();
612
+ // {
613
+ // isInitialized: true,
614
+ // totalCount: 8, ← total connections open
615
+ // idleCount: 3, ← ready to use
616
+ // waitingCount: 0, ← requests waiting (0 = healthy)
617
+ // isHealthy: true
618
+ // }
672
619
 
673
- ```ts
674
- import { buildSelect, buildInsert, buildUpdate, buildDelete, buildUpsert, buildBulkInsert, withClient } from 'reltype';
620
+ // Ping the DB server (SELECT 1)
621
+ const alive = await checkPoolHealth(); // boolean
675
622
 
676
- // SELECT
677
- const { sql, params } = buildSelect('users', {
678
- where: { isActive: true },
679
- orderBy: [{ col: 'createdAt', dir: 'DESC' }],
680
- limit: 5,
623
+ // Graceful shutdown
624
+ process.on('SIGTERM', async () => {
625
+ await closePool();
626
+ process.exit(0);
681
627
  });
628
+ ```
682
629
 
683
- // INSERT
684
- const built = buildInsert('users', { firstName: 'John', email: 'john@example.com' });
685
-
686
- // UPDATE
687
- const built = buildUpdate('users', { firstName: 'Jane' }, { id: 1 });
688
-
689
- // DELETE
690
- const built = buildDelete('users', { id: 1 });
691
-
692
- // UPSERT
693
- const built = buildUpsert('users', { id: 1, firstName: 'John', email: 'john@example.com' }, 'id');
630
+ ### Recommended pool configuration
694
631
 
695
- // BULK INSERT
696
- const built = buildBulkInsert('users', [
697
- { firstName: 'Alice', email: 'alice@example.com' },
698
- { firstName: 'Bob', email: 'bob@example.com' },
699
- ]);
700
-
701
- // Execute
702
- await withClient(async (client) => {
703
- const result = await client.query(sql, params);
704
- return result.rows;
705
- });
632
+ ```env
633
+ DB_MAX=10 # Max connections (match your Postgres max_connections)
634
+ DB_CONNECTION_TIMEOUT=3000 # ⚠️ Must set otherwise exhausted pool waits forever
635
+ DB_IDLE_TIMEOUT=30000 # Release idle connections after 30s
636
+ DB_STATEMENT_TIMEOUT=10000 # Kill runaway queries after 10s
706
637
  ```
707
638
 
708
- > All query builders automatically convert camelCase keys to snake_case column names.
639
+ > If `DB_CONNECTION_TIMEOUT` is not set, reltype will warn on startup. An exhausted pool will hang indefinitely without this value.
709
640
 
710
641
  ---
711
642
 
712
- ## Case Conversion Utilities
643
+ ## PostgreSQL Schema Support
713
644
 
714
645
  ```ts
715
- import { toCamel, toSnake, keysToCamel, keysToSnake } from 'reltype';
646
+ // Dot notation
647
+ const logsTable = defineTable('audit.activity_logs', { ... });
716
648
 
717
- toCamel('first_name') // 'firstName'
718
- toSnake('firstName') // 'first_name'
649
+ // Explicit option
650
+ const usersTable = defineTable('users', { ... }, { schema: 'auth' });
719
651
 
720
- keysToCamel({ first_name: 'John', created_at: new Date() })
721
- // { firstName: 'John', createdAt: Date }
722
-
723
- keysToSnake({ firstName: 'John', createdAt: new Date() })
724
- // { first_name: 'John', created_at: Date }
652
+ // → SQL: INSERT INTO "auth"."users" ...
653
+ // Identifiers are always quoted to avoid reserved word conflicts
725
654
  ```
726
655
 
727
656
  ---
728
657
 
729
- ## Logger
658
+ ## Column Types
730
659
 
731
- ```ts
732
- import { Logger } from 'reltype';
660
+ | Method | PostgreSQL Type | TypeScript Type |
661
+ |---|---|---|
662
+ | `col.serial()` | `SERIAL` | `number` |
663
+ | `col.integer()` | `INTEGER` | `number` |
664
+ | `col.bigint()` | `BIGINT` | `bigint` |
665
+ | `col.numeric()` | `NUMERIC` | `number` |
666
+ | `col.varchar(n?)` | `VARCHAR(n)` | `string` |
667
+ | `col.text()` | `TEXT` | `string` |
668
+ | `col.boolean()` | `BOOLEAN` | `boolean` |
669
+ | `col.timestamp()` | `TIMESTAMP` | `Date` |
670
+ | `col.timestamptz()` | `TIMESTAMPTZ` | `Date` |
671
+ | `col.date()` | `DATE` | `Date` |
672
+ | `col.uuid()` | `UUID` | `string` |
673
+ | `col.jsonb<T>()` | `JSONB` | `T` (default `unknown`) |
733
674
 
734
- const logger = Logger.fromEnv(process.env as Record<string, string | undefined>, {
735
- prefix: '[MyApp]',
736
- level: 'info',
737
- });
675
+ ### Modifiers
738
676
 
739
- logger.debug('debug message');
740
- logger.info('info message');
741
- logger.warn('warn message');
742
- logger.error('error message', new Error('oops'));
677
+ ```ts
678
+ col.text().notNull() // required on INSERT
679
+ col.text().nullable() // optional on INSERT, allows NULL
680
+ col.integer().primaryKey() // optional on INSERT, serial/auto
681
+ col.boolean().default() // optional on INSERT (DB has a DEFAULT)
682
+ col.timestamptz().defaultNow() // optional on INSERT (DEFAULT NOW())
743
683
  ```
744
684
 
745
- Enable with environment variables: `LOGGER=true`, `LOG_LEVEL=debug`.
746
-
747
685
  ---
748
686
 
749
687
  ## Extending BaseRepo
750
688
 
751
- Extend `BaseRepo` to add custom methods.
689
+ Add domain-specific methods to your repository:
752
690
 
753
691
  ```ts
754
692
  import { BaseRepo, InferRow } from 'reltype';
755
693
  import { usersTable } from './schema';
756
694
 
757
695
  class UserRepo extends BaseRepo<typeof usersTable> {
758
- async findActiveUsers(): Promise<InferRow<typeof usersTable>[]> {
696
+ findActive(): Promise<InferRow<typeof usersTable>[]> {
759
697
  return this.findAll({ where: { isActive: true } });
760
698
  }
761
699
 
762
- async findByEmail(email: string): Promise<InferRow<typeof usersTable> | null> {
700
+ findByEmail(email: string): Promise<InferRow<typeof usersTable> | null> {
763
701
  return this.findOne({ email });
764
702
  }
703
+
704
+ async search(query: string, page: number) {
705
+ return this.select()
706
+ .or({ firstName: { operator: 'ILIKE', value: `%${query}%` } })
707
+ .or({ email: { operator: 'ILIKE', value: `%${query}%` } })
708
+ .orderBy([{ column: 'createdAt', direction: 'DESC' }])
709
+ .paginate({ page, pageSize: 20 });
710
+ }
765
711
  }
766
712
 
767
713
  export const userRepo = new UserRepo(usersTable);
@@ -769,186 +715,128 @@ export const userRepo = new UserRepo(usersTable);
769
715
 
770
716
  ---
771
717
 
772
- ## Error Handling
773
-
774
- ### DbError — PostgreSQL Error Classification
775
-
776
- All DB errors are automatically converted to `DbError`.
777
- `DbError` separates internal log details from user-facing messages.
778
-
779
- ```ts
780
- import { DbError } from 'reltype';
781
-
782
- try {
783
- await userRepo.create({ firstName: 'Alice', email: 'alice@example.com' });
784
- } catch (err) {
785
- if (err instanceof DbError) {
786
- // Safe to expose to users
787
- console.log(err.toUserPayload());
788
- // { error: 'A duplicate value already exists.', kind: 'uniqueViolation', isRetryable: false }
718
+ ## Logging
789
719
 
790
- // Internal logging details
791
- console.log(err.toLogContext());
792
- // { pgCode: '23505', kind: 'uniqueViolation', table: 'users', constraint: '...', ... }
793
-
794
- // Check if retryable
795
- if (err.isRetryable) {
796
- // retry logic
797
- }
798
- }
799
- }
720
+ ```env
721
+ LOGGER=true # Enable logging
722
+ LOG_LEVEL=debug # debug | info | log | warn | error
723
+ LOG_FORMAT=json # text (dev, colored) | json (prod, log collectors)
800
724
  ```
801
725
 
802
- ### Example with Express
803
-
804
- ```ts
805
- app.post('/users', async (req, res) => {
806
- try {
807
- const user = await userRepo.create(req.body);
808
- res.status(201).json(user);
809
- } catch (err) {
810
- if (err instanceof DbError) {
811
- const status = err.kind === 'uniqueViolation' ? 409
812
- : err.kind === 'notNullViolation' ? 400
813
- : err.isRetryable ? 503
814
- : 500;
815
- res.status(status).json(err.toUserPayload());
816
- } else {
817
- res.status(500).json({ error: 'An unexpected error occurred.' });
818
- }
819
- }
820
- });
726
+ **Development output (`text` format):**
727
+ ```
728
+ 2026-01-01T00:00:00.000Z [Pool] INFO Pool created { max: 10, connectionTimeoutMillis: 3000 }
729
+ 2026-01-01T00:00:00.000Z [Repo] DEBUG SQL: SELECT * FROM users WHERE is_active = $1 [ true ]
730
+ 2026-01-01T00:00:00.000Z [Repo] DEBUG Done (8ms) rowCount=42
821
731
  ```
822
732
 
823
- ### DbErrorKind Reference
733
+ **Production output (`json` format, for Datadog / CloudWatch / Grafana Loki):**
734
+ ```json
735
+ {"ts":"2026-01-01T00:00:00.000Z","level":"INFO","prefix":"[Pool]","msg":"Pool created","meta":[{"max":10}]}
736
+ {"ts":"2026-01-01T00:00:00.000Z","level":"ERROR","prefix":"[Repo]","msg":"Query failed [users]","meta":[{"pgCode":"23505","kind":"uniqueViolation","constraint":"users_email_key"}]}
737
+ ```
824
738
 
825
- | kind | PostgreSQL SQLSTATE | Description | isRetryable |
826
- |---|---|---|---|
827
- | `uniqueViolation` | 23505 | UNIQUE constraint violation | false |
828
- | `foreignKeyViolation` | 23503 | Foreign key constraint violation | false |
829
- | `notNullViolation` | 23502 | NOT NULL constraint violation | false |
830
- | `checkViolation` | 23514 | CHECK constraint violation | false |
831
- | `deadlock` | 40P01 | Deadlock detected | **true** |
832
- | `serializationFailure` | 40001 | Serialization failure | **true** |
833
- | `connectionFailed` | 08xxx | Connection failure | **true** |
834
- | `tooManyConnections` | 53300 | Too many connections | **true** |
835
- | `queryTimeout` | 57014 | Query timeout | false |
836
- | `undefinedTable` | 42P01 | Table does not exist | false |
837
- | `undefinedColumn` | 42703 | Column does not exist | false |
838
- | `invalidInput` | 22xxx | Invalid input format | false |
839
- | `unknown` | other | Unclassified error | false |
739
+ | Level | Prefix | When |
740
+ |---|---|---|
741
+ | INFO | [Pool] | Pool created / closed |
742
+ | WARN | [Pool] | No connectionTimeoutMillis / max connections reached |
743
+ | ERROR | [Pool] | Idle client error / connection acquisition failed |
744
+ | DEBUG | [Repo] | Every SQL + elapsed time |
745
+ | ERROR | [Repo] | Query failed (pgCode, kind, elapsed) |
746
+ | DEBUG | [Tx] | Transaction started / committed |
747
+ | WARN | [Tx] | Rollback |
748
+ | ERROR | [Tx] | Rollback failed |
840
749
 
841
750
  ---
842
751
 
843
- ## Pool Monitoring
844
-
845
- ```ts
846
- import { getPoolStatus, checkPoolHealth } from 'reltype';
752
+ ## All Environment Variables
847
753
 
848
- // Get current pool status
849
- const status = getPoolStatus();
850
- console.log(status);
851
- // {
852
- // totalCount: 5, // Total connections created
853
- // idleCount: 3, // Idle connections
854
- // waitingCount: 0, // Requests waiting for a connection
855
- // isHealthy: true // Pool is healthy
856
- // }
857
-
858
- // Health check against the DB server (SELECT 1)
859
- const isAlive = await checkPoolHealth();
860
- ```
861
-
862
- ### Preventing Too Many Connections
754
+ ```env
755
+ # ── Connection ────────────────────────────────────────────────────────────────
756
+ DB_CONNECTION_STRING= # postgresql://user:pass@host:5432/db (priority)
757
+ DB_HOST=127.0.0.1
758
+ DB_PORT=5432
759
+ DB_NAME=mydb
760
+ DB_USER=postgres
761
+ DB_PASSWORD=postgres
863
762
 
864
- Always configure pool size and timeouts in your `.env`.
763
+ # ── Pool ──────────────────────────────────────────────────────────────────────
764
+ DB_MAX=10 # Max pool size
765
+ DB_IDLE_TIMEOUT=30000 # Idle connection release (ms)
766
+ DB_CONNECTION_TIMEOUT=3000 # Max wait to acquire connection (ms) — ALWAYS SET THIS
767
+ DB_ALLOW_EXIT_ON_IDLE=false # Allow process exit when pool is empty
768
+ DB_STATEMENT_TIMEOUT=0 # Max statement execution time (ms, 0 = unlimited)
769
+ DB_QUERY_TIMEOUT=0 # Max query time (ms, 0 = unlimited)
770
+ DB_SSL=false # Enable SSL
771
+ DB_KEEP_ALIVE=true # TCP keep-alive
772
+ DB_KEEP_ALIVE_INITIAL_DELAY=10000 # Keep-alive initial delay (ms)
773
+ DB_APPLICATION_NAME=my-app # Name visible in pg_stat_activity
865
774
 
866
- ```env
867
- DB_MAX=10 # Max pool size (default: 10)
868
- DB_CONNECTION_TIMEOUT=3000 # Connection acquisition timeout in ms (infinite wait if not set — warning)
869
- DB_IDLE_TIMEOUT=30000 # Idle connection release time in ms
870
- DB_STATEMENT_TIMEOUT=10000 # Max SQL statement execution time in ms
775
+ # ── Logging ───────────────────────────────────────────────────────────────────
776
+ LOGGER=true
777
+ LOG_LEVEL=info # debug | info | log | warn | error
778
+ LOG_FORMAT=text # text | json
871
779
  ```
872
780
 
873
- > If `DB_CONNECTION_TIMEOUT` is not set, requests will wait indefinitely when the pool is exhausted.
874
- > Always configure this value.
875
-
876
781
  ---
877
782
 
878
- ## Log System
783
+ ## FAQ
879
784
 
880
- ### Format Configuration
785
+ **Q. Do I need to run migrations?**
786
+ No. reltype does not manage your database schema. Use your preferred migration tool (Flyway, Liquibase, `psql`, etc.). reltype only generates and executes SQL queries.
881
787
 
882
- ```env
883
- LOGGER=true # Enable logger
884
- LOG_LEVEL=debug # debug / info / log / warn / error
885
- LOG_FORMAT=json # text (default) / json (recommended for production)
886
- ```
788
+ **Q. Can I use it with an existing database?**
789
+ Yes. Define your `defineTable(...)` to match your existing columns. reltype reads from whatever is in Postgres.
887
790
 
888
- ### text format (development)
791
+ **Q. What if I have a very complex query?**
792
+ Use `repo.raw(sql, params)` or `QueryBuilder.raw(sql, params)` for full SQL control. You still get camelCase conversion on results.
889
793
 
890
- ```
891
- 2024-01-01T00:00:00.000Z [Pool] INFO Pool created { max: 10, ... }
892
- 2024-01-01T00:00:00.000Z [Repo] DEBUG SQL: SELECT * FROM users WHERE id = $1 [ 1 ]
893
- 2024-01-01T00:00:00.000Z [Repo] DEBUG Done (12ms) rowCount=1
894
- ```
794
+ **Q. Can I use this with NestJS / Fastify / Koa?**
795
+ Yes. reltype is framework-agnostic. It only depends on `pg`.
895
796
 
896
- ### json format (production / log aggregators)
797
+ **Q. Is it safe against SQL injection?**
798
+ All values in `where`, `create`, `update`, etc. are passed as parameterized queries (`$1`, `$2`, ...). Never string-interpolated. The only surface to be careful about is the `on` clause in `.join()` — always construct that from static strings in your code.
897
799
 
898
- ```json
899
- {"ts":"2024-01-01T00:00:00.000Z","level":"INFO","prefix":"[Pool]","msg":"Pool created","meta":[{"max":10}]}
900
- {"ts":"2024-01-01T00:00:00.000Z","level":"ERROR","prefix":"[Repo]","msg":"Query failed [users]","meta":[{"pgCode":"23505","kind":"uniqueViolation","constraint":"users_email_key"}]}
901
- ```
902
-
903
- ### Log Event Reference
904
-
905
- | Level | Prefix | Event |
906
- |---|---|---|
907
- | INFO | [Pool] | Pool created / Pool closed |
908
- | WARN | [Pool] | connectionTimeoutMillis not configured |
909
- | WARN | [Pool] | Max connections reached |
910
- | DEBUG | [Pool] | New connection / Connection removed |
911
- | ERROR | [Pool] | Idle client error / Client acquisition failed |
912
- | DEBUG | [Repo] | SQL executed + elapsed time |
913
- | ERROR | [Repo] | Query failed (pgCode, kind, elapsed included) |
914
- | DEBUG | [Tx] | Transaction started / committed |
915
- | WARN | [Tx] | Transaction rolled back |
916
- | ERROR | [Tx] | Rollback failed |
800
+ **Q. How is it different from Drizzle ORM?**
801
+ Both are TypeScript-first and lightweight. reltype's key advantages are automatic camelCase↔snake_case conversion (Drizzle requires manual column naming), built-in cursor pagination, streaming, and batch processing out of the box, and a structured `DbError` system with user-safe messages.
917
802
 
918
803
  ---
919
804
 
920
805
  ## Architecture
921
806
 
922
807
  ```
923
- src/
924
- ├── index.ts ← Public API entry point
925
- ├── configs/env.ts ← DB config parsing
808
+ reltype/
809
+ ├── index.ts ← Public API
810
+ ├── configs/env.ts ← DB config helper
926
811
  ├── utils/
927
- │ ├── logger.ts ← Logger class
928
- └── reader.ts Env parser, PostgresConfig
812
+ │ ├── logger.ts ← Logger (text/json format)
813
+ ├── dbError.ts DbError classification
814
+ │ └── reader.ts ← Env parser, PostgresConfig
929
815
  └── features/
930
- ├── schema/ ← defineTable, col, InferRow/Insert/Update
931
- ├── transform/ ← camelCase ↔ snake_case conversion
932
- ├── connection/ ← Pool management, Transaction
933
- ├── query/ SQL query builders (select/insert/update/delete/upsert/bulkInsert)
934
- └── repository/ ← BaseRepo, createRepo, IRepo
816
+ ├── schema/ ← defineTable, col, InferRow/Insert/Update
817
+ ├── transform/ ← camelCase ↔ snake_case
818
+ ├── connection/ ← Pool, withClient, runInTx
819
+ ├── query/ QueryBuilder, build* functions
820
+ └── repository/ ← BaseRepo, createRepo
935
821
  ```
936
822
 
937
823
  ---
938
824
 
939
825
  ## Contributing
940
826
 
941
- Bug reports, feature suggestions, and pull requests are all welcome.
942
- → [Issues](https://github.com/psh-suhyun/reltype/issues) · [Pull Requests](https://github.com/psh-suhyun/reltype/pulls)
827
+ Bug reports, feature ideas, and PRs are very welcome.
828
+
829
+ → [Open an Issue](https://github.com/psh-suhyun/reltype/issues)
830
+ → [Submit a PR](https://github.com/psh-suhyun/reltype/pulls)
943
831
 
944
832
  ---
945
833
 
946
834
  ## Changelog
947
835
 
948
- See [CHANGELOG.md](./CHANGELOG.md) for the full version history.
836
+ See [CHANGELOG.md](./CHANGELOG.md).
949
837
 
950
838
  ---
951
839
 
952
840
  ## License
953
841
 
954
- MIT
842
+ MIT © [psh-suhyun](https://github.com/psh-suhyun)