@vertz/db 0.2.0 → 0.2.3
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 +412 -694
- package/dist/d1/index.d.ts +241 -0
- package/dist/d1/index.js +8 -0
- package/dist/diagnostic/index.js +1 -1
- package/dist/index.d.ts +932 -626
- package/dist/index.js +1759 -533
- package/dist/internals.d.ts +96 -31
- package/dist/internals.js +8 -7
- package/dist/plugin/index.d.ts +1 -1
- package/dist/plugin/index.js +7 -3
- package/dist/postgres/index.d.ts +77 -0
- package/dist/postgres/index.js +7 -0
- package/dist/shared/{chunk-3f2grpak.js → chunk-0e1vy9qd.js} +147 -52
- package/dist/shared/chunk-2gd1fqcw.js +7 -0
- package/dist/shared/{chunk-xp022dyp.js → chunk-agyds4jw.js} +25 -19
- package/dist/shared/chunk-dvwe5jsq.js +7 -0
- package/dist/shared/chunk-j4kwq1gh.js +5 -0
- package/dist/shared/{chunk-wj026daz.js → chunk-k04v1jjx.js} +2 -2
- package/dist/shared/chunk-kb4tnn2k.js +26 -0
- package/dist/shared/chunk-pnk6yzjv.js +48 -0
- package/dist/shared/chunk-rqe0prft.js +100 -0
- package/dist/shared/chunk-sfmyxz6r.js +306 -0
- package/dist/shared/chunk-ssga2xea.js +9 -0
- package/dist/shared/{chunk-hrfdj0rr.js → chunk-v2qm94qp.js} +12 -2
- package/dist/sql/index.d.ts +61 -61
- package/dist/sql/index.js +2 -2
- package/dist/sqlite/index.d.ts +221 -0
- package/dist/sqlite/index.js +845 -0
- package/package.json +32 -5
package/README.md
CHANGED
|
@@ -1,606 +1,424 @@
|
|
|
1
1
|
# @vertz/db
|
|
2
2
|
|
|
3
|
-
Type-safe database layer
|
|
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
|
-
|
|
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
|
|
34
|
-
const
|
|
35
|
-
id: d.uuid().
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
createdAt: d.timestamp().
|
|
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
|
-
//
|
|
51
|
-
const
|
|
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
|
|
32
|
+
// 3. Create database client
|
|
60
33
|
const db = createDb({
|
|
61
34
|
url: process.env.DATABASE_URL!,
|
|
62
|
-
|
|
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
|
-
//
|
|
75
|
-
await
|
|
76
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
49
|
+
## Schema Builder (`d`)
|
|
50
|
+
|
|
51
|
+
### Column Types
|
|
92
52
|
|
|
93
53
|
```typescript
|
|
94
|
-
|
|
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
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
//
|
|
61
|
+
// Identifiers
|
|
62
|
+
d.uuid() // UUID → string
|
|
111
63
|
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
+
// Multi-tenancy
|
|
86
|
+
d.tenant(orgsTable) // UUID FK to tenant root → string
|
|
87
|
+
```
|
|
129
88
|
|
|
130
|
-
|
|
89
|
+
### Column Modifiers
|
|
131
90
|
|
|
132
|
-
|
|
91
|
+
Columns are **required by default**. Use modifiers to change behavior:
|
|
133
92
|
|
|
134
93
|
```typescript
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
110
|
+
### ID Generation
|
|
175
111
|
|
|
176
112
|
```typescript
|
|
177
|
-
d.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
120
|
+
### Tables
|
|
186
121
|
|
|
187
122
|
```typescript
|
|
188
123
|
const users = d.table('users', {
|
|
189
|
-
id: d.uuid().
|
|
190
|
-
email: d.email().unique()
|
|
191
|
-
name: d.text()
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
134
|
+
### Annotations
|
|
200
135
|
|
|
201
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
213
|
-
const postTags = d.table('post_tags', {
|
|
214
|
-
postId: d.uuid().notNull(),
|
|
215
|
-
tagId: d.uuid().notNull(),
|
|
216
|
-
});
|
|
145
|
+
### Phantom Types
|
|
217
146
|
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
+
type Response = typeof users.$response;
|
|
158
|
+
// { id: string; name: string; createdAt: Date }
|
|
159
|
+
// (passwordHash excluded — hidden)
|
|
226
160
|
|
|
227
|
-
|
|
161
|
+
type CreateInput = typeof users.$create_input;
|
|
162
|
+
// { name: string }
|
|
163
|
+
// (id excluded — primary, createdAt excluded — readOnly, passwordHash excluded — hidden)
|
|
228
164
|
|
|
229
|
-
|
|
230
|
-
|
|
165
|
+
type UpdateInput = typeof users.$update_input;
|
|
166
|
+
// { name?: string }
|
|
167
|
+
// (same exclusions, all fields optional)
|
|
231
168
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
185
|
+
### Models
|
|
260
186
|
|
|
261
|
-
|
|
187
|
+
`d.model()` combines a table with its relations and derived runtime schemas:
|
|
262
188
|
|
|
263
189
|
```typescript
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
Find a single record or throw `NotFoundError`.
|
|
204
|
+
Every model exposes:
|
|
276
205
|
|
|
277
206
|
```typescript
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
Find multiple records.
|
|
214
|
+
Models are used by `@vertz/server`'s `entity()` to derive validation and type-safe CRUD.
|
|
286
215
|
|
|
287
|
-
|
|
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
|
-
|
|
218
|
+
Define relations as the second argument to `d.model()`:
|
|
301
219
|
|
|
302
220
|
```typescript
|
|
303
|
-
|
|
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
|
-
|
|
223
|
+
const usersTable = d.table('users', {
|
|
224
|
+
id: d.uuid().primary(),
|
|
225
|
+
name: d.text(),
|
|
226
|
+
});
|
|
314
227
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
+
// Models with relations
|
|
242
|
+
const usersModel = d.model(usersTable, {
|
|
243
|
+
posts: d.ref.many(() => postsTable, 'authorId'),
|
|
244
|
+
});
|
|
326
245
|
|
|
327
|
-
|
|
246
|
+
const postsModel = d.model(postsTable, {
|
|
247
|
+
author: d.ref.one(() => usersTable, 'authorId'),
|
|
248
|
+
comments: d.ref.many(() => commentsTable, 'postId'),
|
|
249
|
+
});
|
|
328
250
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
Insert multiple records (no return value).
|
|
257
|
+
### Relation Types
|
|
342
258
|
|
|
343
259
|
```typescript
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
263
|
+
// hasMany — FK lives on target table
|
|
264
|
+
d.ref.many(() => postsTable, 'authorId')
|
|
355
265
|
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
270
|
+
## Database Client
|
|
367
271
|
|
|
368
|
-
|
|
272
|
+
### Configuration
|
|
369
273
|
|
|
370
274
|
```typescript
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
290
|
+
### Query Methods
|
|
379
291
|
|
|
380
|
-
|
|
292
|
+
All methods return `Promise<Result<T, Error>>` — never throw.
|
|
381
293
|
|
|
382
294
|
```typescript
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
396
|
-
const
|
|
315
|
+
// Upsert
|
|
316
|
+
const upserted = await db.upsert('users', {
|
|
397
317
|
where: { email: 'alice@example.com' },
|
|
398
|
-
create: {
|
|
399
|
-
|
|
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
|
-
|
|
328
|
+
### Result-Based Error Handling
|
|
409
329
|
|
|
410
|
-
|
|
330
|
+
Query methods return `Result<T, ReadError | WriteError>` instead of throwing:
|
|
411
331
|
|
|
412
332
|
```typescript
|
|
413
|
-
|
|
414
|
-
where: { id: userId },
|
|
415
|
-
select: { id: true, email: true },
|
|
416
|
-
});
|
|
417
|
-
```
|
|
333
|
+
import { match, matchErr } from '@vertz/schema';
|
|
418
334
|
|
|
419
|
-
|
|
335
|
+
const result = await db.create('users', {
|
|
336
|
+
data: { email: 'exists@example.com', name: 'Alice' },
|
|
337
|
+
});
|
|
420
338
|
|
|
421
|
-
|
|
339
|
+
if (result.ok) {
|
|
340
|
+
console.log('Created:', result.data);
|
|
341
|
+
} else {
|
|
342
|
+
console.log('Failed:', result.error);
|
|
343
|
+
}
|
|
422
344
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
Use operators in `where` clauses:
|
|
352
|
+
### Filter Operators
|
|
436
353
|
|
|
437
354
|
```typescript
|
|
438
|
-
await db.
|
|
355
|
+
await db.list('users', {
|
|
439
356
|
where: {
|
|
440
357
|
// Equality
|
|
441
|
-
|
|
442
|
-
|
|
358
|
+
isActive: true,
|
|
359
|
+
|
|
443
360
|
// Comparison
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
451
|
-
email: {
|
|
452
|
-
|
|
364
|
+
name: { contains: 'Smith' },
|
|
365
|
+
email: { startsWith: 'admin' },
|
|
366
|
+
|
|
453
367
|
// Set operations
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
377
|
+
### Select & Include
|
|
476
378
|
|
|
477
379
|
```typescript
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
where: {
|
|
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
|
-
//
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
544
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
Check migration status.
|
|
397
|
+
### Aggregation
|
|
571
398
|
|
|
572
399
|
```typescript
|
|
573
|
-
|
|
400
|
+
// Count
|
|
401
|
+
const count = await db.count('users', { where: { isActive: true } });
|
|
574
402
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
581
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
Convert database errors to HTTP status codes:
|
|
449
|
+
## Diagnostics
|
|
657
450
|
|
|
658
451
|
```typescript
|
|
659
|
-
import {
|
|
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
|
-
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
//
|
|
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
|
-
|
|
462
|
+
console.log(formatDiagnostic(diagnostic));
|
|
463
|
+
console.log(explainError(error.message));
|
|
464
|
+
```
|
|
678
465
|
|
|
679
|
-
|
|
466
|
+
## Multi-Tenancy
|
|
680
467
|
|
|
681
468
|
```typescript
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
697
|
-
|
|
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
|
-
|
|
487
|
+
tenantGraph.root; // 'organizations'
|
|
488
|
+
tenantGraph.directlyScoped; // ['users']
|
|
706
489
|
```
|
|
707
490
|
|
|
708
|
-
|
|
491
|
+
Tables can be marked as shared (cross-tenant):
|
|
492
|
+
|
|
493
|
+
```typescript
|
|
494
|
+
const settings = d.table('settings', { /* ... */ }).shared();
|
|
495
|
+
```
|
|
709
496
|
|
|
710
|
-
|
|
497
|
+
## Dialects
|
|
711
498
|
|
|
712
499
|
```typescript
|
|
713
|
-
import
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
##
|
|
513
|
+
## Migrations
|
|
745
514
|
|
|
746
|
-
|
|
515
|
+
```bash
|
|
516
|
+
# Generate and apply migrations (development)
|
|
517
|
+
vertz db migrate
|
|
747
518
|
|
|
748
|
-
|
|
519
|
+
# Apply migrations from files (production)
|
|
520
|
+
vertz db migrate --deploy
|
|
749
521
|
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
select: { id: true, name: true },
|
|
763
|
-
});
|
|
525
|
+
# Push schema directly without migration files (dev only)
|
|
526
|
+
vertz db push
|
|
527
|
+
```
|
|
764
528
|
|
|
765
|
-
|
|
529
|
+
### Programmatic API
|
|
766
530
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
541
|
+
await migrateDeploy({
|
|
542
|
+
queryFn: db.queryFn,
|
|
543
|
+
migrationsDir: './migrations',
|
|
544
|
+
});
|
|
774
545
|
```
|
|
775
546
|
|
|
776
|
-
###
|
|
547
|
+
### Auto-Migrate
|
|
777
548
|
|
|
778
|
-
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
-
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
-
###
|
|
577
|
+
### Custom Snapshot Storage
|
|
820
578
|
|
|
821
|
-
|
|
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
|
-
|
|
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
|
-
|
|
830
|
-
|
|
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
|
-
|
|
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
|
-
|
|
592
|
+
async save(key: string, snapshot: SchemaSnapshot): Promise<void> {
|
|
593
|
+
await this.kv.put(key, JSON.stringify(snapshot));
|
|
594
|
+
}
|
|
595
|
+
}
|
|
843
596
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
-
|
|
622
|
+
## Raw SQL
|
|
896
623
|
|
|
897
624
|
```typescript
|
|
898
|
-
|
|
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
|
-
|
|
909
|
-
|
|
627
|
+
const result = await db.query(
|
|
628
|
+
sql`SELECT * FROM users WHERE email = ${email}`
|
|
629
|
+
);
|
|
910
630
|
|
|
911
|
-
|
|
631
|
+
// Composition
|
|
632
|
+
const where = sql`WHERE active = ${true}`;
|
|
633
|
+
const query = sql`SELECT * FROM users ${where}`;
|
|
912
634
|
|
|
913
|
-
|
|
914
|
-
const
|
|
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
|