@vertz/db 0.2.0 → 0.2.1

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 CHANGED
@@ -1,606 +1,424 @@
1
1
  # @vertz/db
2
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
3
+ Type-safe database layer with schema-driven migrations, phantom types, and Result-based error handling.
15
4
 
16
5
  ## Installation
17
6
 
18
7
  ```bash
19
- npm install @vertz/db
8
+ bun add @vertz/db
20
9
  ```
21
10
 
22
11
  **Prerequisites:**
23
- - PostgreSQL database
24
- - Node.js >= 22
12
+ - PostgreSQL or SQLite database
13
+ - Node.js >= 22 or Bun
25
14
 
26
15
  ## Quick Start
27
16
 
28
- ### 1. Define Your Schema
29
-
30
17
  ```typescript
31
- import { d } from '@vertz/db';
18
+ import { d, createDb } from '@vertz/db';
32
19
 
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(),
20
+ // 1. Define table
21
+ const todosTable = d.table('todos', {
22
+ id: d.uuid().primary(),
23
+ title: d.text(),
24
+ completed: d.boolean().default(false),
25
+ createdAt: d.timestamp().default('now').readOnly(),
26
+ updatedAt: d.timestamp().autoUpdate(),
48
27
  });
49
28
 
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
- };
29
+ // 2. Create model (table + relations + derived schemas)
30
+ const todosModel = d.model(todosTable);
58
31
 
59
- // Create registry
32
+ // 3. Create database client
60
33
  const db = createDb({
61
34
  url: process.env.DATABASE_URL!,
62
- tables: {
63
- users: d.entry(users, userRelations),
64
- posts: d.entry(posts, postRelations),
65
- },
35
+ models: { todos: todosModel },
66
36
  });
67
- ```
68
-
69
- ### 2. Run Migrations
70
-
71
- ```typescript
72
- import { migrateDev } from '@vertz/db';
73
37
 
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',
38
+ // 4. Query with full type inference and Result-based errors
39
+ const result = await db.create('todos', {
40
+ data: { title: 'Buy milk' },
80
41
  });
81
42
 
82
- // Production: apply migrations from files
83
- import { migrateDeploy } from '@vertz/db';
84
-
85
- await migrateDeploy({
86
- queryFn: db.queryFn,
87
- migrationsDir: './migrations',
88
- });
43
+ if (result.ok) {
44
+ console.log(result.data);
45
+ // { id: string; title: string; completed: boolean; createdAt: Date; updatedAt: Date }
46
+ }
89
47
  ```
90
48
 
91
- ### 3. Query Your Data
49
+ ## Schema Builder (`d`)
50
+
51
+ ### Column Types
92
52
 
93
53
  ```typescript
94
- // Create a user
95
- const user = await db.users.create({
96
- data: {
97
- email: 'alice@example.com',
98
- name: 'Alice',
99
- },
100
- });
54
+ import { d } from '@vertz/db';
101
55
 
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
- });
56
+ // Text
57
+ d.text() // TEXT string
58
+ d.varchar(255) // VARCHAR(255) string
59
+ d.email() // TEXT with email format → string
109
60
 
110
- // Type-safe: usersWithPosts[0].posts is Post[]
61
+ // Identifiers
62
+ d.uuid() // UUID → string
111
63
 
112
- // Update a post
113
- await db.posts.update({
114
- where: { id: postId },
115
- data: { published: true },
116
- });
64
+ // Numeric
65
+ d.integer() // INTEGER → number
66
+ d.bigint() // BIGINT bigint
67
+ d.serial() // SERIAL (auto-increment) number
68
+ d.decimal(10, 2) // NUMERIC(10,2) → string
69
+ d.real() // REAL → number
70
+ d.doublePrecision() // DOUBLE PRECISION → number
117
71
 
118
- // Delete old posts
119
- await db.posts.deleteMany({
120
- where: {
121
- createdAt: { lt: new Date('2024-01-01') },
122
- },
123
- });
124
- ```
72
+ // Date/Time
73
+ d.timestamp() // TIMESTAMP WITH TIME ZONE → Date
74
+ d.date() // DATE → string
75
+ d.time() // TIME string
125
76
 
126
- ## API Reference
77
+ // Other
78
+ d.boolean() // BOOLEAN → boolean
79
+ d.jsonb<MyType>() // JSONB → MyType
80
+ d.jsonb<MyType>(schema) // JSONB with runtime validation
81
+ d.textArray() // TEXT[] → string[]
82
+ d.integerArray() // INTEGER[] → number[]
83
+ d.enum('status', ['active', 'inactive']) // ENUM → 'active' | 'inactive'
127
84
 
128
- ### Schema Builder (`d`)
85
+ // Multi-tenancy
86
+ d.tenant(orgsTable) // UUID FK to tenant root → string
87
+ ```
129
88
 
130
- The `d` object provides all schema building functions.
89
+ ### Column Modifiers
131
90
 
132
- #### Column Types
91
+ Columns are **required by default**. Use modifiers to change behavior:
133
92
 
134
93
  ```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
94
+ d.text()
95
+ .primary() // PRIMARY KEY (auto-excludes from inputs)
96
+ .primary({ generate: 'cuid' }) // PRIMARY KEY with ID generation
97
+ .unique() // UNIQUE constraint
98
+ .nullable() // Allows NULL (T | null)
99
+ .default('hello') // DEFAULT value (makes field optional in inserts)
100
+ .default('now') // DEFAULT NOW() for timestamps
101
+ .hidden() // Excluded from default SELECT queries
102
+ .readOnly() // Excluded from INSERT/UPDATE inputs
103
+ .sensitive() // Excluded when select: { not: 'sensitive' }
104
+ .autoUpdate() // Read-only + auto-updated on every write
105
+ .check('length(name) > 0') // SQL CHECK constraint
106
+ .references('users') // FK to users.id
107
+ .references('users', 'email') // FK to users.email
172
108
  ```
173
109
 
174
- #### Column Modifiers
110
+ ### ID Generation
175
111
 
176
112
  ```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
113
+ d.uuid().primary() // No auto-generation
114
+ d.uuid().primary({ generate: 'cuid' }) // CUID2
115
+ d.uuid().primary({ generate: 'uuid' }) // UUID v7
116
+ d.uuid().primary({ generate: 'nanoid' }) // Nano ID
117
+ d.serial().primary() // Auto-increment
183
118
  ```
184
119
 
185
- #### Defining Tables
120
+ ### Tables
186
121
 
187
122
  ```typescript
188
123
  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
- ],
124
+ id: d.uuid().primary({ generate: 'cuid' }),
125
+ email: d.email().unique(),
126
+ name: d.text(),
127
+ bio: d.text().nullable(),
128
+ isActive: d.boolean().default(true),
129
+ createdAt: d.timestamp().default('now').readOnly(),
130
+ updatedAt: d.timestamp().autoUpdate(),
196
131
  });
197
132
  ```
198
133
 
199
- #### Defining Relations
134
+ ### Annotations
200
135
 
201
- ```typescript
202
- // One-to-many
203
- const userRelations = {
204
- posts: d.ref.many(() => posts, 'authorId'),
205
- };
136
+ Column annotations control visibility and mutability across the stack:
206
137
 
207
- // Many-to-one
208
- const postRelations = {
209
- author: d.ref.one(() => users, 'authorId'),
210
- };
138
+ | Annotation | Effect on queries | Effect on inputs | Use case |
139
+ |---|---|---|---|
140
+ | `.hidden()` | Excluded from default SELECT | N/A | Internal fields (password hashes) |
141
+ | `.readOnly()` | Included in responses | Excluded from create/update | Server-managed fields |
142
+ | `.autoUpdate()` | Included in responses | Excluded from create/update | `updatedAt` timestamps |
143
+ | `.sensitive()` | Excluded with `select: { not: 'sensitive' }` | N/A | Fields to exclude in bulk queries |
211
144
 
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
- });
145
+ ### Phantom Types
217
146
 
218
- const postRelations = {
219
- tags: d.ref.many(() => tags).through(() => postTags, 'postId', 'tagId'),
220
- };
221
- ```
147
+ Every `TableDef` carries phantom type properties for compile-time type inference:
222
148
 
223
- ### Database Client
149
+ ```typescript
150
+ const users = d.table('users', {
151
+ id: d.uuid().primary(),
152
+ name: d.text(),
153
+ passwordHash: d.text().hidden(),
154
+ createdAt: d.timestamp().default('now').readOnly(),
155
+ });
224
156
 
225
- #### `createDb(options)`
157
+ type Response = typeof users.$response;
158
+ // { id: string; name: string; createdAt: Date }
159
+ // (passwordHash excluded — hidden)
226
160
 
227
- Create a database client instance.
161
+ type CreateInput = typeof users.$create_input;
162
+ // { name: string }
163
+ // (id excluded — primary, createdAt excluded — readOnly, passwordHash excluded — hidden)
228
164
 
229
- ```typescript
230
- import { createDb, d } from '@vertz/db';
165
+ type UpdateInput = typeof users.$update_input;
166
+ // { name?: string }
167
+ // (same exclusions, all fields optional)
231
168
 
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
- });
169
+ type Insert = typeof users.$insert;
170
+ // { name: string; passwordHash: string; createdAt?: Date }
171
+ // (id excluded — has default, createdAt optional — has default)
251
172
  ```
252
173
 
253
- **Returns:** `DatabaseInstance<TTables>` with typed table accessors.
254
-
255
- #### Query Methods
256
-
257
- All query methods are available on `db.<tableName>`:
174
+ | Phantom type | Description |
175
+ |---|---|
176
+ | `$response` | API response shape (excludes hidden) |
177
+ | `$create_input` | API create input (excludes readOnly + primary) |
178
+ | `$update_input` | API update input (same exclusions, all optional) |
179
+ | `$insert` | DB insert shape (columns with defaults are optional) |
180
+ | `$update` | DB update shape (non-PK columns, all optional) |
181
+ | `$infer` | Default SELECT (excludes hidden) |
182
+ | `$infer_all` | All columns including hidden |
183
+ | `$not_sensitive` | Excludes sensitive + hidden |
258
184
 
259
- ##### `findOne(options)`
185
+ ### Models
260
186
 
261
- Find a single record (returns `null` if not found).
187
+ `d.model()` combines a table with its relations and derived runtime schemas:
262
188
 
263
189
  ```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
190
+ // Without relations
191
+ const todosModel = d.model(todosTable);
192
+
193
+ // With relations
194
+ const usersModel = d.model(usersTable, {
195
+ posts: d.ref.many(() => postsTable, 'authorId'),
268
196
  });
269
197
 
270
- // Type: { id: string; name: string; posts: Post[] } | null
198
+ const postsModel = d.model(postsTable, {
199
+ author: d.ref.one(() => usersTable, 'authorId'),
200
+ comments: d.ref.many(() => commentsTable, 'postId'),
201
+ });
271
202
  ```
272
203
 
273
- ##### `findOneOrThrow(options)`
274
-
275
- Find a single record or throw `NotFoundError`.
204
+ Every model exposes:
276
205
 
277
206
  ```typescript
278
- const user = await db.users.findOneOrThrow({
279
- where: { id: userId },
280
- });
207
+ postsModel.table // the table definition
208
+ postsModel.relations // { author, comments }
209
+ postsModel.schemas.response // SchemaLike<$response>
210
+ postsModel.schemas.createInput // SchemaLike<$create_input>
211
+ postsModel.schemas.updateInput // SchemaLike<$update_input>
281
212
  ```
282
213
 
283
- ##### `findMany(options)`
284
-
285
- Find multiple records.
214
+ Models are used by `@vertz/server`'s `entity()` to derive validation and type-safe CRUD.
286
215
 
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
- ```
216
+ ## Relations
299
217
 
300
- **Cursor-based pagination:**
218
+ Define relations as the second argument to `d.model()`:
301
219
 
302
220
  ```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)`
221
+ import { d } from '@vertz/db';
312
222
 
313
- Find records and get total count (useful for pagination).
223
+ const usersTable = d.table('users', {
224
+ id: d.uuid().primary(),
225
+ name: d.text(),
226
+ });
314
227
 
315
- ```typescript
316
- const { rows, count } = await db.posts.findManyAndCount({
317
- where: { published: true },
318
- limit: 10,
319
- offset: 0,
228
+ const postsTable = d.table('posts', {
229
+ id: d.uuid().primary(),
230
+ title: d.text(),
231
+ authorId: d.uuid(),
320
232
  });
321
233
 
322
- console.log(`Showing ${rows.length} of ${count} posts`);
323
- ```
234
+ const commentsTable = d.table('comments', {
235
+ id: d.uuid().primary(),
236
+ body: d.text(),
237
+ postId: d.uuid(),
238
+ authorId: d.uuid(),
239
+ });
324
240
 
325
- ##### `create(options)`
241
+ // Models with relations
242
+ const usersModel = d.model(usersTable, {
243
+ posts: d.ref.many(() => postsTable, 'authorId'),
244
+ });
326
245
 
327
- Insert a single record.
246
+ const postsModel = d.model(postsTable, {
247
+ author: d.ref.one(() => usersTable, 'authorId'),
248
+ comments: d.ref.many(() => commentsTable, 'postId'),
249
+ });
328
250
 
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
251
+ const commentsModel = d.model(commentsTable, {
252
+ post: d.ref.one(() => postsTable, 'postId'),
253
+ author: d.ref.one(() => usersTable, 'authorId'),
336
254
  });
337
255
  ```
338
256
 
339
- ##### `createMany(options)`
340
-
341
- Insert multiple records (no return value).
257
+ ### Relation Types
342
258
 
343
259
  ```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)`
260
+ // belongsTo — FK lives on source table
261
+ d.ref.one(() => usersTable, 'authorId')
353
262
 
354
- Insert multiple records and return them.
263
+ // hasMany FK lives on target table
264
+ d.ref.many(() => postsTable, 'authorId')
355
265
 
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
- });
266
+ // Many-to-many — via join table
267
+ d.ref.many(() => coursesTable).through(() => enrollmentsTable, 'studentId', 'courseId')
364
268
  ```
365
269
 
366
- ##### `update(options)`
270
+ ## Database Client
367
271
 
368
- Update a single record.
272
+ ### Configuration
369
273
 
370
274
  ```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 },
275
+ const db = createDb({
276
+ url: 'postgresql://user:pass@localhost:5432/mydb',
277
+ models: { users: usersModel, posts: postsModel },
278
+ dialect: 'postgres', // 'postgres' (default) or 'sqlite'
279
+ pool: {
280
+ max: 20,
281
+ idleTimeout: 30000,
282
+ connectionTimeout: 5000,
283
+ replicas: ['postgresql://...'],
284
+ },
285
+ casing: 'snake_case', // column name transformation
286
+ log: (msg) => console.log(msg),
375
287
  });
376
288
  ```
377
289
 
378
- ##### `updateMany(options)`
290
+ ### Query Methods
379
291
 
380
- Update multiple records (returns count).
292
+ All methods return `Promise<Result<T, Error>>` — never throw.
381
293
 
382
294
  ```typescript
383
- const { count } = await db.posts.updateMany({
384
- where: { authorId: userId },
385
- data: { published: false },
295
+ // Read
296
+ const user = await db.get('users', { where: { id: userId } });
297
+ const users = await db.list('users', {
298
+ where: { isActive: true },
299
+ orderBy: { createdAt: 'desc' },
300
+ limit: 10,
301
+ include: { posts: true },
386
302
  });
303
+ const { data, total } = await db.listAndCount('users', { where: { isActive: true } });
387
304
 
388
- console.log(`Updated ${count} posts`);
389
- ```
390
-
391
- ##### `upsert(options)`
392
-
393
- Insert or update (based on unique constraint).
305
+ // Write
306
+ const created = await db.create('users', {
307
+ data: { name: 'Alice', email: 'alice@example.com' },
308
+ });
309
+ const updated = await db.update('users', {
310
+ where: { id: userId },
311
+ data: { name: 'Bob' },
312
+ });
313
+ const deleted = await db.delete('users', { where: { id: userId } });
394
314
 
395
- ```typescript
396
- const user = await db.users.upsert({
315
+ // Upsert
316
+ const upserted = await db.upsert('users', {
397
317
  where: { email: 'alice@example.com' },
398
- create: {
399
- email: 'alice@example.com',
400
- name: 'Alice',
401
- },
402
- update: {
403
- name: 'Alice Updated',
404
- },
318
+ create: { email: 'alice@example.com', name: 'Alice' },
319
+ update: { name: 'Alice Updated' },
405
320
  });
321
+
322
+ // Bulk
323
+ await db.createMany('users', { data: [{ name: 'A' }, { name: 'B' }] });
324
+ await db.updateMany('users', { where: { isActive: false }, data: { isActive: true } });
325
+ await db.deleteMany('users', { where: { isActive: false } });
406
326
  ```
407
327
 
408
- ##### `delete(options)`
328
+ ### Result-Based Error Handling
409
329
 
410
- Delete a single record.
330
+ Query methods return `Result<T, ReadError | WriteError>` instead of throwing:
411
331
 
412
332
  ```typescript
413
- const deleted = await db.users.delete({
414
- where: { id: userId },
415
- select: { id: true, email: true },
416
- });
417
- ```
333
+ import { match, matchErr } from '@vertz/schema';
418
334
 
419
- ##### `deleteMany(options)`
335
+ const result = await db.create('users', {
336
+ data: { email: 'exists@example.com', name: 'Alice' },
337
+ });
420
338
 
421
- Delete multiple records (returns count).
339
+ if (result.ok) {
340
+ console.log('Created:', result.data);
341
+ } else {
342
+ console.log('Failed:', result.error);
343
+ }
422
344
 
423
- ```typescript
424
- const { count } = await db.posts.deleteMany({
425
- where: {
426
- createdAt: { lt: new Date('2024-01-01') },
427
- },
345
+ // Pattern matching
346
+ match(result, {
347
+ ok: (user) => console.log('Created:', user.name),
348
+ err: (error) => console.log('Error:', error.code),
428
349
  });
429
-
430
- console.log(`Deleted ${count} old posts`);
431
350
  ```
432
351
 
433
- #### Filter Operators
434
-
435
- Use operators in `where` clauses:
352
+ ### Filter Operators
436
353
 
437
354
  ```typescript
438
- await db.posts.findMany({
355
+ await db.list('users', {
439
356
  where: {
440
357
  // Equality
441
- published: true,
442
-
358
+ isActive: true,
359
+
443
360
  // 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
-
361
+ age: { gte: 18, lte: 65 },
362
+
449
363
  // Pattern matching
450
- title: { like: '%tutorial%' },
451
- email: { ilike: '%@EXAMPLE.COM%' }, // case-insensitive
452
-
364
+ name: { contains: 'Smith' },
365
+ email: { startsWith: 'admin' },
366
+
453
367
  // Set operations
454
- status: { in: ['draft', 'published'] },
455
- category: { notIn: ['spam', 'deleted'] },
456
-
368
+ role: { in: ['admin', 'moderator'] },
369
+ status: { notIn: ['deleted'] },
370
+
457
371
  // Null checks
458
372
  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
373
  },
472
374
  });
473
375
  ```
474
376
 
475
- #### Aggregation
377
+ ### Select & Include
476
378
 
477
379
  ```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 },
380
+ // Select specific fields
381
+ await db.get('users', {
382
+ where: { id: userId },
383
+ select: { id: true, name: true, email: true },
491
384
  });
492
385
 
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',
386
+ // Exclude by visibility
387
+ await db.list('users', {
388
+ select: { not: 'sensitive' }, // excludes sensitive + hidden fields
541
389
  });
542
390
 
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',
391
+ // Include relations
392
+ await db.list('posts', {
393
+ include: { author: true, comments: true },
563
394
  });
564
-
565
- console.log(`Applied ${result.appliedCount} migrations`);
566
395
  ```
567
396
 
568
- #### `migrateStatus(options)`
569
-
570
- Check migration status.
397
+ ### Aggregation
571
398
 
572
399
  ```typescript
573
- import { migrateStatus } from '@vertz/db';
400
+ // Count
401
+ const count = await db.count('users', { where: { isActive: true } });
574
402
 
575
- const status = await migrateStatus({
576
- queryFn: db.queryFn,
577
- migrationsDir: './migrations',
403
+ // Aggregate functions
404
+ await db.aggregate('orders', {
405
+ where: { status: 'completed' },
406
+ _count: true,
407
+ _sum: { price: true },
408
+ _avg: { amount: true },
409
+ _min: { discount: true },
410
+ _max: { total: true },
578
411
  });
579
412
 
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(),
413
+ // Group by
414
+ await db.groupBy('orders', {
415
+ by: ['customerId', 'status'],
416
+ _count: true,
417
+ _sum: { total: true },
596
418
  });
597
-
598
- console.log(`Pushed changes to: ${result.tablesAffected.join(', ')}`);
599
419
  ```
600
420
 
601
- ### Error Handling
602
-
603
- `@vertz/db` provides typed error classes for common database errors:
421
+ ## Error Types
604
422
 
605
423
  ```typescript
606
424
  import {
@@ -610,312 +428,212 @@ import {
610
428
  NotNullError,
611
429
  CheckConstraintError,
612
430
  ConnectionError,
613
- DbError,
431
+ dbErrorToHttpError,
614
432
  } 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
433
  ```
632
434
 
633
- #### Diagnostic Utilities
634
-
635
- Get helpful error explanations:
435
+ | Error | HTTP Status | When |
436
+ |---|---|---|
437
+ | `NotFoundError` | 404 | Record not found |
438
+ | `UniqueConstraintError` | 409 | Duplicate unique value |
439
+ | `ForeignKeyError` | 409 | Referenced record doesn't exist |
440
+ | `NotNullError` | 422 | Required field missing |
441
+ | `CheckConstraintError` | 422 | CHECK constraint violated |
442
+ | `ConnectionError` | 503 | Database unreachable |
636
443
 
637
444
  ```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
- }
445
+ const httpError = dbErrorToHttpError(error);
446
+ // Converts any db error to the appropriate HTTP status
652
447
  ```
653
448
 
654
- #### HTTP Error Mapping
655
-
656
- Convert database errors to HTTP status codes:
449
+ ## Diagnostics
657
450
 
658
451
  ```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
- }
452
+ import { diagnoseError, formatDiagnostic, explainError } from '@vertz/db';
669
453
 
670
- // NotFoundError 404
671
- // UniqueConstraintError → 409 Conflict
672
- // ForeignKeyError → 409 Conflict
673
- // CheckConstraintError 422 Unprocessable Entity
674
- // NotNullError → 422 Unprocessable Entity
675
- ```
454
+ const diagnostic = diagnoseError(error.message);
455
+ // {
456
+ // code: 'NOT_NULL_VIOLATION',
457
+ // explanation: 'Not null constraint violated on column "email"',
458
+ // table: 'users',
459
+ // suggestion: 'Ensure the email field is provided and not null'
460
+ // }
676
461
 
677
- ### Multi-Tenant Support
462
+ console.log(formatDiagnostic(diagnostic));
463
+ console.log(explainError(error.message));
464
+ ```
678
465
 
679
- Built-in support for tenant isolation:
466
+ ## Multi-Tenancy
680
467
 
681
468
  ```typescript
682
- // Define organization table
683
- const organizations = d.table('organizations', {
684
- id: d.uuid().primaryKey(),
685
- name: d.text().notNull(),
469
+ import { d, computeTenantGraph } from '@vertz/db';
470
+
471
+ const orgsTable = d.table('organizations', {
472
+ id: d.uuid().primary(),
473
+ name: d.text(),
686
474
  });
687
475
 
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(),
476
+ const usersTable = d.table('users', {
477
+ id: d.uuid().primary(),
478
+ email: d.email(),
479
+ orgId: d.tenant(orgsTable), // scopes this table to a tenant
694
480
  });
695
481
 
696
- // Compute tenant graph (for automatic scoping)
697
- import { computeTenantGraph } from '@vertz/db';
482
+ const orgsModel = d.model(orgsTable);
483
+ const usersModel = d.model(usersTable);
698
484
 
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
- });
485
+ const tenantGraph = computeTenantGraph({ organizations: orgsModel, users: usersModel });
704
486
 
705
- console.log(tenantGraph.scopedTables); // ['posts']
487
+ tenantGraph.root; // 'organizations'
488
+ tenantGraph.directlyScoped; // ['users']
706
489
  ```
707
490
 
708
- ### Plugin System
491
+ Tables can be marked as shared (cross-tenant):
492
+
493
+ ```typescript
494
+ const settings = d.table('settings', { /* ... */ }).shared();
495
+ ```
709
496
 
710
- Extend `@vertz/db` with custom behavior:
497
+ ## Dialects
711
498
 
712
499
  ```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
- };
500
+ import { createDb, defaultPostgresDialect, defaultSqliteDialect } from '@vertz/db';
736
501
 
737
- const db = createDb({
738
- url: process.env.DATABASE_URL!,
739
- tables: { /* ... */ },
740
- plugins: [auditLogPlugin],
502
+ // PostgreSQL (default)
503
+ const pgDb = createDb({ url: 'postgresql://...', models });
504
+
505
+ // SQLite
506
+ const sqliteDb = createDb({
507
+ models,
508
+ dialect: 'sqlite',
509
+ d1: d1Database, // Cloudflare D1 or compatible
741
510
  });
742
511
  ```
743
512
 
744
- ## Type Safety Features
513
+ ## Migrations
745
514
 
746
- ### Schema to Query Type Flow
515
+ ```bash
516
+ # Generate and apply migrations (development)
517
+ vertz db migrate
747
518
 
748
- Types flow automatically from schema definition to query results:
519
+ # Apply migrations from files (production)
520
+ vertz db migrate --deploy
749
521
 
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
- });
522
+ # Check migration status
523
+ vertz db migrate --status
758
524
 
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
- });
525
+ # Push schema directly without migration files (dev only)
526
+ vertz db push
527
+ ```
764
528
 
765
- // 3. Type is inferred: { id: string; name: string } | null
529
+ ### Programmatic API
766
530
 
767
- // 4. With relations
768
- const userWithPosts = await db.users.findOne({
769
- where: { id: userId },
770
- include: { posts: true },
531
+ ```typescript
532
+ import { migrateDev, migrateDeploy, migrateStatus, push } from '@vertz/db';
533
+
534
+ await migrateDev({
535
+ queryFn: db.queryFn,
536
+ currentSnapshot: db.snapshot,
537
+ previousSnapshot: loadFromFile(),
538
+ migrationsDir: './migrations',
771
539
  });
772
540
 
773
- // 5. Type is inferred: { id: string; email: string; name: string; age: number | null; posts: Post[] } | null
541
+ await migrateDeploy({
542
+ queryFn: db.queryFn,
543
+ migrationsDir: './migrations',
544
+ });
774
545
  ```
775
546
 
776
- ### Insert Type Inference
547
+ ### Auto-Migrate
777
548
 
778
- Insert types respect `notNull()`, `defaultValue()`, and `nullable()`:
549
+ For development workflows, `autoMigrate` diffs the current schema against a saved snapshot and applies changes automatically — no migration files needed.
779
550
 
780
551
  ```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
- },
552
+ import { autoMigrate } from '@vertz/db';
553
+
554
+ await autoMigrate({
555
+ currentSchema, // from d.table() definitions
556
+ snapshotPath: '.vertz/schema-snapshot.json',
557
+ dialect: 'sqlite',
558
+ db: queryFn,
798
559
  });
799
560
  ```
800
561
 
801
- ### Select Type Narrowing
802
-
803
- Select only specific columns with full type safety:
562
+ When using `createDbProvider`, auto-migration runs automatically in non-production environments:
804
563
 
805
564
  ```typescript
806
- const user = await db.users.findOne({
807
- where: { id: userId },
808
- select: {
809
- id: true,
810
- email: true,
811
- // name intentionally omitted
565
+ import { createDbProvider } from '@vertz/db/core';
566
+
567
+ const dbProvider = createDbProvider({
568
+ url: process.env.DATABASE_URL!,
569
+ models: { users: { table: users, relations: {} } },
570
+ migrations: {
571
+ autoApply: true, // explicit opt-in (defaults to NODE_ENV !== 'production')
572
+ snapshotPath: '.vertz/schema-snapshot.json',
812
573
  },
813
574
  });
814
-
815
- // Type: { id: string; email: string } | null
816
- // user.name ← TypeScript error: Property 'name' does not exist
817
575
  ```
818
576
 
819
- ### Branded Error Types
577
+ ### Custom Snapshot Storage
820
578
 
821
- Compile-time errors for invalid queries:
579
+ By default, snapshots are stored on the filesystem via `NodeSnapshotStorage`. For non-Node runtimes (Cloudflare Workers, Deno Deploy) or custom backends, implement the `SnapshotStorage` interface:
822
580
 
823
581
  ```typescript
824
- // TypeScript error: Invalid column
825
- await db.users.findMany({
826
- where: { invalidColumn: 'value' }, // Error: 'invalidColumn' does not exist on User
827
- });
582
+ import type { SnapshotStorage, SchemaSnapshot } from '@vertz/db';
828
583
 
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
- ```
584
+ class KVSnapshotStorage implements SnapshotStorage {
585
+ constructor(private kv: KVNamespace) {}
839
586
 
840
- ## Integration with @vertz/schema
587
+ async load(key: string): Promise<SchemaSnapshot | null> {
588
+ const data = await this.kv.get(key);
589
+ return data ? JSON.parse(data) : null;
590
+ }
841
591
 
842
- Use `@vertz/schema` for additional validation on JSONB columns:
592
+ async save(key: string, snapshot: SchemaSnapshot): Promise<void> {
593
+ await this.kv.put(key, JSON.stringify(snapshot));
594
+ }
595
+ }
843
596
 
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
- },
597
+ // Pass to autoMigrate or createDbProvider
598
+ await autoMigrate({
599
+ currentSchema,
600
+ snapshotPath: 'schema-snapshot',
601
+ dialect: 'sqlite',
602
+ db: queryFn,
603
+ storage: new KVSnapshotStorage(env.SNAPSHOTS),
604
+ });
605
+
606
+ // Or via the provider
607
+ const dbProvider = createDbProvider({
608
+ url: env.DATABASE_URL,
609
+ models,
610
+ migrations: {
611
+ storage: new KVSnapshotStorage(env.SNAPSHOTS),
873
612
  },
874
613
  });
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
614
  ```
880
615
 
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
616
+ | Interface | Method | Description |
617
+ |-----------|--------|-------------|
618
+ | `SnapshotStorage` | `load(key: string)` | Load a snapshot by key. Returns `null` on first run |
619
+ | `SnapshotStorage` | `save(key: string, snapshot)` | Persist a snapshot |
620
+ | `NodeSnapshotStorage` | (class) | Built-in filesystem implementation using `node:fs` |
894
621
 
895
- By default, `@vertz/db` uses `snake_case` for database column names (PostgreSQL convention):
622
+ ## Raw SQL
896
623
 
897
624
  ```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
- });
625
+ import { sql } from '@vertz/db/sql';
907
626
 
908
- // Automatically converted to snake_case in SQL
909
- ```
627
+ const result = await db.query(
628
+ sql`SELECT * FROM users WHERE email = ${email}`
629
+ );
910
630
 
911
- To use `camelCase` in the database:
631
+ // Composition
632
+ const where = sql`WHERE active = ${true}`;
633
+ const query = sql`SELECT * FROM users ${where}`;
912
634
 
913
- ```typescript
914
- const db = createDb({
915
- url: process.env.DATABASE_URL!,
916
- tables: { /* ... */ },
917
- casing: 'camelCase',
918
- });
635
+ // Raw (unparameterized)
636
+ const col = sql.raw('created_at');
919
637
  ```
920
638
 
921
639
  ## License