@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 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