crudora 0.1.0 → 0.2.0
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/LICENSE +21 -21
- package/README.md +554 -328
- package/dist/cli.js +72 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/crudora.d.ts +34 -9
- package/dist/core/crudora.d.ts.map +1 -1
- package/dist/core/crudora.js +254 -105
- package/dist/core/crudora.js.map +1 -1
- package/dist/core/crudoraServer.d.ts +64 -10
- package/dist/core/crudoraServer.d.ts.map +1 -1
- package/dist/core/crudoraServer.js +138 -19
- package/dist/core/crudoraServer.js.map +1 -1
- package/dist/core/drizzleTableBuilder.d.ts +6 -0
- package/dist/core/drizzleTableBuilder.d.ts.map +1 -0
- package/dist/core/drizzleTableBuilder.js +175 -0
- package/dist/core/drizzleTableBuilder.js.map +1 -0
- package/dist/core/model.d.ts +28 -9
- package/dist/core/model.d.ts.map +1 -1
- package/dist/core/model.js +33 -70
- package/dist/core/model.js.map +1 -1
- package/dist/core/repository.d.ts +98 -14
- package/dist/core/repository.d.ts.map +1 -1
- package/dist/core/repository.js +561 -103
- package/dist/core/repository.js.map +1 -1
- package/dist/core/schemaGenerator.d.ts +3 -3
- package/dist/core/schemaGenerator.d.ts.map +1 -1
- package/dist/core/schemaGenerator.js +237 -32
- package/dist/core/schemaGenerator.js.map +1 -1
- package/dist/decorators/model.d.ts +56 -1
- package/dist/decorators/model.d.ts.map +1 -1
- package/dist/decorators/model.js +92 -0
- package/dist/decorators/model.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/dist/scripts/copy-assets.js +47 -47
- package/dist/scripts/postinstall.js +172 -136
- package/dist/templates/.env.example +13 -9
- package/dist/templates/drizzle.config.ts +10 -0
- package/dist/templates/schema.ts +23 -0
- package/dist/types/logger.type.d.ts +7 -0
- package/dist/types/logger.type.d.ts.map +1 -0
- package/dist/types/logger.type.js +3 -0
- package/dist/types/logger.type.js.map +1 -0
- package/dist/types/model.type.d.ts +30 -5
- package/dist/types/model.type.d.ts.map +1 -1
- package/dist/utils/validation.d.ts.map +1 -1
- package/dist/utils/validation.js +91 -19
- package/dist/utils/validation.js.map +1 -1
- package/package.json +108 -94
- package/scripts/copy-assets.js +47 -47
- package/scripts/postinstall.js +172 -136
- package/templates/.env.example +13 -9
- package/templates/drizzle.config.ts +10 -0
- package/templates/schema.ts +23 -0
- package/dist/templates/schema.prisma +0 -22
- package/templates/schema.prisma +0 -22
package/README.md
CHANGED
|
@@ -1,328 +1,554 @@
|
|
|
1
|
-
# Crudora
|
|
2
|
-
|
|
3
|
-
Automatic CRUD API generator for TypeScript with
|
|
4
|
-
|
|
5
|
-
[](https://badge.fury.io/js/crudora)
|
|
6
|
-
[](https://opensource.org/licenses/MIT)
|
|
7
|
-
[](https://www.typescriptlang.org/)
|
|
8
|
-
|
|
9
|
-
##
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
static
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
1
|
+
# Crudora
|
|
2
|
+
|
|
3
|
+
Automatic CRUD API generator for TypeScript with Drizzle ORM — build REST APIs in minutes, not hours.
|
|
4
|
+
|
|
5
|
+
[](https://badge.fury.io/js/crudora)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://www.typescriptlang.org/)
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Zero Configuration** — generate CRUD APIs instantly from model classes
|
|
12
|
+
- **Drizzle ORM** — type-safe queries with Drizzle under the hood
|
|
13
|
+
- **Multiple Schema Support** — `pgSchema` / `mysqlSchema` per model via `static schema`
|
|
14
|
+
- **`@Field()` Decorators** — define columns with types, constraints, and Drizzle table auto-generation
|
|
15
|
+
- **Rich Field Types** — `uuid`, `string`, `text`, `integer`, `number`, `boolean`, `date`, `decimal`, `json`, `enum`, `bigint`, `serial`, `array`
|
|
16
|
+
- **Advanced Filtering** — equality, range (`_gt`, `_gte`, `_lt`, `_lte`), negation (`_ne`), LIKE (`_like`), and IN (`_in`) operators via query params
|
|
17
|
+
- **Offset & Cursor Pagination** — built-in offset pagination and efficient cursor-based pagination
|
|
18
|
+
- **Zod Validation** — automatic request validation with length limits and enum constraints
|
|
19
|
+
- **Soft Delete** — built-in soft-delete support with `restore()` and `hardDelete()`
|
|
20
|
+
- **Relations** — `@HasMany`, `@HasOne`, `@BelongsTo`, `@BelongsToMany` with batch loading
|
|
21
|
+
- **Transactions** — `repository.transaction()` and `crudora.transaction()`
|
|
22
|
+
- **Lifecycle Hooks** — `beforeCreate`, `afterCreate`, `afterCreateMany`, `beforeUpdate`, `afterUpdate`, `beforeDelete`, `afterDelete`, `beforeFind`, `afterFind`
|
|
23
|
+
- **Structured Logging** — pluggable `CrudoraLogger` with correlation IDs per request; compatible with pino, winston
|
|
24
|
+
- **Field Security** — `hidden` fields stripped at query time via `getTableColumns()`
|
|
25
|
+
- **Standardized Responses** — all endpoints return `{ success, data, meta?, error? }` OpenAPI-style envelope
|
|
26
|
+
- **Schema Generator** — auto-generate Drizzle TypeScript schema files from models
|
|
27
|
+
- **TypeScript First** — full type safety, ESM and CJS dual build
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install crudora drizzle-orm
|
|
33
|
+
# PostgreSQL
|
|
34
|
+
npm install pg
|
|
35
|
+
# or MySQL
|
|
36
|
+
npm install mysql2
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
After installation, Crudora sets up your project with:
|
|
40
|
+
- `drizzle.config.ts` template
|
|
41
|
+
- `src/db/schema.ts` template
|
|
42
|
+
- Environment configuration (`.env`)
|
|
43
|
+
- Basic server setup (`src/server.ts`)
|
|
44
|
+
|
|
45
|
+
Add these scripts to your `package.json`:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"scripts": {
|
|
50
|
+
"dev": "ts-node src/server.ts",
|
|
51
|
+
"build": "tsc",
|
|
52
|
+
"db:generate": "drizzle-kit generate",
|
|
53
|
+
"db:push": "drizzle-kit push",
|
|
54
|
+
"db:migrate": "drizzle-kit migrate",
|
|
55
|
+
"db:studio": "drizzle-kit studio"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Quick Start
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
import { CrudoraServer, Model, Field } from 'crudora';
|
|
64
|
+
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
65
|
+
import { Pool } from 'pg';
|
|
66
|
+
import 'dotenv/config';
|
|
67
|
+
|
|
68
|
+
const db = drizzle(new Pool({ connectionString: process.env.DATABASE_URL }));
|
|
69
|
+
|
|
70
|
+
class User extends Model {
|
|
71
|
+
static schema = 'auth'; // PostgreSQL schema (optional)
|
|
72
|
+
static tableName = 'users';
|
|
73
|
+
static hidden = ['password'];
|
|
74
|
+
|
|
75
|
+
@Field({ type: 'uuid', primary: true })
|
|
76
|
+
id!: string;
|
|
77
|
+
|
|
78
|
+
@Field({ type: 'string', required: true, unique: true, length: 255 })
|
|
79
|
+
email!: string;
|
|
80
|
+
|
|
81
|
+
@Field({ type: 'string', required: true })
|
|
82
|
+
password!: string;
|
|
83
|
+
|
|
84
|
+
static async beforeCreate(data: any) {
|
|
85
|
+
data.password = await hashPassword(data.password);
|
|
86
|
+
return data;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
static async afterCreate(_data: any, result: any) {
|
|
90
|
+
await sendWelcomeEmail(result.email);
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const server = new CrudoraServer({ db, dialect: 'postgresql', port: 3000 });
|
|
96
|
+
|
|
97
|
+
server
|
|
98
|
+
.registerModel(User)
|
|
99
|
+
.generateRoutes()
|
|
100
|
+
.listen();
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Generated API Endpoints
|
|
104
|
+
|
|
105
|
+
For each registered model, Crudora automatically generates:
|
|
106
|
+
|
|
107
|
+
| Method | Path | Description |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `GET` | `/api/{tableName}` | List all with offset or cursor pagination |
|
|
110
|
+
| `GET` | `/api/{tableName}/:id` | Get by ID |
|
|
111
|
+
| `POST` | `/api/{tableName}` | Create — returns `201` |
|
|
112
|
+
| `PUT` | `/api/{tableName}/:id` | Full replace — all required fields must be provided |
|
|
113
|
+
| `PATCH` | `/api/{tableName}/:id` | Partial update — any subset of fields |
|
|
114
|
+
| `DELETE` | `/api/{tableName}/:id` | Delete — returns `204 No Content` |
|
|
115
|
+
|
|
116
|
+
### Response Envelope
|
|
117
|
+
|
|
118
|
+
All endpoints return a consistent JSON envelope:
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
// Success (list)
|
|
122
|
+
{
|
|
123
|
+
"success": true,
|
|
124
|
+
"data": [{ "id": "uuid", "email": "john@example.com" }],
|
|
125
|
+
"meta": {
|
|
126
|
+
"pagination": { "page": 1, "limit": 10, "total": 42, "pages": 5 }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Success (single)
|
|
131
|
+
{ "success": true, "data": { "id": "uuid", "email": "john@example.com" } }
|
|
132
|
+
|
|
133
|
+
// Error
|
|
134
|
+
{
|
|
135
|
+
"success": false,
|
|
136
|
+
"error": {
|
|
137
|
+
"code": "NOT_FOUND",
|
|
138
|
+
"message": "Resource not found"
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Validation error
|
|
143
|
+
{
|
|
144
|
+
"success": false,
|
|
145
|
+
"error": {
|
|
146
|
+
"code": "VALIDATION_ERROR",
|
|
147
|
+
"message": "Validation failed",
|
|
148
|
+
"details": [{ "field": "email", "message": "Invalid email" }]
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Query Parameters (GET list)
|
|
154
|
+
|
|
155
|
+
| Param | Example | Description |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| `page` | `?page=2` | Page number (offset pagination, default: `1`) |
|
|
158
|
+
| `limit` | `?limit=20` | Records per page (default: `10`, max: `1000`) |
|
|
159
|
+
| `orderBy` | `?orderBy=name,createdAt` | Sort fields (comma-separated) |
|
|
160
|
+
| `order` | `?order=asc,desc` | Sort directions (comma-separated) |
|
|
161
|
+
| `cursor` | `?cursor=base64...` | Cursor for cursor-based pagination |
|
|
162
|
+
| `cursorField` | `?cursorField=createdAt` | Field to use as cursor (default: primary key) |
|
|
163
|
+
| `select` | `?select=id,name,email` | Return only specified fields |
|
|
164
|
+
| `with` | `?with=posts,profile` | Load relations (max 5) |
|
|
165
|
+
| `withDeleted` | `?withDeleted=true` | Include soft-deleted records |
|
|
166
|
+
| `{field}` | `?name=John` | Equality filter |
|
|
167
|
+
| `{field}_gt` | `?age_gt=18` | Greater than |
|
|
168
|
+
| `{field}_gte` | `?age_gte=18` | Greater than or equal |
|
|
169
|
+
| `{field}_lt` | `?createdAt_lt=2025-01-01` | Less than |
|
|
170
|
+
| `{field}_lte` | `?createdAt_lte=2025-01-01` | Less than or equal |
|
|
171
|
+
| `{field}_ne` | `?status_ne=deleted` | Not equal |
|
|
172
|
+
| `{field}_like` | `?name_like=john` | LIKE `%john%` (case-sensitive) |
|
|
173
|
+
| `{field}_in` | `?status_in=active,pending` | IN list (comma-separated) |
|
|
174
|
+
|
|
175
|
+
## Multiple Schema Support
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
class User extends Model {
|
|
179
|
+
static schema = 'auth'; // → pgSchema('auth').table('users', ...)
|
|
180
|
+
static tableName = 'users';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
class AuditLog extends Model {
|
|
184
|
+
static schema = 'audit'; // → pgSchema('audit').table('audit_logs', ...)
|
|
185
|
+
static tableName = 'audit_logs';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
class Post extends Model {
|
|
189
|
+
// No schema → pgTable('posts', ...) (default public schema)
|
|
190
|
+
static tableName = 'posts';
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## `@Field()` Decorator
|
|
195
|
+
|
|
196
|
+
Columns are defined with `@Field()`. Crudora reads this metadata to auto-build Drizzle table objects at registration time.
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
import { Model, Field } from 'crudora';
|
|
200
|
+
|
|
201
|
+
class Product extends Model {
|
|
202
|
+
static tableName = 'products';
|
|
203
|
+
|
|
204
|
+
@Field({ type: 'serial', primary: true })
|
|
205
|
+
id!: number;
|
|
206
|
+
|
|
207
|
+
@Field({ type: 'string', required: true, length: 200 })
|
|
208
|
+
name!: string;
|
|
209
|
+
|
|
210
|
+
@Field({ type: 'enum', enumValues: ['draft', 'published', 'archived'] })
|
|
211
|
+
status!: string;
|
|
212
|
+
|
|
213
|
+
@Field({ type: 'decimal', precision: 10, scale: 2, required: true })
|
|
214
|
+
price!: string;
|
|
215
|
+
|
|
216
|
+
@Field({ type: 'integer' })
|
|
217
|
+
stock!: number;
|
|
218
|
+
|
|
219
|
+
@Field({ type: 'boolean' })
|
|
220
|
+
isActive!: boolean;
|
|
221
|
+
|
|
222
|
+
@Field({ type: 'json' })
|
|
223
|
+
metadata!: object;
|
|
224
|
+
|
|
225
|
+
@Field({ type: 'array' })
|
|
226
|
+
tags!: string[];
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Supported Field Types
|
|
231
|
+
|
|
232
|
+
| `type` | PostgreSQL | MySQL | Notes |
|
|
233
|
+
|---|---|---|---|
|
|
234
|
+
| `uuid` | `uuid` | `varchar(36)` | |
|
|
235
|
+
| `string` | `varchar(length)` | `varchar(length)` | Zod enforces `max(length)` |
|
|
236
|
+
| `text` | `text` | `text` | |
|
|
237
|
+
| `integer` | `integer` | `int` | |
|
|
238
|
+
| `number` | `doublePrecision` | `double` | |
|
|
239
|
+
| `boolean` | `boolean` | `boolean` | |
|
|
240
|
+
| `date` | `timestamp` | `datetime` | |
|
|
241
|
+
| `decimal` | `decimal(p, s)` | `decimal(p, s)` | |
|
|
242
|
+
| `json` | `json` | `json` | |
|
|
243
|
+
| `enum` | `text` + Zod enum | `mysqlEnum` | Requires `enumValues` |
|
|
244
|
+
| `bigint` | `bigint` (mode: number) | `bigint` (mode: number) | |
|
|
245
|
+
| `serial` | `serial` (auto-increment) | `int().autoincrement()` | DB-managed, skip in API |
|
|
246
|
+
| `array` | `text[]` | — (use `json`) | PostgreSQL only |
|
|
247
|
+
|
|
248
|
+
> **Note on `enum` in PostgreSQL:** The column is stored as `text`. Enum values are enforced by Zod at the API layer. MySQL uses a native `ENUM` column.
|
|
249
|
+
|
|
250
|
+
### Field Options
|
|
251
|
+
|
|
252
|
+
| Option | Type | Description |
|
|
253
|
+
|---|---|---|
|
|
254
|
+
| `type` | `FieldType` | Column type (required) |
|
|
255
|
+
| `primary` | `boolean` | Primary key — excluded from API validation |
|
|
256
|
+
| `required` | `boolean` | NOT NULL constraint + required in Zod |
|
|
257
|
+
| `nullable` | `boolean` | Column allows NULL; Zod accepts `null` values |
|
|
258
|
+
| `unique` | `boolean` | UNIQUE constraint |
|
|
259
|
+
| `length` | `number` | Max length for `string` — enforced by Zod |
|
|
260
|
+
| `precision` | `number` | Decimal precision (default: 10) |
|
|
261
|
+
| `scale` | `number` | Decimal scale (default: 2) |
|
|
262
|
+
| `default` | `any` | Column default value |
|
|
263
|
+
| `enumValues` | `string[]` | Required for `enum` type |
|
|
264
|
+
|
|
265
|
+
## Soft Delete
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
class Post extends Model {
|
|
269
|
+
static tableName = 'posts';
|
|
270
|
+
static softDelete = true; // adds deletedAt column
|
|
271
|
+
|
|
272
|
+
@Field({ type: 'uuid', primary: true }) id!: string;
|
|
273
|
+
@Field({ type: 'string' }) title!: string;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// DELETE /api/posts/:id → sets deletedAt (soft delete)
|
|
277
|
+
// GET /api/posts → excludes soft-deleted records by default
|
|
278
|
+
// GET /api/posts?withDeleted=true → includes soft-deleted records
|
|
279
|
+
|
|
280
|
+
const repo = crudora.getRepository(Post);
|
|
281
|
+
await repo.restore('uuid'); // restore a soft-deleted record
|
|
282
|
+
await repo.hardDelete('uuid'); // permanently delete
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Relations
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
import { HasMany, BelongsTo } from 'crudora';
|
|
289
|
+
|
|
290
|
+
class User extends Model {
|
|
291
|
+
static tableName = 'users';
|
|
292
|
+
|
|
293
|
+
@Field({ type: 'uuid', primary: true }) id!: string;
|
|
294
|
+
@Field({ type: 'string' }) name!: string;
|
|
295
|
+
|
|
296
|
+
@HasMany(() => Post, 'authorId')
|
|
297
|
+
posts?: Post[];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
class Post extends Model {
|
|
301
|
+
static tableName = 'posts';
|
|
302
|
+
|
|
303
|
+
@Field({ type: 'uuid', primary: true }) id!: string;
|
|
304
|
+
@Field({ type: 'string' }) title!: string;
|
|
305
|
+
@Field({ type: 'uuid' }) authorId!: string;
|
|
306
|
+
|
|
307
|
+
@BelongsTo(() => User, 'authorId')
|
|
308
|
+
author?: User;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Load with relations
|
|
312
|
+
// GET /api/users?with=posts
|
|
313
|
+
// GET /api/posts?with=author
|
|
314
|
+
|
|
315
|
+
// Or via repository
|
|
316
|
+
const users = await userRepo.findAll({ with: ['posts'] });
|
|
317
|
+
const post = await postRepo.findById('uuid', { with: ['author'] });
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
## Lifecycle Hooks
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
class User extends Model {
|
|
324
|
+
static async beforeCreate(data: any) {
|
|
325
|
+
data.password = await hashPassword(data.password);
|
|
326
|
+
return data;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
static async afterCreate(_data: any, result: any) {
|
|
330
|
+
await sendWelcomeEmail(result.email);
|
|
331
|
+
return result;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Called once after createMany() — use for bulk side effects
|
|
335
|
+
static async afterCreateMany(records: any[]) {
|
|
336
|
+
await sendBulkWelcomeEmails(records.map(r => r.email));
|
|
337
|
+
return records;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
static async beforeUpdate(_id: string, data: any) {
|
|
341
|
+
return data;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
static async afterUpdate(id: string, _data: any, result: any) {
|
|
345
|
+
await auditLog('update', id);
|
|
346
|
+
return result;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
static async beforeDelete(id: string) {
|
|
350
|
+
await archiveUserData(id);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
static async afterDelete(id: string, result: any) {
|
|
354
|
+
await auditLog('delete', id);
|
|
355
|
+
return result;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
static async beforeFind(options: any) {
|
|
359
|
+
return options;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
static async afterFind(results: any[]) {
|
|
363
|
+
return results.map(u => ({ ...u, displayName: u.name }));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
> **`afterCreate` vs `afterCreateMany`:** `afterCreate` is called for each individual `create()`. `afterCreateMany` is called once with all records after `createMany()`. This is intentional — calling `afterCreate` N times in a batch defeats the performance benefit of bulk insert.
|
|
369
|
+
|
|
370
|
+
## Using Repositories
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
const crudora = server.getCrudora();
|
|
374
|
+
const userRepo = crudora.getRepository(User);
|
|
375
|
+
|
|
376
|
+
// Create
|
|
377
|
+
const user = await userRepo.create({ email: 'john@example.com', password: 'plain' });
|
|
378
|
+
|
|
379
|
+
// Bulk insert
|
|
380
|
+
const users = await userRepo.createMany([
|
|
381
|
+
{ email: 'alice@example.com', password: '...' },
|
|
382
|
+
{ email: 'bob@example.com', password: '...' },
|
|
383
|
+
]);
|
|
384
|
+
|
|
385
|
+
// Find
|
|
386
|
+
const user = await userRepo.findById('uuid');
|
|
387
|
+
const users = await userRepo.findAll({ skip: 0, take: 10, where: { isActive: 'true' } });
|
|
388
|
+
const first = await userRepo.findOne({ email: 'john@example.com' });
|
|
389
|
+
const exists = await userRepo.exists({ email: 'john@example.com' });
|
|
390
|
+
const total = await userRepo.count({ isActive: 'true' });
|
|
391
|
+
|
|
392
|
+
// Cursor pagination
|
|
393
|
+
const page1 = await userRepo.findWithCursor({ take: 10 });
|
|
394
|
+
const page2 = await userRepo.findWithCursor({ take: 10, cursor: page1.nextCursor });
|
|
395
|
+
|
|
396
|
+
// Update / Delete
|
|
397
|
+
const updated = await userRepo.update('uuid', { name: 'Jane' });
|
|
398
|
+
await userRepo.delete('uuid');
|
|
399
|
+
|
|
400
|
+
// Transactions
|
|
401
|
+
await userRepo.transaction(async (trx) => {
|
|
402
|
+
const user = await trx.create({ email: 'alice@example.com', password: '...' });
|
|
403
|
+
await postTrxRepo.create({ title: 'Hello', authorId: user.id });
|
|
404
|
+
});
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
## Logging
|
|
408
|
+
|
|
409
|
+
By default, Crudora writes structured JSON to the console for request errors:
|
|
410
|
+
|
|
411
|
+
```json
|
|
412
|
+
{"level":"error","time":"2025-01-01T00:00:00.000Z","msg":"POST request failed","path":"/api/users","correlationId":"uuid-...","error":"Duplicate key"}
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Every request automatically gets a unique **correlation ID** (`req.correlationId`) that appears in all log entries for that request.
|
|
416
|
+
|
|
417
|
+
### Custom Logger
|
|
418
|
+
|
|
419
|
+
Pass any object with `error`, `warn`, `info`, `debug` methods:
|
|
420
|
+
|
|
421
|
+
```typescript
|
|
422
|
+
import pino from 'pino';
|
|
423
|
+
|
|
424
|
+
const logger = pino();
|
|
425
|
+
|
|
426
|
+
const server = new CrudoraServer({
|
|
427
|
+
db,
|
|
428
|
+
dialect: 'postgresql',
|
|
429
|
+
logger: {
|
|
430
|
+
error: (msg, ctx) => logger.error(ctx, msg),
|
|
431
|
+
warn: (msg, ctx) => logger.warn(ctx, msg),
|
|
432
|
+
info: (msg, ctx) => logger.info(ctx, msg),
|
|
433
|
+
debug: (msg, ctx) => logger.debug(ctx, msg),
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// Or disable logging entirely
|
|
438
|
+
const server = new CrudoraServer({ db, dialect: 'postgresql', logger: false });
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
## Schema Generation
|
|
442
|
+
|
|
443
|
+
```typescript
|
|
444
|
+
const schema = server.getCrudora().generateDrizzleSchema();
|
|
445
|
+
console.log(schema);
|
|
446
|
+
// → TypeScript file ready for drizzle-kit
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Example output:
|
|
450
|
+
|
|
451
|
+
```typescript
|
|
452
|
+
// Auto-generated by Crudora — do not edit manually
|
|
453
|
+
import { pgTable, pgSchema, uuid, varchar, timestamp } from 'drizzle-orm/pg-core';
|
|
454
|
+
|
|
455
|
+
const authSchema = pgSchema('auth');
|
|
456
|
+
|
|
457
|
+
export const usersTable = authSchema.table('users', {
|
|
458
|
+
id: uuid('id').primaryKey(),
|
|
459
|
+
email: varchar('email', { length: 255 }).notNull().unique(),
|
|
460
|
+
password: varchar('password', { length: 255 }).notNull(),
|
|
461
|
+
createdAt: timestamp('createdAt', { mode: 'date' }).defaultNow().notNull(),
|
|
462
|
+
updatedAt: timestamp('updatedAt', { mode: 'date' }).defaultNow().notNull(),
|
|
463
|
+
});
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
Or use the CLI:
|
|
467
|
+
|
|
468
|
+
```bash
|
|
469
|
+
npx crudora generate-schema --entry src/server.ts --output src/db/schema.ts
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
## Custom Routes
|
|
473
|
+
|
|
474
|
+
```typescript
|
|
475
|
+
server
|
|
476
|
+
.get('/health', (_req, res) => {
|
|
477
|
+
res.json({ success: true, data: { status: 'ok', timestamp: new Date() } });
|
|
478
|
+
})
|
|
479
|
+
.post('/auth/login', async (req, res) => {
|
|
480
|
+
const { email, password } = req.body;
|
|
481
|
+
const userRepo = server.getCrudora().getRepository(User);
|
|
482
|
+
// includeHidden: true bypasses static hidden so the password hash is readable
|
|
483
|
+
const row = await userRepo.findOne({ email }, { includeHidden: true });
|
|
484
|
+
if (!row || !verifyPassword(password, (row as any).password)) {
|
|
485
|
+
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } });
|
|
486
|
+
}
|
|
487
|
+
const { password: _pw, ...safeUser } = row as any;
|
|
488
|
+
res.json({ success: true, data: { token: generateJWT(safeUser) } });
|
|
489
|
+
});
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
## Authentication
|
|
493
|
+
|
|
494
|
+
Crudora has no built-in auth by design — strategies differ too much across projects. Use standard Express middleware instead.
|
|
495
|
+
|
|
496
|
+
**Protect all auto-generated routes:**
|
|
497
|
+
|
|
498
|
+
```typescript
|
|
499
|
+
// Mount before generateRoutes() — every /api/* route will require a valid JWT
|
|
500
|
+
server.getApp().use('/api', verifyJWT);
|
|
501
|
+
|
|
502
|
+
server.registerModel(User, Post).generateRoutes().listen();
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
**Add a login route** (note: `findOne()` strips `hidden` fields — pass `{ includeHidden: true }` to bypass):
|
|
506
|
+
|
|
507
|
+
```typescript
|
|
508
|
+
server.post('/auth/login', async (req, res) => {
|
|
509
|
+
const userRepo = server.getCrudora().getRepository(User);
|
|
510
|
+
|
|
511
|
+
// includeHidden: true bypasses static hidden so the password hash is readable
|
|
512
|
+
const row = await userRepo.findOne({ email: req.body.email }, { includeHidden: true });
|
|
513
|
+
|
|
514
|
+
if (!row || !verifyPassword(req.body.password, (row as any).password)) {
|
|
515
|
+
return res.status(401).json({ success: false, error: 'Invalid credentials' });
|
|
516
|
+
}
|
|
517
|
+
const { password: _pw, ...safeUser } = row as any;
|
|
518
|
+
res.json({ success: true, data: { token: generateJWT(safeUser) } });
|
|
519
|
+
});
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
See the [Authentication Guide](./docs/authentication.md) for register, per-route middleware, role guards, and a full JWT example.
|
|
523
|
+
|
|
524
|
+
## Project Setup
|
|
525
|
+
|
|
526
|
+
```bash
|
|
527
|
+
# Install dependencies
|
|
528
|
+
npm install drizzle-orm pg
|
|
529
|
+
npm install -D drizzle-kit typescript ts-node
|
|
530
|
+
|
|
531
|
+
# Update .env with your DATABASE_URL
|
|
532
|
+
|
|
533
|
+
# Push schema to database
|
|
534
|
+
npx drizzle-kit push
|
|
535
|
+
|
|
536
|
+
# Start development server
|
|
537
|
+
npx ts-node src/server.ts
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
## Documentation
|
|
541
|
+
|
|
542
|
+
- [API Reference](./docs/api.md)
|
|
543
|
+
- [Model Definition Guide](./docs/models.md)
|
|
544
|
+
- [Custom Routes](./docs/custom-routes.md)
|
|
545
|
+
- [Authentication Guide](./docs/authentication.md)
|
|
546
|
+
- [Deployment Guide](./docs/deployment.md)
|
|
547
|
+
|
|
548
|
+
## Contributing
|
|
549
|
+
|
|
550
|
+
We welcome contributions! Please see our [Contributing Guide](./CONTRIBUTING.md) for details.
|
|
551
|
+
|
|
552
|
+
## License
|
|
553
|
+
|
|
554
|
+
MIT © [Muhammad Surya J](https://suryamsj.my.id)
|