@vertz/db 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/README.md +923 -0
- package/dist/diagnostic/index.d.ts +41 -0
- package/dist/diagnostic/index.js +10 -0
- package/dist/index.d.ts +1346 -0
- package/dist/index.js +2010 -0
- package/dist/internals.d.ts +223 -0
- package/dist/internals.js +25 -0
- package/dist/plugin/index.d.ts +66 -0
- package/dist/plugin/index.js +66 -0
- package/dist/shared/chunk-3f2grpak.js +428 -0
- package/dist/shared/chunk-hrfdj0rr.js +13 -0
- package/dist/shared/chunk-wj026daz.js +86 -0
- package/dist/shared/chunk-xp022dyp.js +296 -0
- package/dist/sql/index.d.ts +213 -0
- package/dist/sql/index.js +64 -0
- package/package.json +72 -0
package/README.md
ADDED
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
# @vertz/db
|
|
2
|
+
|
|
3
|
+
Type-safe database layer for Vertz with schema-driven migrations, powerful query building, and full type inference from schema to query results.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Type-safe schema builder** — Define tables, columns, relations with full TypeScript inference
|
|
8
|
+
- **Automatic migrations** — Generate SQL migrations from schema changes
|
|
9
|
+
- **Query builder with relations** — Type-safe CRUD with `include` for nested data loading
|
|
10
|
+
- **Multi-tenant support** — Built-in tenant isolation with `d.tenant()` columns
|
|
11
|
+
- **Connection pooling** — PostgreSQL connection pool with configurable limits
|
|
12
|
+
- **Comprehensive error handling** — Parse and transform Postgres errors with helpful diagnostics
|
|
13
|
+
- **Plugin system** — Extend behavior with lifecycle hooks
|
|
14
|
+
- **Zero runtime overhead** — Types are erased at build time
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @vertz/db
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Prerequisites:**
|
|
23
|
+
- PostgreSQL database
|
|
24
|
+
- Node.js >= 22
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
### 1. Define Your Schema
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
import { d } from '@vertz/db';
|
|
32
|
+
|
|
33
|
+
// Define tables
|
|
34
|
+
const users = d.table('users', {
|
|
35
|
+
id: d.uuid().primaryKey().defaultValue('gen_random_uuid()'),
|
|
36
|
+
email: d.email().unique().notNull(),
|
|
37
|
+
name: d.text().notNull(),
|
|
38
|
+
createdAt: d.timestamp().defaultValue('now()').notNull(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const posts = d.table('posts', {
|
|
42
|
+
id: d.uuid().primaryKey().defaultValue('gen_random_uuid()'),
|
|
43
|
+
title: d.text().notNull(),
|
|
44
|
+
content: d.text().notNull(),
|
|
45
|
+
authorId: d.uuid().notNull(),
|
|
46
|
+
published: d.boolean().defaultValue('false').notNull(),
|
|
47
|
+
createdAt: d.timestamp().defaultValue('now()').notNull(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Define relations
|
|
51
|
+
const userRelations = {
|
|
52
|
+
posts: d.ref.many(() => posts, 'authorId'),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const postRelations = {
|
|
56
|
+
author: d.ref.one(() => users, 'authorId'),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Create registry
|
|
60
|
+
const db = createDb({
|
|
61
|
+
url: process.env.DATABASE_URL!,
|
|
62
|
+
tables: {
|
|
63
|
+
users: d.entry(users, userRelations),
|
|
64
|
+
posts: d.entry(posts, postRelations),
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 2. Run Migrations
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
import { migrateDev } from '@vertz/db';
|
|
73
|
+
|
|
74
|
+
// Development: auto-generate and apply migrations
|
|
75
|
+
await migrateDev({
|
|
76
|
+
queryFn: db.queryFn,
|
|
77
|
+
currentSnapshot: db.snapshot,
|
|
78
|
+
previousSnapshot: loadPreviousSnapshot(), // From file
|
|
79
|
+
migrationsDir: './migrations',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Production: apply migrations from files
|
|
83
|
+
import { migrateDeploy } from '@vertz/db';
|
|
84
|
+
|
|
85
|
+
await migrateDeploy({
|
|
86
|
+
queryFn: db.queryFn,
|
|
87
|
+
migrationsDir: './migrations',
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 3. Query Your Data
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
// Create a user
|
|
95
|
+
const user = await db.users.create({
|
|
96
|
+
data: {
|
|
97
|
+
email: 'alice@example.com',
|
|
98
|
+
name: 'Alice',
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Find users with posts included
|
|
103
|
+
const usersWithPosts = await db.users.findMany({
|
|
104
|
+
where: { published: true },
|
|
105
|
+
include: { posts: true },
|
|
106
|
+
orderBy: { createdAt: 'desc' },
|
|
107
|
+
limit: 10,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Type-safe: usersWithPosts[0].posts is Post[]
|
|
111
|
+
|
|
112
|
+
// Update a post
|
|
113
|
+
await db.posts.update({
|
|
114
|
+
where: { id: postId },
|
|
115
|
+
data: { published: true },
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Delete old posts
|
|
119
|
+
await db.posts.deleteMany({
|
|
120
|
+
where: {
|
|
121
|
+
createdAt: { lt: new Date('2024-01-01') },
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## API Reference
|
|
127
|
+
|
|
128
|
+
### Schema Builder (`d`)
|
|
129
|
+
|
|
130
|
+
The `d` object provides all schema building functions.
|
|
131
|
+
|
|
132
|
+
#### Column Types
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
// Text types
|
|
136
|
+
d.text() // TEXT
|
|
137
|
+
d.varchar(255) // VARCHAR(255)
|
|
138
|
+
d.email() // TEXT with email format constraint
|
|
139
|
+
d.uuid() // UUID
|
|
140
|
+
|
|
141
|
+
// Numeric types
|
|
142
|
+
d.integer() // INTEGER
|
|
143
|
+
d.bigint() // BIGINT
|
|
144
|
+
d.serial() // SERIAL (auto-incrementing integer)
|
|
145
|
+
d.decimal(10, 2) // DECIMAL(10, 2)
|
|
146
|
+
d.real() // REAL
|
|
147
|
+
d.doublePrecision() // DOUBLE PRECISION
|
|
148
|
+
|
|
149
|
+
// Date/time types
|
|
150
|
+
d.timestamp() // TIMESTAMP WITH TIME ZONE
|
|
151
|
+
d.date() // DATE
|
|
152
|
+
d.time() // TIME
|
|
153
|
+
|
|
154
|
+
// Boolean
|
|
155
|
+
d.boolean() // BOOLEAN
|
|
156
|
+
|
|
157
|
+
// JSON
|
|
158
|
+
d.jsonb() // JSONB
|
|
159
|
+
d.jsonb<MyType>({ // JSONB with validation
|
|
160
|
+
validator: (v) => MyTypeSchema.parse(v)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// Arrays
|
|
164
|
+
d.textArray() // TEXT[]
|
|
165
|
+
d.integerArray() // INTEGER[]
|
|
166
|
+
|
|
167
|
+
// Enums
|
|
168
|
+
d.enum('status', ['draft', 'published', 'archived'])
|
|
169
|
+
|
|
170
|
+
// Multi-tenant column
|
|
171
|
+
d.tenant(organizationTable) // UUID with tenant FK
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
#### Column Modifiers
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
d.text()
|
|
178
|
+
.primaryKey() // Add to PRIMARY KEY
|
|
179
|
+
.unique() // Add UNIQUE constraint
|
|
180
|
+
.notNull() // Add NOT NULL constraint
|
|
181
|
+
.defaultValue('default') // Set default value
|
|
182
|
+
.index() // Add index on this column
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
#### Defining Tables
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
const users = d.table('users', {
|
|
189
|
+
id: d.uuid().primaryKey(),
|
|
190
|
+
email: d.email().unique().notNull(),
|
|
191
|
+
name: d.text().notNull(),
|
|
192
|
+
}, {
|
|
193
|
+
indexes: [
|
|
194
|
+
d.index(['email', 'name']), // Composite index
|
|
195
|
+
],
|
|
196
|
+
});
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
#### Defining Relations
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
// One-to-many
|
|
203
|
+
const userRelations = {
|
|
204
|
+
posts: d.ref.many(() => posts, 'authorId'),
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Many-to-one
|
|
208
|
+
const postRelations = {
|
|
209
|
+
author: d.ref.one(() => users, 'authorId'),
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// Many-to-many (through join table)
|
|
213
|
+
const postTags = d.table('post_tags', {
|
|
214
|
+
postId: d.uuid().notNull(),
|
|
215
|
+
tagId: d.uuid().notNull(),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const postRelations = {
|
|
219
|
+
tags: d.ref.many(() => tags).through(() => postTags, 'postId', 'tagId'),
|
|
220
|
+
};
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Database Client
|
|
224
|
+
|
|
225
|
+
#### `createDb(options)`
|
|
226
|
+
|
|
227
|
+
Create a database client instance.
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
import { createDb, d } from '@vertz/db';
|
|
231
|
+
|
|
232
|
+
const db = createDb({
|
|
233
|
+
url: 'postgresql://user:pass@localhost:5432/mydb',
|
|
234
|
+
tables: {
|
|
235
|
+
users: d.entry(usersTable, userRelations),
|
|
236
|
+
posts: d.entry(postsTable, postRelations),
|
|
237
|
+
},
|
|
238
|
+
pool: {
|
|
239
|
+
max: 20, // Max connections (default: 10)
|
|
240
|
+
idleTimeout: 30000, // Idle timeout ms (default: 30000)
|
|
241
|
+
connectionTimeout: 5000, // Connection timeout ms (default: 10000)
|
|
242
|
+
healthCheckTimeout: 5000, // Health check timeout ms (default: 5000)
|
|
243
|
+
replicas: [ // Read replica URLs for query routing
|
|
244
|
+
'postgresql://user:pass@localhost:5433/mydb',
|
|
245
|
+
'postgresql://user:pass@localhost:5434/mydb',
|
|
246
|
+
],
|
|
247
|
+
},
|
|
248
|
+
casing: 'snake_case', // or 'camelCase' (default: 'snake_case')
|
|
249
|
+
log: (msg) => console.log(msg), // Optional logger
|
|
250
|
+
});
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
**Returns:** `DatabaseInstance<TTables>` with typed table accessors.
|
|
254
|
+
|
|
255
|
+
#### Query Methods
|
|
256
|
+
|
|
257
|
+
All query methods are available on `db.<tableName>`:
|
|
258
|
+
|
|
259
|
+
##### `findOne(options)`
|
|
260
|
+
|
|
261
|
+
Find a single record (returns `null` if not found).
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
const user = await db.users.findOne({
|
|
265
|
+
where: { email: 'alice@example.com' },
|
|
266
|
+
select: { id: true, name: true }, // Optional: select specific columns
|
|
267
|
+
include: { posts: true }, // Optional: include relations
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// Type: { id: string; name: string; posts: Post[] } | null
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
##### `findOneOrThrow(options)`
|
|
274
|
+
|
|
275
|
+
Find a single record or throw `NotFoundError`.
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
const user = await db.users.findOneOrThrow({
|
|
279
|
+
where: { id: userId },
|
|
280
|
+
});
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
##### `findMany(options)`
|
|
284
|
+
|
|
285
|
+
Find multiple records.
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
const posts = await db.posts.findMany({
|
|
289
|
+
where: {
|
|
290
|
+
published: true,
|
|
291
|
+
authorId: userId,
|
|
292
|
+
},
|
|
293
|
+
orderBy: { createdAt: 'desc' },
|
|
294
|
+
limit: 10,
|
|
295
|
+
offset: 0,
|
|
296
|
+
include: { author: true },
|
|
297
|
+
});
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
**Cursor-based pagination:**
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
const posts = await db.posts.findMany({
|
|
304
|
+
where: { published: true },
|
|
305
|
+
orderBy: { createdAt: 'desc' },
|
|
306
|
+
cursor: { id: lastPostId }, // Start after this record
|
|
307
|
+
take: 10,
|
|
308
|
+
});
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
##### `findManyAndCount(options)`
|
|
312
|
+
|
|
313
|
+
Find records and get total count (useful for pagination).
|
|
314
|
+
|
|
315
|
+
```typescript
|
|
316
|
+
const { rows, count } = await db.posts.findManyAndCount({
|
|
317
|
+
where: { published: true },
|
|
318
|
+
limit: 10,
|
|
319
|
+
offset: 0,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
console.log(`Showing ${rows.length} of ${count} posts`);
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
##### `create(options)`
|
|
326
|
+
|
|
327
|
+
Insert a single record.
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
const user = await db.users.create({
|
|
331
|
+
data: {
|
|
332
|
+
email: 'bob@example.com',
|
|
333
|
+
name: 'Bob',
|
|
334
|
+
},
|
|
335
|
+
select: { id: true, email: true }, // Optional: customize returned fields
|
|
336
|
+
});
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
##### `createMany(options)`
|
|
340
|
+
|
|
341
|
+
Insert multiple records (no return value).
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
await db.posts.createMany({
|
|
345
|
+
data: [
|
|
346
|
+
{ title: 'Post 1', content: 'Content 1', authorId: userId },
|
|
347
|
+
{ title: 'Post 2', content: 'Content 2', authorId: userId },
|
|
348
|
+
],
|
|
349
|
+
});
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
##### `createManyAndReturn(options)`
|
|
353
|
+
|
|
354
|
+
Insert multiple records and return them.
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
const posts = await db.posts.createManyAndReturn({
|
|
358
|
+
data: [
|
|
359
|
+
{ title: 'Post 1', content: 'Content 1', authorId: userId },
|
|
360
|
+
{ title: 'Post 2', content: 'Content 2', authorId: userId },
|
|
361
|
+
],
|
|
362
|
+
select: { id: true, title: true },
|
|
363
|
+
});
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
##### `update(options)`
|
|
367
|
+
|
|
368
|
+
Update a single record.
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
const updatedPost = await db.posts.update({
|
|
372
|
+
where: { id: postId },
|
|
373
|
+
data: { published: true, updatedAt: new Date() },
|
|
374
|
+
select: { id: true, published: true },
|
|
375
|
+
});
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
##### `updateMany(options)`
|
|
379
|
+
|
|
380
|
+
Update multiple records (returns count).
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
const { count } = await db.posts.updateMany({
|
|
384
|
+
where: { authorId: userId },
|
|
385
|
+
data: { published: false },
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
console.log(`Updated ${count} posts`);
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
##### `upsert(options)`
|
|
392
|
+
|
|
393
|
+
Insert or update (based on unique constraint).
|
|
394
|
+
|
|
395
|
+
```typescript
|
|
396
|
+
const user = await db.users.upsert({
|
|
397
|
+
where: { email: 'alice@example.com' },
|
|
398
|
+
create: {
|
|
399
|
+
email: 'alice@example.com',
|
|
400
|
+
name: 'Alice',
|
|
401
|
+
},
|
|
402
|
+
update: {
|
|
403
|
+
name: 'Alice Updated',
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
##### `delete(options)`
|
|
409
|
+
|
|
410
|
+
Delete a single record.
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
const deleted = await db.users.delete({
|
|
414
|
+
where: { id: userId },
|
|
415
|
+
select: { id: true, email: true },
|
|
416
|
+
});
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
##### `deleteMany(options)`
|
|
420
|
+
|
|
421
|
+
Delete multiple records (returns count).
|
|
422
|
+
|
|
423
|
+
```typescript
|
|
424
|
+
const { count } = await db.posts.deleteMany({
|
|
425
|
+
where: {
|
|
426
|
+
createdAt: { lt: new Date('2024-01-01') },
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
console.log(`Deleted ${count} old posts`);
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
#### Filter Operators
|
|
434
|
+
|
|
435
|
+
Use operators in `where` clauses:
|
|
436
|
+
|
|
437
|
+
```typescript
|
|
438
|
+
await db.posts.findMany({
|
|
439
|
+
where: {
|
|
440
|
+
// Equality
|
|
441
|
+
published: true,
|
|
442
|
+
|
|
443
|
+
// Comparison
|
|
444
|
+
views: { gt: 100 }, // greater than
|
|
445
|
+
createdAt: { gte: startDate }, // greater than or equal
|
|
446
|
+
likes: { lt: 50 }, // less than
|
|
447
|
+
rating: { lte: 3 }, // less than or equal
|
|
448
|
+
|
|
449
|
+
// Pattern matching
|
|
450
|
+
title: { like: '%tutorial%' },
|
|
451
|
+
email: { ilike: '%@EXAMPLE.COM%' }, // case-insensitive
|
|
452
|
+
|
|
453
|
+
// Set operations
|
|
454
|
+
status: { in: ['draft', 'published'] },
|
|
455
|
+
category: { notIn: ['spam', 'deleted'] },
|
|
456
|
+
|
|
457
|
+
// Null checks
|
|
458
|
+
deletedAt: { isNull: true },
|
|
459
|
+
publishedAt: { isNotNull: true },
|
|
460
|
+
|
|
461
|
+
// Logical operators
|
|
462
|
+
OR: [
|
|
463
|
+
{ authorId: user1Id },
|
|
464
|
+
{ authorId: user2Id },
|
|
465
|
+
],
|
|
466
|
+
AND: [
|
|
467
|
+
{ published: true },
|
|
468
|
+
{ views: { gt: 100 } },
|
|
469
|
+
],
|
|
470
|
+
NOT: { status: 'archived' },
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
#### Aggregation
|
|
476
|
+
|
|
477
|
+
```typescript
|
|
478
|
+
// Count
|
|
479
|
+
const count = await db.posts.count({
|
|
480
|
+
where: { published: true },
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// Sum
|
|
484
|
+
const totalViews = await db.posts.sum('views', {
|
|
485
|
+
where: { authorId: userId },
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// Average
|
|
489
|
+
const avgRating = await db.posts.avg('rating', {
|
|
490
|
+
where: { published: true },
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// Min/Max
|
|
494
|
+
const oldestPost = await db.posts.min('createdAt');
|
|
495
|
+
const newestPost = await db.posts.max('createdAt');
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
#### Raw SQL Queries
|
|
499
|
+
|
|
500
|
+
For complex queries, use the raw query function:
|
|
501
|
+
|
|
502
|
+
```typescript
|
|
503
|
+
import { sql } from '@vertz/db/sql';
|
|
504
|
+
|
|
505
|
+
const results = await db.query<{ count: number }>(
|
|
506
|
+
sql`
|
|
507
|
+
SELECT COUNT(*) as count
|
|
508
|
+
FROM posts
|
|
509
|
+
WHERE published = ${true}
|
|
510
|
+
AND author_id = ${userId}
|
|
511
|
+
`
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
console.log(results.rows[0].count);
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
**Security note:** Always use `sql` tagged template for user input to prevent SQL injection.
|
|
518
|
+
|
|
519
|
+
#### Timestamp Coercion
|
|
520
|
+
|
|
521
|
+
> ⚠️ **Important:** The PostgreSQL driver automatically coerces string values that match ISO 8601 timestamp patterns into JavaScript `Date` objects. This applies to all columns, not just declared timestamp columns.
|
|
522
|
+
|
|
523
|
+
If you store timestamp-formatted strings in plain `text` columns (e.g., `"2024-01-15T10:30:00Z"`), they will be silently converted to `Date` objects when returned from queries.
|
|
524
|
+
|
|
525
|
+
This behavior uses a heuristic regex (`/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/`) to detect timestamp-like strings. Future versions may add column-type-aware coercion to eliminate false positives.
|
|
526
|
+
|
|
527
|
+
### Migrations
|
|
528
|
+
|
|
529
|
+
#### `migrateDev(options)`
|
|
530
|
+
|
|
531
|
+
Development workflow: generate and apply migrations.
|
|
532
|
+
|
|
533
|
+
```typescript
|
|
534
|
+
import { migrateDev } from '@vertz/db';
|
|
535
|
+
|
|
536
|
+
const result = await migrateDev({
|
|
537
|
+
queryFn: db.queryFn,
|
|
538
|
+
currentSnapshot: db.snapshot,
|
|
539
|
+
previousSnapshot: loadPreviousSnapshot(), // Load from file
|
|
540
|
+
migrationsDir: './migrations',
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
console.log(`Applied migration: ${result.migrationName}`);
|
|
544
|
+
console.log(`SQL:\n${result.sql}`);
|
|
545
|
+
|
|
546
|
+
// Save current snapshot for next time
|
|
547
|
+
fs.writeFileSync(
|
|
548
|
+
'./schema-snapshot.json',
|
|
549
|
+
JSON.stringify(db.snapshot, null, 2)
|
|
550
|
+
);
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
#### `migrateDeploy(options)`
|
|
554
|
+
|
|
555
|
+
Production: apply pending migrations from files.
|
|
556
|
+
|
|
557
|
+
```typescript
|
|
558
|
+
import { migrateDeploy } from '@vertz/db';
|
|
559
|
+
|
|
560
|
+
const result = await migrateDeploy({
|
|
561
|
+
queryFn: db.queryFn,
|
|
562
|
+
migrationsDir: './migrations',
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
console.log(`Applied ${result.appliedCount} migrations`);
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
#### `migrateStatus(options)`
|
|
569
|
+
|
|
570
|
+
Check migration status.
|
|
571
|
+
|
|
572
|
+
```typescript
|
|
573
|
+
import { migrateStatus } from '@vertz/db';
|
|
574
|
+
|
|
575
|
+
const status = await migrateStatus({
|
|
576
|
+
queryFn: db.queryFn,
|
|
577
|
+
migrationsDir: './migrations',
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
for (const migration of status.migrations) {
|
|
581
|
+
console.log(`${migration.name}: ${migration.applied ? '✓' : '✗'}`);
|
|
582
|
+
}
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
#### `push(options)`
|
|
586
|
+
|
|
587
|
+
Push schema changes directly without creating migration files (development only).
|
|
588
|
+
|
|
589
|
+
```typescript
|
|
590
|
+
import { push } from '@vertz/db';
|
|
591
|
+
|
|
592
|
+
const result = await push({
|
|
593
|
+
queryFn: db.queryFn,
|
|
594
|
+
currentSnapshot: db.snapshot,
|
|
595
|
+
previousSnapshot: loadPreviousSnapshot(),
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
console.log(`Pushed changes to: ${result.tablesAffected.join(', ')}`);
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
### Error Handling
|
|
602
|
+
|
|
603
|
+
`@vertz/db` provides typed error classes for common database errors:
|
|
604
|
+
|
|
605
|
+
```typescript
|
|
606
|
+
import {
|
|
607
|
+
NotFoundError,
|
|
608
|
+
UniqueConstraintError,
|
|
609
|
+
ForeignKeyError,
|
|
610
|
+
NotNullError,
|
|
611
|
+
CheckConstraintError,
|
|
612
|
+
ConnectionError,
|
|
613
|
+
DbError,
|
|
614
|
+
} from '@vertz/db';
|
|
615
|
+
|
|
616
|
+
try {
|
|
617
|
+
await db.users.create({
|
|
618
|
+
data: { email: 'duplicate@example.com', name: 'Test' },
|
|
619
|
+
});
|
|
620
|
+
} catch (error) {
|
|
621
|
+
if (error instanceof UniqueConstraintError) {
|
|
622
|
+
console.error(`Unique constraint violated on: ${error.constraint}`);
|
|
623
|
+
console.error(`Table: ${error.table}, Column: ${error.column}`);
|
|
624
|
+
} else if (error instanceof ForeignKeyError) {
|
|
625
|
+
console.error(`Foreign key violation: ${error.constraint}`);
|
|
626
|
+
} else if (error instanceof NotNullError) {
|
|
627
|
+
console.error(`Not null constraint on: ${error.column}`);
|
|
628
|
+
}
|
|
629
|
+
throw error;
|
|
630
|
+
}
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
#### Diagnostic Utilities
|
|
634
|
+
|
|
635
|
+
Get helpful error explanations:
|
|
636
|
+
|
|
637
|
+
```typescript
|
|
638
|
+
import { diagnoseError, formatDiagnostic } from '@vertz/db';
|
|
639
|
+
|
|
640
|
+
try {
|
|
641
|
+
await db.users.create({ data: { email: null, name: 'Test' } });
|
|
642
|
+
} catch (error) {
|
|
643
|
+
const diagnostic = diagnoseError(error);
|
|
644
|
+
if (diagnostic) {
|
|
645
|
+
console.error(formatDiagnostic(diagnostic));
|
|
646
|
+
// Output:
|
|
647
|
+
// ERROR: Not null constraint violated on column "email"
|
|
648
|
+
// Table: users
|
|
649
|
+
// Suggestion: Ensure the email field is provided and not null
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
#### HTTP Error Mapping
|
|
655
|
+
|
|
656
|
+
Convert database errors to HTTP status codes:
|
|
657
|
+
|
|
658
|
+
```typescript
|
|
659
|
+
import { dbErrorToHttpError } from '@vertz/db';
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
await db.users.findOneOrThrow({ where: { id: userId } });
|
|
663
|
+
} catch (error) {
|
|
664
|
+
const httpError = dbErrorToHttpError(error);
|
|
665
|
+
return new Response(JSON.stringify(httpError), {
|
|
666
|
+
status: httpError.status,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// NotFoundError → 404
|
|
671
|
+
// UniqueConstraintError → 409 Conflict
|
|
672
|
+
// ForeignKeyError → 409 Conflict
|
|
673
|
+
// CheckConstraintError → 422 Unprocessable Entity
|
|
674
|
+
// NotNullError → 422 Unprocessable Entity
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
### Multi-Tenant Support
|
|
678
|
+
|
|
679
|
+
Built-in support for tenant isolation:
|
|
680
|
+
|
|
681
|
+
```typescript
|
|
682
|
+
// Define organization table
|
|
683
|
+
const organizations = d.table('organizations', {
|
|
684
|
+
id: d.uuid().primaryKey(),
|
|
685
|
+
name: d.text().notNull(),
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// Add tenant column to scoped tables
|
|
689
|
+
const posts = d.table('posts', {
|
|
690
|
+
id: d.uuid().primaryKey(),
|
|
691
|
+
organizationId: d.tenant(organizations), // Automatic FK to organizations.id
|
|
692
|
+
title: d.text().notNull(),
|
|
693
|
+
content: d.text().notNull(),
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
// Compute tenant graph (for automatic scoping)
|
|
697
|
+
import { computeTenantGraph } from '@vertz/db';
|
|
698
|
+
|
|
699
|
+
const tenantGraph = computeTenantGraph({
|
|
700
|
+
users: d.entry(users),
|
|
701
|
+
organizations: d.entry(organizations),
|
|
702
|
+
posts: d.entry(posts), // Will be marked as tenant-scoped
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
console.log(tenantGraph.scopedTables); // ['posts']
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
### Plugin System
|
|
709
|
+
|
|
710
|
+
Extend `@vertz/db` with custom behavior:
|
|
711
|
+
|
|
712
|
+
```typescript
|
|
713
|
+
import type { DbPlugin } from '@vertz/db/plugin';
|
|
714
|
+
|
|
715
|
+
const auditLogPlugin: DbPlugin = {
|
|
716
|
+
name: 'audit-log',
|
|
717
|
+
|
|
718
|
+
hooks: {
|
|
719
|
+
beforeCreate: async (tableName, data) => {
|
|
720
|
+
console.log(`Creating ${tableName}:`, data);
|
|
721
|
+
},
|
|
722
|
+
|
|
723
|
+
afterCreate: async (tableName, result) => {
|
|
724
|
+
await logToAuditTable(tableName, 'create', result);
|
|
725
|
+
},
|
|
726
|
+
|
|
727
|
+
beforeUpdate: async (tableName, where, data) => {
|
|
728
|
+
console.log(`Updating ${tableName}:`, { where, data });
|
|
729
|
+
},
|
|
730
|
+
|
|
731
|
+
afterDelete: async (tableName, result) => {
|
|
732
|
+
await logToAuditTable(tableName, 'delete', result);
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
const db = createDb({
|
|
738
|
+
url: process.env.DATABASE_URL!,
|
|
739
|
+
tables: { /* ... */ },
|
|
740
|
+
plugins: [auditLogPlugin],
|
|
741
|
+
});
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
## Type Safety Features
|
|
745
|
+
|
|
746
|
+
### Schema to Query Type Flow
|
|
747
|
+
|
|
748
|
+
Types flow automatically from schema definition to query results:
|
|
749
|
+
|
|
750
|
+
```typescript
|
|
751
|
+
// 1. Define schema
|
|
752
|
+
const users = d.table('users', {
|
|
753
|
+
id: d.uuid(),
|
|
754
|
+
email: d.email(),
|
|
755
|
+
name: d.text(),
|
|
756
|
+
age: d.integer().nullable(), // Optional field
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
// 2. Query with full inference
|
|
760
|
+
const user = await db.users.findOne({
|
|
761
|
+
where: { email: 'test@example.com' },
|
|
762
|
+
select: { id: true, name: true },
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
// 3. Type is inferred: { id: string; name: string } | null
|
|
766
|
+
|
|
767
|
+
// 4. With relations
|
|
768
|
+
const userWithPosts = await db.users.findOne({
|
|
769
|
+
where: { id: userId },
|
|
770
|
+
include: { posts: true },
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// 5. Type is inferred: { id: string; email: string; name: string; age: number | null; posts: Post[] } | null
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
### Insert Type Inference
|
|
777
|
+
|
|
778
|
+
Insert types respect `notNull()`, `defaultValue()`, and `nullable()`:
|
|
779
|
+
|
|
780
|
+
```typescript
|
|
781
|
+
const users = d.table('users', {
|
|
782
|
+
id: d.uuid().primaryKey().defaultValue('gen_random_uuid()'), // Auto-generated
|
|
783
|
+
email: d.email().notNull(), // Required
|
|
784
|
+
name: d.text().notNull(), // Required
|
|
785
|
+
bio: d.text().nullable(), // Optional
|
|
786
|
+
createdAt: d.timestamp().defaultValue('now()'), // Auto-generated
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
// Type inference for insert:
|
|
790
|
+
await db.users.create({
|
|
791
|
+
data: {
|
|
792
|
+
// id: NOT required (has default)
|
|
793
|
+
email: 'test@example.com', // REQUIRED
|
|
794
|
+
name: 'Test User', // REQUIRED
|
|
795
|
+
bio: null, // OPTIONAL (can be null or omitted)
|
|
796
|
+
// createdAt: NOT required (has default)
|
|
797
|
+
},
|
|
798
|
+
});
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
### Select Type Narrowing
|
|
802
|
+
|
|
803
|
+
Select only specific columns with full type safety:
|
|
804
|
+
|
|
805
|
+
```typescript
|
|
806
|
+
const user = await db.users.findOne({
|
|
807
|
+
where: { id: userId },
|
|
808
|
+
select: {
|
|
809
|
+
id: true,
|
|
810
|
+
email: true,
|
|
811
|
+
// name intentionally omitted
|
|
812
|
+
},
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
// Type: { id: string; email: string } | null
|
|
816
|
+
// user.name ← TypeScript error: Property 'name' does not exist
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
### Branded Error Types
|
|
820
|
+
|
|
821
|
+
Compile-time errors for invalid queries:
|
|
822
|
+
|
|
823
|
+
```typescript
|
|
824
|
+
// ❌ TypeScript error: Invalid column
|
|
825
|
+
await db.users.findMany({
|
|
826
|
+
where: { invalidColumn: 'value' }, // Error: 'invalidColumn' does not exist on User
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
// ❌ TypeScript error: Invalid relation
|
|
830
|
+
await db.users.findOne({
|
|
831
|
+
include: { invalidRelation: true }, // Error: 'invalidRelation' is not a valid relation
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
// ❌ TypeScript error: Invalid filter operator
|
|
835
|
+
await db.posts.findMany({
|
|
836
|
+
where: { title: { invalidOp: 'value' } }, // Error: 'invalidOp' is not a valid operator
|
|
837
|
+
});
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
## Integration with @vertz/schema
|
|
841
|
+
|
|
842
|
+
Use `@vertz/schema` for additional validation on JSONB columns:
|
|
843
|
+
|
|
844
|
+
```typescript
|
|
845
|
+
import { d } from '@vertz/db';
|
|
846
|
+
import { s } from '@vertz/schema';
|
|
847
|
+
|
|
848
|
+
// Define a schema for JSONB data
|
|
849
|
+
const MetadataSchema = s.object({
|
|
850
|
+
tags: s.array(s.string()),
|
|
851
|
+
priority: s.enum(['low', 'medium', 'high']),
|
|
852
|
+
dueDate: s.string().datetime().nullable(),
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
// Use the schema as a JSONB validator
|
|
856
|
+
const tasks = d.table('tasks', {
|
|
857
|
+
id: d.uuid().primaryKey(),
|
|
858
|
+
title: d.text().notNull(),
|
|
859
|
+
metadata: d.jsonb<typeof MetadataSchema._output>({
|
|
860
|
+
validator: (value) => MetadataSchema.parse(value),
|
|
861
|
+
}),
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
// Insert with validated JSONB
|
|
865
|
+
await db.tasks.create({
|
|
866
|
+
data: {
|
|
867
|
+
title: 'Complete documentation',
|
|
868
|
+
metadata: {
|
|
869
|
+
tags: ['docs', 'p0'],
|
|
870
|
+
priority: 'high',
|
|
871
|
+
dueDate: '2024-12-31T23:59:59Z',
|
|
872
|
+
},
|
|
873
|
+
},
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
// Query returns typed JSONB
|
|
877
|
+
const task = await db.tasks.findOne({ where: { id: taskId } });
|
|
878
|
+
// task.metadata is typed as { tags: string[]; priority: 'low' | 'medium' | 'high'; dueDate: string | null }
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
## Best Practices
|
|
882
|
+
|
|
883
|
+
1. **Use migrations in production** — Never use `push()` in production; always use `migrateDeploy()`
|
|
884
|
+
2. **Store schema snapshots** — Commit `schema-snapshot.json` to version control
|
|
885
|
+
3. **Leverage type inference** — Let TypeScript infer types; avoid manual type annotations
|
|
886
|
+
4. **Use relations wisely** — `include` loads related data, but use `select` to avoid over-fetching
|
|
887
|
+
5. **Prefer `findOneOrThrow`** — More explicit than null checks for required data
|
|
888
|
+
6. **Use connection pooling** — Configure `pool.max` based on your load
|
|
889
|
+
7. **Handle specific errors** — Catch `UniqueConstraintError`, `ForeignKeyError`, etc. for better UX
|
|
890
|
+
8. **Use `sql` template for raw queries** — Prevents SQL injection
|
|
891
|
+
9. **Test migrations locally** — Run `migrateDev` locally before deploying
|
|
892
|
+
|
|
893
|
+
## Casing Strategy
|
|
894
|
+
|
|
895
|
+
By default, `@vertz/db` uses `snake_case` for database column names (PostgreSQL convention):
|
|
896
|
+
|
|
897
|
+
```typescript
|
|
898
|
+
const users = d.table('users', {
|
|
899
|
+
firstName: d.text(), // Stored as "first_name" in database
|
|
900
|
+
lastName: d.text(), // Stored as "last_name"
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
// Use camelCase in queries:
|
|
904
|
+
await db.users.create({
|
|
905
|
+
data: { firstName: 'Alice', lastName: 'Smith' },
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
// Automatically converted to snake_case in SQL
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
To use `camelCase` in the database:
|
|
912
|
+
|
|
913
|
+
```typescript
|
|
914
|
+
const db = createDb({
|
|
915
|
+
url: process.env.DATABASE_URL!,
|
|
916
|
+
tables: { /* ... */ },
|
|
917
|
+
casing: 'camelCase',
|
|
918
|
+
});
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
## License
|
|
922
|
+
|
|
923
|
+
MIT
|