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.
- package/CHANGELOG.md +83 -3
- package/README.ko.md +517 -623
- package/README.md +511 -623
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,97 +6,160 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/reltype)
|
|
7
7
|
[](https://www.typescriptlang.org/)
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
> 한국어 문서 → [README.ko.md](./README.ko.md)
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
**The PostgreSQL query library that gets out of your way.**
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
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 TypeScript — define your table once, get fully-typed queries instantly.
|
|
19
15
|
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
>
|
|
74
|
+
> `pg` (node-postgres) is a peer dependency. Version 8.0.0+ required.
|
|
36
75
|
|
|
37
76
|
---
|
|
38
77
|
|
|
39
|
-
##
|
|
78
|
+
## 2-Minute Quick Start
|
|
40
79
|
|
|
41
|
-
|
|
80
|
+
### Step 1 — Environment Variables
|
|
42
81
|
|
|
43
82
|
```env
|
|
44
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
105
|
+
import { getPool } from 'reltype';
|
|
106
|
+
```
|
|
78
107
|
|
|
79
|
-
###
|
|
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(),
|
|
86
|
-
firstName: col.varchar(255).notNull(),
|
|
87
|
-
lastName: col.varchar(255).nullable(),
|
|
88
|
-
email: col.text().notNull(),
|
|
89
|
-
isActive: col.boolean().default(),
|
|
90
|
-
createdAt: col.timestamptz().defaultNow(),
|
|
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
|
-
|
|
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
|
-
// {
|
|
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
|
-
// {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
| Method | Return Type | Description |
|
|
200
|
+
| Method | Returns | Description |
|
|
147
201
|
|---|---|---|
|
|
148
|
-
| `create(data)` | `Promise<T>` | INSERT
|
|
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
|
|
154
|
-
| `selectOne(where)` | `Promise<T \| null>` | Fetch
|
|
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[]>` |
|
|
157
|
-
| `findById(id)` | `Promise<T \| null>` | Fetch
|
|
158
|
-
| `findOne(where)` | `Promise<T \| null>` | Fetch
|
|
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
|
-
##
|
|
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
|
-
###
|
|
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(
|
|
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({
|
|
229
|
+
.where({ createdAt: { operator: '>=', value: new Date('2024-01-01') } })
|
|
230
|
+
.where({ id: { operator: 'IN', value: [1, 2, 3] } });
|
|
280
231
|
|
|
281
|
-
//
|
|
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
|
-
###
|
|
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
|
-
|
|
328
|
-
const
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
Runs `COUNT`, `SUM`, `AVG`, `MIN`, `MAX` aggregations.
|
|
272
|
+
### Debug — Preview SQL before running
|
|
377
273
|
|
|
378
274
|
```ts
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
275
|
+
const { sql, params } = userRepo.select({ isActive: true })
|
|
276
|
+
.orderBy([{ column: 'createdAt', direction: 'DESC' }])
|
|
277
|
+
.limit(20)
|
|
278
|
+
.toSQL();
|
|
382
279
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
##
|
|
288
|
+
## Pagination
|
|
396
289
|
|
|
397
|
-
|
|
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[],
|
|
407
|
-
// count: 150,
|
|
298
|
+
// data: User[],
|
|
299
|
+
// count: 150, ← total matching rows (COUNT query runs automatically)
|
|
408
300
|
// page: 1,
|
|
409
301
|
// pageSize: 20,
|
|
410
|
-
// nextAction: true,
|
|
411
|
-
// previousAction: false,
|
|
302
|
+
// nextAction: true, ← has next page
|
|
303
|
+
// previousAction: false, ← no previous page
|
|
412
304
|
// }
|
|
413
305
|
```
|
|
414
306
|
|
|
415
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
430
|
-
|
|
431
|
-
|
|
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
|
|
438
|
-
const
|
|
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
|
-
|
|
|
445
|
-
|
|
|
446
|
-
|
|
|
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
|
-
##
|
|
336
|
+
## Large Data Processing
|
|
337
|
+
|
|
338
|
+
### Batch processing (forEach)
|
|
451
339
|
|
|
452
|
-
Processes
|
|
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[] (
|
|
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
|
-
|
|
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
|
-
//
|
|
360
|
+
// Custom batch size for internal fetching
|
|
478
361
|
for await (const user of userRepo.select().stream({ batchSize: 1000 })) {
|
|
479
|
-
await
|
|
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
|
-
|
|
374
|
+
console.log(plan);
|
|
375
|
+
// Index Scan using users_created_at_idx on users ...
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
---
|
|
486
379
|
|
|
487
|
-
|
|
488
|
-
Result column names are automatically converted from `snake_case` to `camelCase`.
|
|
380
|
+
## Aggregate Functions
|
|
489
381
|
|
|
490
382
|
```ts
|
|
491
|
-
//
|
|
492
|
-
const
|
|
493
|
-
|
|
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
|
-
//
|
|
498
|
-
|
|
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
|
-
|
|
501
|
-
|
|
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
|
|
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
|
-
##
|
|
426
|
+
## CRUD Methods
|
|
513
427
|
|
|
514
|
-
|
|
428
|
+
### create
|
|
515
429
|
|
|
516
430
|
```ts
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
452
|
+
```ts
|
|
453
|
+
const ok = await userRepo.delete(1);
|
|
454
|
+
// → true if deleted, false if not found
|
|
455
|
+
```
|
|
530
456
|
|
|
531
|
-
|
|
457
|
+
### upsert
|
|
532
458
|
|
|
533
459
|
```ts
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
//
|
|
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
|
-
##
|
|
483
|
+
## Lifecycle Hooks
|
|
548
484
|
|
|
549
|
-
|
|
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 }) =>
|
|
555
|
-
|
|
556
|
-
|
|
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
|
|
506
|
+
### Repository-level global hooks
|
|
562
507
|
|
|
563
|
-
|
|
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 })
|
|
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
|
-
##
|
|
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
|
-
|
|
595
|
-
const user = await userRepo.findById(1); // User | null
|
|
522
|
+
### DbError — structured PostgreSQL error classification
|
|
596
523
|
|
|
597
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
###
|
|
547
|
+
### Express integration example
|
|
623
548
|
|
|
624
549
|
```ts
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
await userRepo.create({ firstName: '
|
|
642
|
-
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
674
|
-
|
|
620
|
+
// Ping the DB server (SELECT 1)
|
|
621
|
+
const alive = await checkPoolHealth(); // → boolean
|
|
675
622
|
|
|
676
|
-
//
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
limit: 5,
|
|
623
|
+
// Graceful shutdown
|
|
624
|
+
process.on('SIGTERM', async () => {
|
|
625
|
+
await closePool();
|
|
626
|
+
process.exit(0);
|
|
681
627
|
});
|
|
628
|
+
```
|
|
682
629
|
|
|
683
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
>
|
|
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
|
-
##
|
|
643
|
+
## PostgreSQL Schema Support
|
|
713
644
|
|
|
714
645
|
```ts
|
|
715
|
-
|
|
646
|
+
// Dot notation
|
|
647
|
+
const logsTable = defineTable('audit.activity_logs', { ... });
|
|
716
648
|
|
|
717
|
-
|
|
718
|
-
|
|
649
|
+
// Explicit option
|
|
650
|
+
const usersTable = defineTable('users', { ... }, { schema: 'auth' });
|
|
719
651
|
|
|
720
|
-
|
|
721
|
-
//
|
|
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
|
-
##
|
|
658
|
+
## Column Types
|
|
730
659
|
|
|
731
|
-
|
|
732
|
-
|
|
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
|
-
|
|
735
|
-
prefix: '[MyApp]',
|
|
736
|
-
level: 'info',
|
|
737
|
-
});
|
|
675
|
+
### Modifiers
|
|
738
676
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
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
|
-
|
|
696
|
+
findActive(): Promise<InferRow<typeof usersTable>[]> {
|
|
759
697
|
return this.findAll({ where: { isActive: true } });
|
|
760
698
|
}
|
|
761
699
|
|
|
762
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
826
|
-
|
|
827
|
-
|
|
|
828
|
-
|
|
|
829
|
-
|
|
|
830
|
-
|
|
|
831
|
-
|
|
|
832
|
-
|
|
|
833
|
-
|
|
|
834
|
-
|
|
|
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
|
-
##
|
|
844
|
-
|
|
845
|
-
```ts
|
|
846
|
-
import { getPoolStatus, checkPoolHealth } from 'reltype';
|
|
752
|
+
## All Environment Variables
|
|
847
753
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
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
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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
|
-
##
|
|
783
|
+
## FAQ
|
|
879
784
|
|
|
880
|
-
|
|
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
|
-
|
|
883
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
899
|
-
|
|
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
|
-
|
|
924
|
-
├── index.ts
|
|
925
|
-
├── configs/env.ts
|
|
808
|
+
reltype/
|
|
809
|
+
├── index.ts ← Public API
|
|
810
|
+
├── configs/env.ts ← DB config helper
|
|
926
811
|
├── utils/
|
|
927
|
-
│ ├── logger.ts
|
|
928
|
-
│
|
|
812
|
+
│ ├── logger.ts ← Logger (text/json format)
|
|
813
|
+
│ ├── dbError.ts ← DbError classification
|
|
814
|
+
│ └── reader.ts ← Env parser, PostgresConfig
|
|
929
815
|
└── features/
|
|
930
|
-
├── schema/
|
|
931
|
-
├── transform/
|
|
932
|
-
├── connection/
|
|
933
|
-
├── query/
|
|
934
|
-
└── repository/
|
|
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
|
|
942
|
-
|
|
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)
|
|
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)
|