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