@b9g/zen 0.1.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/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +1044 -0
- package/chunk-2IEEEMRN.js +38 -0
- package/chunk-56M5Z3A6.js +1346 -0
- package/chunk-QXGEP5PB.js +310 -0
- package/ddl-NAJM37GQ.js +9 -0
- package/package.json +102 -0
- package/src/bun.d.ts +50 -0
- package/src/bun.js +906 -0
- package/src/mysql.d.ts +62 -0
- package/src/mysql.js +573 -0
- package/src/postgres.d.ts +62 -0
- package/src/postgres.js +555 -0
- package/src/sqlite.d.ts +43 -0
- package/src/sqlite.js +447 -0
- package/src/zen.d.ts +14 -0
- package/src/zen.js +2143 -0
package/README.md
ADDED
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
# ZenDB
|
|
2
|
+
The simple database client.
|
|
3
|
+
|
|
4
|
+
Define tables. Write SQL. Get objects.
|
|
5
|
+
|
|
6
|
+
Cultivate your data.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @b9g/zen zod
|
|
12
|
+
|
|
13
|
+
# In Node, install a driver (choose one):
|
|
14
|
+
npm install better-sqlite3 # for SQLite
|
|
15
|
+
npm install postgres # for PostgreSQL
|
|
16
|
+
npm install mysql2 # for MySQL
|
|
17
|
+
|
|
18
|
+
# Bun has a driver which uses Bun.SQL
|
|
19
|
+
bun install @b9g/zen zod
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
import {Database} from "@b9g/zen";
|
|
24
|
+
import BunDriver from "@b9g/zen/bun";
|
|
25
|
+
import SQLiteDriver from "@b9g/zen/sqlite";
|
|
26
|
+
import PostgresDriver from "@b9g/zen/postgres";
|
|
27
|
+
import MySQLDriver from "@b9g/zen/mysql";
|
|
28
|
+
|
|
29
|
+
// Each driver implements the `Driver` interface
|
|
30
|
+
// and is a separate module in the package
|
|
31
|
+
|
|
32
|
+
const sqliteDriver = new SQLiteDriver("file:app.db");
|
|
33
|
+
const postgresDriver = new PostgresDriver("postgresql://localhost/mydb");
|
|
34
|
+
const mySQLDriver = new MySQLDriver("mysql://localhost/mydb");
|
|
35
|
+
|
|
36
|
+
// Bun auto-detects dialect from the connection URL.
|
|
37
|
+
const bunDriver = new BunDriver("sqlite://app.db");
|
|
38
|
+
|
|
39
|
+
const db = new Database(bunDriver);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick Start
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
import {z, table, Database} from "@b9g/zen";
|
|
46
|
+
import SQLiteDriver from "@b9g/zen/sqlite";
|
|
47
|
+
|
|
48
|
+
const driver = new SQLiteDriver("file:app.db");
|
|
49
|
+
|
|
50
|
+
// 1. Define tables
|
|
51
|
+
const Users = table("users", {
|
|
52
|
+
id: z.string().uuid().db.primary().db.auto(),
|
|
53
|
+
email: z.string().email().db.unique(),
|
|
54
|
+
name: z.string(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const Posts = table("posts", {
|
|
58
|
+
id: z.string().uuid().db.primary().db.auto(),
|
|
59
|
+
authorId: z.string().uuid().db.references(Users, "author"),
|
|
60
|
+
title: z.string(),
|
|
61
|
+
published: z.boolean().default(false),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// 2. Create database with migrations
|
|
65
|
+
const db = new Database(driver);
|
|
66
|
+
|
|
67
|
+
db.addEventListener("upgradeneeded", (e) => {
|
|
68
|
+
e.waitUntil((async () => {
|
|
69
|
+
if (e.oldVersion < 1) {
|
|
70
|
+
// Create tables (includes PK/unique/FK on new tables)
|
|
71
|
+
await db.ensureTable(Users);
|
|
72
|
+
await db.ensureTable(Posts);
|
|
73
|
+
// For existing tables when adding FKs/uniques later, call ensureConstraints()
|
|
74
|
+
}
|
|
75
|
+
if (e.oldVersion < 2) {
|
|
76
|
+
// Evolve schema: add avatar column (safe, additive)
|
|
77
|
+
const UsersV2 = table("users", {
|
|
78
|
+
id: z.string().uuid().db.primary().db.auto(),
|
|
79
|
+
email: z.string().email().db.unique(),
|
|
80
|
+
name: z.string(),
|
|
81
|
+
avatar: z.string().optional(),
|
|
82
|
+
});
|
|
83
|
+
await db.ensureTable(UsersV2); // adds missing columns/indexes
|
|
84
|
+
await db.ensureConstraints(UsersV2); // applies new constraints on existing data
|
|
85
|
+
}
|
|
86
|
+
})());
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await db.open(2);
|
|
90
|
+
|
|
91
|
+
// 3. Insert with validation (id auto-generated)
|
|
92
|
+
const user = await db.insert(Users, {
|
|
93
|
+
email: "alice@example.com",
|
|
94
|
+
name: "Alice",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// 4. Query with normalization
|
|
98
|
+
const posts = await db.all([Posts, Users])`
|
|
99
|
+
JOIN "users" ON ${Users.on(Posts)}
|
|
100
|
+
WHERE ${Posts.cols.published} = ${true}
|
|
101
|
+
`;
|
|
102
|
+
|
|
103
|
+
const author = posts[0].author;
|
|
104
|
+
author?.name; // "Alice" — resolved from JOIN
|
|
105
|
+
author === posts[1].author; // true — same instance
|
|
106
|
+
|
|
107
|
+
// 5. Get by primary key
|
|
108
|
+
const post = await db.get(Posts, posts[0].id);
|
|
109
|
+
|
|
110
|
+
// 6. Update
|
|
111
|
+
await db.update(Users, {name: "Alice Smith"}, user.id);
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Table Definitions
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
import {z, table} from "@b9g/zen";
|
|
118
|
+
import type {Row} from "@b9g/zen";
|
|
119
|
+
|
|
120
|
+
const Users = table("users", {
|
|
121
|
+
id: z.string().uuid().db.primary().db.auto(),
|
|
122
|
+
email: z.string().email().db.unique(),
|
|
123
|
+
name: z.string().max(100),
|
|
124
|
+
role: z.enum(["user", "admin"]).default("user"),
|
|
125
|
+
createdAt: z.date().db.auto(),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const Posts = table("posts", {
|
|
129
|
+
id: z.string().uuid().db.primary().db.auto(),
|
|
130
|
+
title: z.string(),
|
|
131
|
+
content: z.string().optional(),
|
|
132
|
+
authorId: z.string().uuid().db.references(Users, "author", {onDelete: "cascade"}),
|
|
133
|
+
published: z.boolean().default(false),
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**The `.db` namespace:**
|
|
138
|
+
|
|
139
|
+
The `.db` property is available on all Zod types imported from `@b9g/zen`. It provides database-specific modifiers:
|
|
140
|
+
|
|
141
|
+
- `.db.primary()` — Primary key
|
|
142
|
+
- `.db.unique()` — Unique constraint
|
|
143
|
+
- `.db.index()` — Create an index
|
|
144
|
+
- `.db.auto()` — Auto-generate value on insert (type-aware)
|
|
145
|
+
- `.db.references(table, as, {field?, reverseAs?, onDelete?})` — Foreign key with resolved property name
|
|
146
|
+
- `.db.softDelete()` — Soft delete timestamp field
|
|
147
|
+
- `.db.encode(fn)` — Custom encoding for database storage
|
|
148
|
+
- `.db.decode(fn)` — Custom decoding from database storage
|
|
149
|
+
- `.db.type(columnType)` — Explicit column type for DDL generation
|
|
150
|
+
|
|
151
|
+
**How does `.db` work?** When you import `z` from `@b9g/zen`, it's already extended with the `.db` namespace. The extension happens once when the module loads. If you need to extend a separate Zod instance, use `extendZod(z)`.
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
import {z} from "zod";
|
|
155
|
+
import {extendZod} from "@b9g/zen";
|
|
156
|
+
extendZod(z);
|
|
157
|
+
// .db is available on all Zod types
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
**Auto-generated values with `.db.auto()`:**
|
|
161
|
+
|
|
162
|
+
The `.db.auto()` modifier auto-generates values on insert based on the field type:
|
|
163
|
+
|
|
164
|
+
| Type | Behavior |
|
|
165
|
+
|------|----------|
|
|
166
|
+
| `z.string().uuid()` | Generates UUID via `crypto.randomUUID()` |
|
|
167
|
+
| `z.number().int()` | Auto-increment (database-side) |
|
|
168
|
+
| `z.date()` | Current timestamp via `NOW` |
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
const Users = table("users", {
|
|
172
|
+
id: z.string().uuid().db.primary().db.auto(), // UUID generated on insert
|
|
173
|
+
name: z.string(),
|
|
174
|
+
createdAt: z.date().db.auto(), // NOW on insert
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// id and createdAt are optional - auto-generated if not provided
|
|
178
|
+
const user = await db.insert(Users, {name: "Alice"});
|
|
179
|
+
user.id; // "550e8400-e29b-41d4-a716-446655440000"
|
|
180
|
+
user.createdAt; // 2024-01-15T10:30:00.000Z
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**Automatic JSON encoding/decoding:**
|
|
184
|
+
|
|
185
|
+
Objects (`z.object()`) and arrays (`z.array()`) are automatically serialized to JSON when stored and parsed back when read:
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
const Settings = table("settings", {
|
|
189
|
+
id: z.string().uuid().db.primary().db.auto(),
|
|
190
|
+
config: z.object({theme: z.string(), fontSize: z.number()}),
|
|
191
|
+
tags: z.array(z.string()),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// On insert: config and tags are JSON.stringify'd
|
|
195
|
+
const settings = await db.insert(Settings, {
|
|
196
|
+
config: {theme: "dark", fontSize: 14},
|
|
197
|
+
tags: ["admin", "premium"],
|
|
198
|
+
});
|
|
199
|
+
// Stored as: config='{"theme":"dark","fontSize":14}', tags='["admin","premium"]'
|
|
200
|
+
|
|
201
|
+
// On read: JSON strings are parsed back to objects/arrays
|
|
202
|
+
settings.config.theme; // "dark" (object, not string)
|
|
203
|
+
settings.tags[0]; // "admin" (array, not string)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**Custom encoding/decoding:**
|
|
207
|
+
|
|
208
|
+
Override automatic JSON encoding with custom transformations:
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
const Custom = table("custom", {
|
|
212
|
+
id: z.string().db.primary(),
|
|
213
|
+
// Store array as CSV instead of JSON
|
|
214
|
+
tags: z.array(z.string())
|
|
215
|
+
.db.encode((arr) => arr.join(","))
|
|
216
|
+
.db.decode((str: string) => str.split(","))
|
|
217
|
+
.db.type("TEXT"), // Required: explicit column type for DDL
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await db.insert(Custom, {id: "c1", tags: ["a", "b", "c"]});
|
|
221
|
+
// Stored as: tags='a,b,c' (not '["a","b","c"]')
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**Explicit column types:**
|
|
225
|
+
|
|
226
|
+
When using custom encode/decode that transforms the storage type (e.g., array → CSV string), use `.db.type()` to specify the correct column type for DDL generation:
|
|
227
|
+
|
|
228
|
+
| Scenario | Column Type |
|
|
229
|
+
|----------|-------------|
|
|
230
|
+
| `z.object()` / `z.array()` (no codec) | JSON/JSONB (automatic) |
|
|
231
|
+
| `z.object()` / `z.array()` + encode only | JSON/JSONB (advanced use) |
|
|
232
|
+
| `z.object()` / `z.array()` + encode + decode | **Explicit `.db.type()` required** |
|
|
233
|
+
|
|
234
|
+
Without `.db.type()`, DDL generation would incorrectly use JSONB for a field that's actually stored as TEXT.
|
|
235
|
+
|
|
236
|
+
**Soft delete:**
|
|
237
|
+
```typescript
|
|
238
|
+
const Users = table("users", {
|
|
239
|
+
id: z.string().uuid().db.primary().db.auto(),
|
|
240
|
+
name: z.string(),
|
|
241
|
+
deletedAt: z.date().nullable().db.softDelete(),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const userId = "u1";
|
|
245
|
+
|
|
246
|
+
// Soft delete a record (sets deletedAt to current timestamp)
|
|
247
|
+
await db.softDelete(Users, userId);
|
|
248
|
+
|
|
249
|
+
// Hard delete (permanent removal)
|
|
250
|
+
await db.delete(Users, userId);
|
|
251
|
+
|
|
252
|
+
// Filter out soft-deleted records in queries
|
|
253
|
+
const activeUsers = await db.all(Users)`
|
|
254
|
+
WHERE NOT ${Users.deleted()}
|
|
255
|
+
`;
|
|
256
|
+
// → WHERE NOT "users"."deletedAt" IS NOT NULL
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
**Compound indexes** via table options:
|
|
260
|
+
```typescript
|
|
261
|
+
const Posts = table("posts", {
|
|
262
|
+
id: z.string().db.primary(),
|
|
263
|
+
authorId: z.string(),
|
|
264
|
+
createdAt: z.date(),
|
|
265
|
+
}, {
|
|
266
|
+
indexes: [["authorId", "createdAt"]],
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
**Derived properties** for client-side transformations:
|
|
271
|
+
```typescript
|
|
272
|
+
const Posts = table("posts", {
|
|
273
|
+
id: z.string().db.primary(),
|
|
274
|
+
title: z.string(),
|
|
275
|
+
authorId: z.string().db.references(Users, "author", {
|
|
276
|
+
reverseAs: "posts"
|
|
277
|
+
}),
|
|
278
|
+
}, {
|
|
279
|
+
derive: {
|
|
280
|
+
// Pure functions only (no I/O, no side effects)
|
|
281
|
+
titleUpper: (post) => post.title.toUpperCase(),
|
|
282
|
+
tags: (post) => post.postTags?.map(pt => pt.tag) ?? [],
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const posts = await db.all([Posts, Users])`
|
|
287
|
+
JOIN "users" ON ${Users.on(Posts)}
|
|
288
|
+
`;
|
|
289
|
+
|
|
290
|
+
type PostWithDerived = Row<typeof Posts> & {titleUpper: string; tags: string[]};
|
|
291
|
+
const post = posts[0] as PostWithDerived;
|
|
292
|
+
post.titleUpper; // ✅ "HELLO WORLD"
|
|
293
|
+
Object.keys(post); // ["id", "title", "authorId", "author"] (no "titleUpper")
|
|
294
|
+
JSON.stringify(post); // ✅ Excludes derived properties (non-enumerable)
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Derived properties:
|
|
298
|
+
- Are lazy getters (computed on access, not stored)
|
|
299
|
+
- Are non-enumerable (hidden from `Object.keys()` and `JSON.stringify()`)
|
|
300
|
+
- Must be pure functions (no I/O, no database queries)
|
|
301
|
+
- Only transform data already in the entity
|
|
302
|
+
- Are NOT part of TypeScript type inference
|
|
303
|
+
|
|
304
|
+
**Partial selects** with `pick()`:
|
|
305
|
+
```typescript
|
|
306
|
+
const UserSummary = Users.pick("id", "name");
|
|
307
|
+
const posts = await db.all([Posts, UserSummary])`
|
|
308
|
+
JOIN "users" ON ${UserSummary.on(Posts)}
|
|
309
|
+
`;
|
|
310
|
+
// posts[0].author has only id and name
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
**Table identity**: A table definition is a singleton value which is passed to database methods for validation, normalization, schema management, and convenient CRUD operations. It is not a class.
|
|
314
|
+
|
|
315
|
+
## Queries
|
|
316
|
+
|
|
317
|
+
Tagged templates with automatic parameterization:
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
const title = "Hello";
|
|
321
|
+
const postId = "p1";
|
|
322
|
+
const userId = "u1";
|
|
323
|
+
|
|
324
|
+
// Single table query
|
|
325
|
+
const posts = await db.all(Posts)`WHERE published = ${true}`;
|
|
326
|
+
|
|
327
|
+
// Multi-table with joins — pass array
|
|
328
|
+
const posts = await db.all([Posts, Users])`
|
|
329
|
+
JOIN "users" ON ${Users.on(Posts)}
|
|
330
|
+
WHERE ${Posts.cols.published} = ${true}
|
|
331
|
+
`;
|
|
332
|
+
|
|
333
|
+
// Get single entity
|
|
334
|
+
const post = await db.get(Posts)`WHERE ${Posts.cols.title} = ${title}`;
|
|
335
|
+
|
|
336
|
+
// Get by primary key (convenience)
|
|
337
|
+
const post = await db.get(Posts, postId);
|
|
338
|
+
|
|
339
|
+
// Raw queries (no normalization)
|
|
340
|
+
const counts = await db.query<{count: number}>`
|
|
341
|
+
SELECT COUNT(*) as count FROM ${Posts} WHERE ${Posts.cols.authorId} = ${userId}
|
|
342
|
+
`;
|
|
343
|
+
|
|
344
|
+
// Execute statements
|
|
345
|
+
await db.exec`CREATE INDEX idx_posts_author ON ${Posts}(${Posts.cols.authorId})`;
|
|
346
|
+
|
|
347
|
+
// Single value
|
|
348
|
+
const count = await db.val<number>`SELECT COUNT(*) FROM ${Posts}`;
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
## Fragment Helpers
|
|
352
|
+
|
|
353
|
+
Type-safe SQL fragments as methods on Table objects:
|
|
354
|
+
|
|
355
|
+
```typescript
|
|
356
|
+
const postId = "p1";
|
|
357
|
+
const rows = [
|
|
358
|
+
{id: "p1", title: "Hello", published: true},
|
|
359
|
+
{id: "p2", title: "World", published: false},
|
|
360
|
+
];
|
|
361
|
+
|
|
362
|
+
// UPDATE with set()
|
|
363
|
+
await db.exec`
|
|
364
|
+
UPDATE ${Posts}
|
|
365
|
+
SET ${Posts.set({title: "New Title", published: true})}
|
|
366
|
+
WHERE ${Posts.cols.id} = ${postId}
|
|
367
|
+
`;
|
|
368
|
+
// → UPDATE "posts" SET "title" = ?, "published" = ? WHERE "posts"."id" = ?
|
|
369
|
+
|
|
370
|
+
// JOIN with on()
|
|
371
|
+
const posts = await db.all([Posts, Users])`
|
|
372
|
+
JOIN "users" ON ${Users.on(Posts)}
|
|
373
|
+
WHERE ${Posts.cols.published} = ${true}
|
|
374
|
+
`;
|
|
375
|
+
// → JOIN "users" ON "users"."id" = "posts"."authorId"
|
|
376
|
+
|
|
377
|
+
// Bulk INSERT with values()
|
|
378
|
+
await db.exec`
|
|
379
|
+
INSERT INTO ${Posts} ${Posts.values(rows)}
|
|
380
|
+
`;
|
|
381
|
+
// → INSERT INTO "posts" ("id", "title", "published") VALUES (?, ?, ?), (?, ?, ?)
|
|
382
|
+
|
|
383
|
+
// Qualified column names with cols
|
|
384
|
+
const posts = await db.all([Posts, Users])`
|
|
385
|
+
JOIN "users" ON ${Users.on(Posts)}
|
|
386
|
+
ORDER BY ${Posts.cols.title} DESC
|
|
387
|
+
`;
|
|
388
|
+
// → ORDER BY "posts"."title" DESC
|
|
389
|
+
|
|
390
|
+
// Safe IN clause with in()
|
|
391
|
+
const postIds = ["id1", "id2", "id3"];
|
|
392
|
+
const posts = await db.all(Posts)`WHERE ${Posts.in("id", postIds)}`;
|
|
393
|
+
// → WHERE "posts"."id" IN (?, ?, ?)
|
|
394
|
+
|
|
395
|
+
// Empty arrays handled correctly
|
|
396
|
+
const posts = await db.all(Posts)`WHERE ${Posts.in("id", [])}`;
|
|
397
|
+
// → WHERE 1 = 0
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
## CRUD Helpers
|
|
401
|
+
|
|
402
|
+
```typescript
|
|
403
|
+
// Insert with Zod validation (uses RETURNING to get actual row)
|
|
404
|
+
const user = await db.insert(Users, {
|
|
405
|
+
email: "alice@example.com",
|
|
406
|
+
name: "Alice",
|
|
407
|
+
});
|
|
408
|
+
// Returns actual row from DB, including auto-generated id and DB-computed defaults
|
|
409
|
+
const userId = user.id;
|
|
410
|
+
|
|
411
|
+
// Update by primary key (uses RETURNING)
|
|
412
|
+
const updated = await db.update(Users, {name: "Bob"}, userId);
|
|
413
|
+
|
|
414
|
+
// Delete by primary key
|
|
415
|
+
await db.delete(Users, userId);
|
|
416
|
+
|
|
417
|
+
// Soft delete (sets deletedAt timestamp, requires softDelete() field)
|
|
418
|
+
await db.softDelete(Users, userId);
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
**RETURNING support:** `insert()` and `update()` use `RETURNING *` on SQLite and PostgreSQL to return the actual row from the database, including DB-computed defaults and triggers. MySQL falls back to a separate SELECT.
|
|
422
|
+
|
|
423
|
+
## Transactions
|
|
424
|
+
|
|
425
|
+
```typescript
|
|
426
|
+
await db.transaction(async (tx) => {
|
|
427
|
+
const user = await tx.insert(Users, {
|
|
428
|
+
email: "alice@example.com",
|
|
429
|
+
name: "Alice",
|
|
430
|
+
});
|
|
431
|
+
await tx.insert(Posts, {
|
|
432
|
+
authorId: user.id,
|
|
433
|
+
title: "Hello",
|
|
434
|
+
published: true,
|
|
435
|
+
});
|
|
436
|
+
// Commits on success, rollbacks on error
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// Returns values
|
|
440
|
+
const user = await db.transaction(async (tx) => {
|
|
441
|
+
return await tx.insert(Users, {
|
|
442
|
+
email: "bob@example.com",
|
|
443
|
+
name: "Bob",
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
## Migrations
|
|
449
|
+
|
|
450
|
+
IndexedDB-style event-based migrations:
|
|
451
|
+
|
|
452
|
+
```typescript
|
|
453
|
+
db.addEventListener("upgradeneeded", (e) => {
|
|
454
|
+
e.waitUntil((async () => {
|
|
455
|
+
if (e.oldVersion < 1) {
|
|
456
|
+
await db.exec`${Users.ddl()}`;
|
|
457
|
+
await db.exec`${Posts.ddl()}`;
|
|
458
|
+
}
|
|
459
|
+
if (e.oldVersion < 2) {
|
|
460
|
+
await db.exec`${Posts.ensureColumn("views")}`;
|
|
461
|
+
}
|
|
462
|
+
if (e.oldVersion < 3) {
|
|
463
|
+
await db.exec`${Posts.ensureIndex(["title"])}`;
|
|
464
|
+
}
|
|
465
|
+
})());
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
await db.open(3); // Opens at version 3, fires upgradeneeded if needed
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
**Migration rules:**
|
|
472
|
+
- Migrations run sequentially from `oldVersion + 1` to `newVersion`
|
|
473
|
+
- If a migration crashes, the version does not bump
|
|
474
|
+
- You must keep migration code around indefinitely (forward-only, no down migrations)
|
|
475
|
+
- Multi-process safe via exclusive locking
|
|
476
|
+
|
|
477
|
+
**Why EventTarget?** Web standard pattern (like IndexedDB's `onupgradeneeded`). Third-party code can subscribe to lifecycle events without changing constructor signatures, enabling plugins for logging, tracing, and instrumentation.
|
|
478
|
+
|
|
479
|
+
### Safe Migration Helpers
|
|
480
|
+
|
|
481
|
+
zen provides idempotent helpers that encourage safe, additive-only migrations:
|
|
482
|
+
|
|
483
|
+
```typescript
|
|
484
|
+
// Add a new column (reads from schema)
|
|
485
|
+
const Posts = table("posts", {
|
|
486
|
+
id: z.string().db.primary(),
|
|
487
|
+
title: z.string(),
|
|
488
|
+
views: z.number().default(0), // NEW - add to schema
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
if (e.oldVersion < 2) {
|
|
492
|
+
await db.exec`${Posts.ensureColumn("views")}`;
|
|
493
|
+
}
|
|
494
|
+
// → ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "views" REAL DEFAULT 0
|
|
495
|
+
|
|
496
|
+
// Add an index
|
|
497
|
+
if (e.oldVersion < 3) {
|
|
498
|
+
await db.exec`${Posts.ensureIndex(["title", "views"])}`;
|
|
499
|
+
}
|
|
500
|
+
// → CREATE INDEX IF NOT EXISTS "idx_posts_title_views" ON "posts"("title", "views")
|
|
501
|
+
|
|
502
|
+
// Safe column rename (additive, non-destructive)
|
|
503
|
+
const Users = table("users", {
|
|
504
|
+
emailAddress: z.string().email(), // renamed from "email"
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
if (e.oldVersion < 4) {
|
|
508
|
+
await db.exec`${Users.ensureColumn("emailAddress")}`;
|
|
509
|
+
await db.exec`${Users.copyColumn("email", "emailAddress")}`;
|
|
510
|
+
// Keep old "email" column for backwards compat
|
|
511
|
+
// Drop it in a later migration if needed (manual SQL)
|
|
512
|
+
}
|
|
513
|
+
// → UPDATE "users" SET "emailAddress" = "email" WHERE "emailAddress" IS NULL
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
**Helper methods:**
|
|
517
|
+
- `table.ensureColumn(fieldName, options?)` - Idempotent ALTER TABLE ADD COLUMN
|
|
518
|
+
- `table.ensureIndex(fields, options?)` - Idempotent CREATE INDEX
|
|
519
|
+
- `table.copyColumn(from, to)` - Copy data between columns (for safe renames)
|
|
520
|
+
|
|
521
|
+
All helpers read from your table schema (single source of truth) and are safe to run multiple times (idempotent).
|
|
522
|
+
|
|
523
|
+
**Destructive operations** (DROP COLUMN, etc.) are not provided - write raw SQL if truly needed:
|
|
524
|
+
```typescript
|
|
525
|
+
// Manual destructive operation
|
|
526
|
+
if (e.oldVersion < 5) {
|
|
527
|
+
await db.exec`ALTER TABLE ${Users} DROP COLUMN deprecated_field`;
|
|
528
|
+
}
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
**Dialect support:**
|
|
532
|
+
|
|
533
|
+
### Default column types
|
|
534
|
+
|
|
535
|
+
| Feature | SQLite | PostgreSQL | MySQL |
|
|
536
|
+
|---------|--------|------------|-------|
|
|
537
|
+
| Date type | TEXT | TIMESTAMPTZ | DATETIME |
|
|
538
|
+
| Date default | CURRENT_TIMESTAMP | NOW() | CURRENT_TIMESTAMP |
|
|
539
|
+
| Boolean | INTEGER | BOOLEAN | BOOLEAN |
|
|
540
|
+
| JSON | TEXT | JSONB | TEXT |
|
|
541
|
+
| Quoting | "double" | "double" | \`backtick\` |
|
|
542
|
+
|
|
543
|
+
## Entity Normalization
|
|
544
|
+
|
|
545
|
+
Normalization is driven by table metadata, not query shape — SQL stays unrestricted.
|
|
546
|
+
|
|
547
|
+
The `all()`/`get()` methods:
|
|
548
|
+
1. Generate SELECT with prefixed column aliases (`posts.id AS "posts.id"`)
|
|
549
|
+
2. Parse rows into per-table entities
|
|
550
|
+
3. Deduplicate by primary key (same PK = same object instance)
|
|
551
|
+
4. Resolve `references()` to actual entity objects (forward and reverse)
|
|
552
|
+
|
|
553
|
+
**Typed relationships:** When you pass multiple tables to `db.all([Posts, Users])`, the return type includes optional relationship properties based on your `references()` declarations. They can be `null` when the foreign key is missing or the JOIN yields no row, so use optional chaining.
|
|
554
|
+
|
|
555
|
+
### Forward References (belongs-to)
|
|
556
|
+
|
|
557
|
+
```typescript
|
|
558
|
+
const Posts = table("posts", {
|
|
559
|
+
id: z.string().db.primary(),
|
|
560
|
+
authorId: z.string().db.references(Users, "author"),
|
|
561
|
+
title: z.string(),
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const posts = await db.all([Posts, Users])`
|
|
565
|
+
JOIN "users" ON ${Users.on(Posts)}
|
|
566
|
+
`;
|
|
567
|
+
posts[0].author?.name; // typed as string | undefined
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
### Reverse References (has-many)
|
|
571
|
+
|
|
572
|
+
Use `reverseAs` to populate arrays of referencing entities:
|
|
573
|
+
|
|
574
|
+
```typescript
|
|
575
|
+
const Posts = table("posts", {
|
|
576
|
+
id: z.string().db.primary(),
|
|
577
|
+
authorId: z.string().db.references(Users, "author", {
|
|
578
|
+
reverseAs: "posts" // Populate author.posts = Post[]
|
|
579
|
+
}),
|
|
580
|
+
title: z.string(),
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
const posts = await db.all([Posts, Users])`
|
|
584
|
+
JOIN "users" ON ${Users.on(Posts)}
|
|
585
|
+
`;
|
|
586
|
+
posts[0].author?.posts; // [{id: "p1", ...}, {id: "p2", ...}]
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
**Note:** Reverse relationships are runtime-only materializations that reflect data in the current query result set. No automatic JOINs, lazy loading, or cascade fetching.
|
|
590
|
+
|
|
591
|
+
### Many-to-Many
|
|
592
|
+
|
|
593
|
+
```typescript
|
|
594
|
+
const Posts = table("posts", {
|
|
595
|
+
id: z.string().db.primary(),
|
|
596
|
+
title: z.string(),
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
const Tags = table("tags", {
|
|
600
|
+
id: z.string().db.primary(),
|
|
601
|
+
name: z.string(),
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
const PostTags = table("post_tags", {
|
|
605
|
+
id: z.string().db.primary(),
|
|
606
|
+
postId: z.string().db.references(Posts, "post", {reverseAs: "postTags"}),
|
|
607
|
+
tagId: z.string().db.references(Tags, "tag", {reverseAs: "postTags"}),
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
const postId = "p1";
|
|
611
|
+
|
|
612
|
+
const results = await db.all([PostTags, Posts, Tags])`
|
|
613
|
+
JOIN "posts" ON ${Posts.on(PostTags)}
|
|
614
|
+
JOIN "tags" ON ${Tags.on(PostTags)}
|
|
615
|
+
WHERE ${Posts.cols.id} = ${postId}
|
|
616
|
+
`;
|
|
617
|
+
|
|
618
|
+
// Access through join table:
|
|
619
|
+
const tags = results.map((pt) => pt.tag);
|
|
620
|
+
|
|
621
|
+
// Or access via reverse relationship:
|
|
622
|
+
const post = results[0].post;
|
|
623
|
+
post?.postTags?.forEach((pt) => console.log(pt.tag?.name));
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
### Serialization Rules
|
|
627
|
+
|
|
628
|
+
References and derived properties have specific serialization behavior to prevent circular JSON and distinguish stored vs computed data:
|
|
629
|
+
|
|
630
|
+
```typescript
|
|
631
|
+
const posts = await db.all([Posts, Users])`
|
|
632
|
+
JOIN "users" ON ${Users.on(Posts)}
|
|
633
|
+
`;
|
|
634
|
+
|
|
635
|
+
const post = posts[0];
|
|
636
|
+
|
|
637
|
+
// Forward references (belongs-to): enumerable and immutable
|
|
638
|
+
Object.keys(post); // ["id", "title", "authorId", "author"]
|
|
639
|
+
JSON.stringify(post); // Includes "author"
|
|
640
|
+
|
|
641
|
+
// Reverse references (has-many): non-enumerable and immutable
|
|
642
|
+
const author = post.author;
|
|
643
|
+
if (author) {
|
|
644
|
+
Object.keys(author); // ["id", "name"] (no "posts")
|
|
645
|
+
JSON.stringify(author); // Excludes "posts" (prevents circular JSON)
|
|
646
|
+
author.posts; // Accessible (just hidden from enumeration)
|
|
647
|
+
|
|
648
|
+
// Circular references are safe:
|
|
649
|
+
JSON.stringify(post); // No error
|
|
650
|
+
// {
|
|
651
|
+
// "id": "p1",
|
|
652
|
+
// "title": "Hello",
|
|
653
|
+
// "authorId": "u1",
|
|
654
|
+
// "author": {"id": "u1", "name": "Alice"} // No "posts" = no cycle
|
|
655
|
+
// }
|
|
656
|
+
|
|
657
|
+
// Explicit inclusion when needed:
|
|
658
|
+
const explicit = {...author, posts: author.posts};
|
|
659
|
+
JSON.stringify(explicit); // Now includes posts
|
|
660
|
+
}
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
**Why this design:**
|
|
664
|
+
- Forward refs are safe to serialize (no cycles by themselves)
|
|
665
|
+
- Reverse refs create cycles when paired with forward refs
|
|
666
|
+
- Non-enumerable reverse refs prevent accidental circular JSON errors
|
|
667
|
+
- Both are immutable to prevent confusion (these are query results, not mutable objects)
|
|
668
|
+
- Explicit spread syntax when you need reverse refs in output
|
|
669
|
+
|
|
670
|
+
## Type Inference
|
|
671
|
+
|
|
672
|
+
```typescript
|
|
673
|
+
type User = Row<typeof Users>; // Full row type (after read)
|
|
674
|
+
type NewUser = Insert<typeof Users>; // Insert type (respects defaults/.db.auto())
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
## Field Metadata
|
|
678
|
+
|
|
679
|
+
Tables expose metadata for form generation:
|
|
680
|
+
|
|
681
|
+
```typescript
|
|
682
|
+
const fields = Users.fields();
|
|
683
|
+
// {
|
|
684
|
+
// email: { name: "email", type: "email", required: true, unique: true },
|
|
685
|
+
// name: { name: "name", type: "text", required: true, maxLength: 100 },
|
|
686
|
+
// role: { name: "role", type: "select", options: ["user", "admin"], default: "user" },
|
|
687
|
+
// }
|
|
688
|
+
|
|
689
|
+
const pkName = Users.primaryKey(); // "id" (field name)
|
|
690
|
+
const pkFragment = Users.primary; // SQLTemplate: "users"."id"
|
|
691
|
+
const refs = Posts.references(); // [{fieldName: "authorId", table: Users, as: "author"}]
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
## Performance
|
|
695
|
+
|
|
696
|
+
- Tagged template queries are cached by template object identity (compiled once per call site)
|
|
697
|
+
- Normalization cost is O(rows) with hash maps per table
|
|
698
|
+
- Reference resolution is zero-cost after deduplication
|
|
699
|
+
|
|
700
|
+
## Driver Interface
|
|
701
|
+
|
|
702
|
+
Drivers implement a template-based interface where each method receives `(TemplateStringsArray, values[])` and builds SQL with native placeholders:
|
|
703
|
+
|
|
704
|
+
```typescript
|
|
705
|
+
interface Driver {
|
|
706
|
+
// Query methods - build SQL with native placeholders (? or $1, $2, ...)
|
|
707
|
+
all<T>(strings: TemplateStringsArray, values: unknown[]): Promise<T[]>;
|
|
708
|
+
get<T>(strings: TemplateStringsArray, values: unknown[]): Promise<T | null>;
|
|
709
|
+
run(strings: TemplateStringsArray, values: unknown[]): Promise<number>;
|
|
710
|
+
val<T>(strings: TemplateStringsArray, values: unknown[]): Promise<T | null>;
|
|
711
|
+
|
|
712
|
+
// Connection management
|
|
713
|
+
close(): Promise<void>;
|
|
714
|
+
transaction<T>(fn: (tx: Driver) => Promise<T>): Promise<T>;
|
|
715
|
+
|
|
716
|
+
// Capabilities
|
|
717
|
+
readonly supportsReturning: boolean;
|
|
718
|
+
|
|
719
|
+
// Optional
|
|
720
|
+
withMigrationLock?<T>(fn: () => Promise<T>): Promise<T>;
|
|
721
|
+
}
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
**Why templates?** Drivers receive raw template parts and build SQL with their native placeholder syntax (`?` for SQLite/MySQL, `$1, $2, ...` for PostgreSQL). No SQL parsing needed.
|
|
725
|
+
|
|
726
|
+
**`supportsReturning`**: Enables optimal paths for INSERT/UPDATE. SQLite and PostgreSQL use `RETURNING *`; MySQL falls back to a separate SELECT.
|
|
727
|
+
|
|
728
|
+
**Migration locking**: If the driver provides `withMigrationLock()`, migrations run atomically (PostgreSQL uses advisory locks, MySQL uses `GET_LOCK`, SQLite uses exclusive transactions).
|
|
729
|
+
|
|
730
|
+
## Error Handling
|
|
731
|
+
|
|
732
|
+
All errors extend `DatabaseError` with typed error codes:
|
|
733
|
+
|
|
734
|
+
```typescript
|
|
735
|
+
import {
|
|
736
|
+
DatabaseError,
|
|
737
|
+
ValidationError,
|
|
738
|
+
ConstraintViolationError,
|
|
739
|
+
NotFoundError,
|
|
740
|
+
isDatabaseError,
|
|
741
|
+
hasErrorCode
|
|
742
|
+
} from "@b9g/zen";
|
|
743
|
+
|
|
744
|
+
// Validation errors (Zod/Standard Schema)
|
|
745
|
+
try {
|
|
746
|
+
await db.insert(Users, { email: "not-an-email" });
|
|
747
|
+
} catch (e) {
|
|
748
|
+
if (hasErrorCode(e, "VALIDATION_ERROR")) {
|
|
749
|
+
console.log(e.fieldErrors); // {email: ["Invalid email"]}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Constraint violations (database-level)
|
|
754
|
+
try {
|
|
755
|
+
await db.insert(Users, { id: "1", email: "duplicate@example.com" });
|
|
756
|
+
} catch (e) {
|
|
757
|
+
if (e instanceof ConstraintViolationError) {
|
|
758
|
+
console.log(e.kind); // "unique"
|
|
759
|
+
console.log(e.constraint); // "users_email_unique"
|
|
760
|
+
console.log(e.table); // "users"
|
|
761
|
+
console.log(e.column); // "email"
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Transaction errors (rolled back automatically)
|
|
766
|
+
await db.transaction(async (tx) => {
|
|
767
|
+
await tx.insert(Users, newUser);
|
|
768
|
+
await tx.insert(Posts, newPost); // Fails → transaction rolled back
|
|
769
|
+
});
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
**Error types:**
|
|
773
|
+
- `ValidationError` — Schema validation failed (fieldErrors, nested paths)
|
|
774
|
+
- `ConstraintViolationError` — Database constraint violated (kind, constraint, table, column)
|
|
775
|
+
- `NotFoundError` — Entity not found (tableName, id)
|
|
776
|
+
- `AlreadyExistsError` — Unique constraint violated (tableName, field, value)
|
|
777
|
+
- `QueryError` — SQL execution failed (sql)
|
|
778
|
+
- `MigrationError` / `MigrationLockError` — Migration failures (fromVersion, toVersion)
|
|
779
|
+
- `ConnectionError` / `TransactionError` — Connection/transaction issues
|
|
780
|
+
|
|
781
|
+
## Debugging
|
|
782
|
+
|
|
783
|
+
Inspect generated SQL and query plans:
|
|
784
|
+
|
|
785
|
+
```typescript
|
|
786
|
+
const userId = "u1";
|
|
787
|
+
|
|
788
|
+
// Print SQL without executing
|
|
789
|
+
const query = db.print`SELECT * FROM ${Posts} WHERE ${Posts.cols.published} = ${true}`;
|
|
790
|
+
console.log(query.sql); // SELECT * FROM "posts" WHERE "posts"."published" = ?
|
|
791
|
+
console.log(query.params); // [true]
|
|
792
|
+
|
|
793
|
+
// Inspect DDL generation
|
|
794
|
+
const ddl = db.print`${Posts.ddl()}`;
|
|
795
|
+
console.log(ddl.sql); // CREATE TABLE IF NOT EXISTS "posts" (...)
|
|
796
|
+
|
|
797
|
+
// Analyze query execution plan
|
|
798
|
+
const plan = await db.explain`
|
|
799
|
+
SELECT * FROM ${Posts}
|
|
800
|
+
WHERE ${Posts.cols.authorId} = ${userId}
|
|
801
|
+
`;
|
|
802
|
+
console.log(plan);
|
|
803
|
+
// SQLite: [{ detail: "SEARCH posts USING INDEX idx_posts_authorId (authorId=?)" }]
|
|
804
|
+
// PostgreSQL: [{ "QUERY PLAN": "Index Scan using idx_posts_authorId on posts" }]
|
|
805
|
+
|
|
806
|
+
// Debug fragments
|
|
807
|
+
console.log(Posts.set({ title: "Updated" }).toString());
|
|
808
|
+
// SQLFragment { sql: "\"title\" = ?", params: ["Updated"] }
|
|
809
|
+
|
|
810
|
+
console.log(Posts.ddl().toString());
|
|
811
|
+
// DDLFragment { type: "create-table", table: "posts" }
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
## Dialect Support
|
|
815
|
+
|
|
816
|
+
| Feature | SQLite | PostgreSQL | MySQL |
|
|
817
|
+
|---------|--------|------------|-------|
|
|
818
|
+
| **DDL Generation** | ✅ | ✅ | ✅ |
|
|
819
|
+
| **RETURNING** | ✅ | ✅ | ❌ (uses SELECT after) |
|
|
820
|
+
| **IF NOT EXISTS** (CREATE TABLE) | ✅ | ✅ | ✅ |
|
|
821
|
+
| **IF NOT EXISTS** (ADD COLUMN) | ✅ | ✅ | ❌ (may error if exists) |
|
|
822
|
+
| **Migration Locks** | BEGIN EXCLUSIVE | pg_advisory_lock | GET_LOCK |
|
|
823
|
+
| **EXPLAIN** | EXPLAIN QUERY PLAN | EXPLAIN | EXPLAIN |
|
|
824
|
+
| **JSON Type** | TEXT | JSONB | TEXT |
|
|
825
|
+
| **Boolean Type** | INTEGER (0/1) | BOOLEAN | BOOLEAN |
|
|
826
|
+
| **Date Type** | TEXT (ISO) | TIMESTAMPTZ | DATETIME |
|
|
827
|
+
| **Transactions** | ✅ | ✅ | ✅ |
|
|
828
|
+
| **Advisory Locks** | ❌ | ✅ | ✅ (named) |
|
|
829
|
+
|
|
830
|
+
## Public API Reference
|
|
831
|
+
|
|
832
|
+
### Core Exports
|
|
833
|
+
|
|
834
|
+
```typescript
|
|
835
|
+
import {
|
|
836
|
+
// Zod (extended with .db namespace)
|
|
837
|
+
z, // Re-exported Zod with .db already available
|
|
838
|
+
|
|
839
|
+
// Table definition
|
|
840
|
+
table, // Create a table definition from Zod schema
|
|
841
|
+
isTable, // Type guard for Table objects
|
|
842
|
+
extendZod, // Extend a separate Zod instance (advanced)
|
|
843
|
+
|
|
844
|
+
// Database
|
|
845
|
+
Database, // Main database class
|
|
846
|
+
Transaction, // Transaction context (passed to transaction callbacks)
|
|
847
|
+
DatabaseUpgradeEvent, // Event object for "upgradeneeded" handler
|
|
848
|
+
|
|
849
|
+
// DB expressions
|
|
850
|
+
db, // Runtime DB expressions (db.now(), db.json(), etc.)
|
|
851
|
+
isDBExpression, // Type guard for DBExpression objects
|
|
852
|
+
|
|
853
|
+
// Custom field helpers
|
|
854
|
+
setDBMeta, // Set database metadata on a Zod schema
|
|
855
|
+
getDBMeta, // Get database metadata from a Zod schema
|
|
856
|
+
|
|
857
|
+
// Errors
|
|
858
|
+
DatabaseError, // Base error class
|
|
859
|
+
ValidationError, // Schema validation failed
|
|
860
|
+
TableDefinitionError, // Invalid table definition
|
|
861
|
+
MigrationError, // Migration failed
|
|
862
|
+
MigrationLockError, // Failed to acquire migration lock
|
|
863
|
+
QueryError, // SQL execution failed
|
|
864
|
+
NotFoundError, // Entity not found
|
|
865
|
+
AlreadyExistsError, // Unique constraint violated
|
|
866
|
+
ConstraintViolationError, // Database constraint violated
|
|
867
|
+
ConnectionError, // Connection failed
|
|
868
|
+
TransactionError, // Transaction failed
|
|
869
|
+
isDatabaseError, // Type guard for DatabaseError
|
|
870
|
+
hasErrorCode, // Check error code
|
|
871
|
+
} from "@b9g/zen";
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
### Types
|
|
875
|
+
|
|
876
|
+
```typescript
|
|
877
|
+
import type {
|
|
878
|
+
// Table types
|
|
879
|
+
Table, // Table definition object
|
|
880
|
+
PartialTable, // Table created via .pick()
|
|
881
|
+
DerivedTable, // Table with derived fields via .derive()
|
|
882
|
+
TableOptions, // Options for table()
|
|
883
|
+
ReferenceInfo, // Foreign key reference metadata
|
|
884
|
+
CompoundReference, // Compound foreign key reference
|
|
885
|
+
|
|
886
|
+
// Field types
|
|
887
|
+
FieldMeta, // Field metadata for form generation
|
|
888
|
+
FieldType, // Field type enum
|
|
889
|
+
FieldDBMeta, // Database-specific field metadata
|
|
890
|
+
|
|
891
|
+
// Type inference
|
|
892
|
+
Row, // Infer row type from Table (after read)
|
|
893
|
+
Insert, // Infer insert type from Table (respects defaults/.db.auto())
|
|
894
|
+
Update, // Infer update type from Table (all fields optional)
|
|
895
|
+
|
|
896
|
+
// Fragment types
|
|
897
|
+
SetValues, // Values accepted by Table.set()
|
|
898
|
+
SQLFragment, // SQL fragment object
|
|
899
|
+
DDLFragment, // DDL fragment object
|
|
900
|
+
SQLDialect, // "sqlite" | "postgresql" | "mysql"
|
|
901
|
+
|
|
902
|
+
// Driver types
|
|
903
|
+
Driver, // Driver interface for adapters
|
|
904
|
+
TaggedQuery, // Tagged template query function
|
|
905
|
+
|
|
906
|
+
// Expression types
|
|
907
|
+
DBExpression, // Runtime database expression
|
|
908
|
+
|
|
909
|
+
// Error types
|
|
910
|
+
DatabaseErrorCode, // Error code string literals
|
|
911
|
+
} from "@b9g/zen";
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
### Table Methods
|
|
915
|
+
|
|
916
|
+
```typescript
|
|
917
|
+
import {z, table} from "@b9g/zen";
|
|
918
|
+
|
|
919
|
+
const Users = table("users", {
|
|
920
|
+
id: z.string().db.primary(),
|
|
921
|
+
email: z.string().email(),
|
|
922
|
+
emailAddress: z.string().email().optional(),
|
|
923
|
+
deletedAt: z.date().nullable().db.softDelete(),
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
const Posts = table("posts", {
|
|
927
|
+
id: z.string().db.primary(),
|
|
928
|
+
authorId: z.string().db.references(Users, "author"),
|
|
929
|
+
title: z.string(),
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
const rows = [{id: "u1", email: "alice@example.com", deletedAt: null}];
|
|
933
|
+
|
|
934
|
+
// DDL Generation
|
|
935
|
+
Users.ddl(); // DDLFragment for CREATE TABLE
|
|
936
|
+
Users.ensureColumn("emailAddress"); // DDLFragment for ALTER TABLE ADD COLUMN
|
|
937
|
+
Users.ensureIndex(["email"]); // DDLFragment for CREATE INDEX
|
|
938
|
+
Users.copyColumn("email", "emailAddress"); // SQLFragment for UPDATE (copy data)
|
|
939
|
+
|
|
940
|
+
// Query Fragments
|
|
941
|
+
Users.set({email: "alice@example.com"}); // SQLFragment for SET clause
|
|
942
|
+
Users.values(rows); // SQLFragment for INSERT VALUES
|
|
943
|
+
Users.on(Posts); // SQLFragment for JOIN ON (foreign key)
|
|
944
|
+
Users.in("id", ["u1"]); // SQLFragment for IN clause
|
|
945
|
+
Users.deleted(); // SQLFragment for soft delete check
|
|
946
|
+
|
|
947
|
+
// Column References
|
|
948
|
+
Users.cols.email; // SQLTemplate for qualified column
|
|
949
|
+
Users.primary; // SQLTemplate for primary key column
|
|
950
|
+
|
|
951
|
+
// Metadata
|
|
952
|
+
Users.name; // Table name string
|
|
953
|
+
Users.schema; // Zod schema
|
|
954
|
+
Users.meta; // Table metadata (primary, indexes, etc.)
|
|
955
|
+
Users.primaryKey(); // Primary key field name or null
|
|
956
|
+
Users.fields(); // Field metadata for form generation
|
|
957
|
+
Users.references(); // Foreign key references
|
|
958
|
+
|
|
959
|
+
// Derived Tables
|
|
960
|
+
Users.pick("id", "email"); // PartialTable with subset of fields
|
|
961
|
+
Users.derive("hasEmail", z.boolean())`
|
|
962
|
+
${Users.cols.email} IS NOT NULL
|
|
963
|
+
`;
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
### Database Methods
|
|
967
|
+
|
|
968
|
+
```typescript
|
|
969
|
+
import {z, table, Database} from "@b9g/zen";
|
|
970
|
+
import SQLiteDriver from "@b9g/zen/sqlite";
|
|
971
|
+
|
|
972
|
+
const Users = table("users", {
|
|
973
|
+
id: z.string().db.primary(),
|
|
974
|
+
email: z.string().email(),
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
const db = new Database(new SQLiteDriver("file:app.db"));
|
|
978
|
+
|
|
979
|
+
// Lifecycle
|
|
980
|
+
await db.open(1);
|
|
981
|
+
db.addEventListener("upgradeneeded", () => {});
|
|
982
|
+
|
|
983
|
+
// Query Methods (with normalization)
|
|
984
|
+
await db.all(Users)`WHERE ${Users.cols.email} = ${"alice@example.com"}`;
|
|
985
|
+
await db.get(Users)`WHERE ${Users.cols.id} = ${"u1"}`;
|
|
986
|
+
await db.get(Users, "u1");
|
|
987
|
+
|
|
988
|
+
// Raw Query Methods (no normalization)
|
|
989
|
+
await db.query<{count: number}>`SELECT COUNT(*) as count FROM ${Users}`;
|
|
990
|
+
await db.exec`CREATE INDEX idx_users_email ON ${Users}(${Users.cols.email})`;
|
|
991
|
+
await db.val<number>`SELECT COUNT(*) FROM ${Users}`;
|
|
992
|
+
|
|
993
|
+
// CRUD Helpers
|
|
994
|
+
await db.insert(Users, {id: "u1", email: "alice@example.com"});
|
|
995
|
+
await db.update(Users, {email: "alice2@example.com"}, "u1");
|
|
996
|
+
await db.delete(Users, "u1");
|
|
997
|
+
|
|
998
|
+
// Transactions
|
|
999
|
+
await db.transaction(async (tx) => {
|
|
1000
|
+
await tx.exec`SELECT 1`;
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
// Debugging
|
|
1004
|
+
db.print`SELECT 1`;
|
|
1005
|
+
await db.explain`SELECT * FROM ${Users}`;
|
|
1006
|
+
```
|
|
1007
|
+
|
|
1008
|
+
### Driver Exports
|
|
1009
|
+
|
|
1010
|
+
```typescript
|
|
1011
|
+
// Bun (built-in, auto-detects dialect)
|
|
1012
|
+
import BunDriver from "@b9g/zen/bun";
|
|
1013
|
+
|
|
1014
|
+
// Node.js SQLite (better-sqlite3)
|
|
1015
|
+
import SQLiteDriver from "@b9g/zen/sqlite";
|
|
1016
|
+
|
|
1017
|
+
// PostgreSQL (postgres.js)
|
|
1018
|
+
import PostgresDriver from "@b9g/zen/postgres";
|
|
1019
|
+
|
|
1020
|
+
// MySQL (mysql2)
|
|
1021
|
+
import MySQLDriver from "@b9g/zen/mysql";
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
## What This Library Does Not Do
|
|
1025
|
+
|
|
1026
|
+
**Query Generation:**
|
|
1027
|
+
- **No model classes** — Tables are plain definitions, not class instances
|
|
1028
|
+
- **No hidden JOINs** — You write all SQL explicitly
|
|
1029
|
+
- **No implicit query building** — No `.where().orderBy().limit()` chains
|
|
1030
|
+
- **No lazy loading** — Related data comes from your JOINs
|
|
1031
|
+
- **No ORM identity map** — Normalization is per-query, not session-wide
|
|
1032
|
+
|
|
1033
|
+
**Migrations:**
|
|
1034
|
+
- **No down migrations** — Forward-only, monotonic versioning (1 → 2 → 3)
|
|
1035
|
+
- **No destructive helpers** — No `dropColumn()`, `dropTable()`, `renameColumn()` methods
|
|
1036
|
+
- **No automatic migrations** — DDL must be written explicitly in upgrade events
|
|
1037
|
+
- **No migration files** — Event handlers replace traditional migration folders
|
|
1038
|
+
- **No branching versions** — Linear version history only
|
|
1039
|
+
|
|
1040
|
+
**Safety Philosophy:**
|
|
1041
|
+
- Migrations are **additive and idempotent** by design
|
|
1042
|
+
- Use `ensureColumn()`, `ensureIndex()`, `copyColumn()` for safe schema changes
|
|
1043
|
+
- Breaking changes require multi-step migrations (add, migrate data, deprecate)
|
|
1044
|
+
- Version numbers never decrease — rollbacks are new forward migrations
|