@b9g/zen 0.1.2 → 0.1.4

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 CHANGED
@@ -5,6 +5,45 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.4] - 2025-12-28
9
+
10
+ ### Added
11
+
12
+ - `db.explain()` - Get query execution plan (EXPLAIN QUERY PLAN for SQLite, EXPLAIN for PostgreSQL/MySQL)
13
+ - `explain()` method on Driver interface - each driver owns its dialect-specific EXPLAIN syntax
14
+
15
+ ### Fixed
16
+
17
+ - README documented fake APIs that never existed (`Users.ddl()`, `Posts.ensureColumn()`, `Posts.ensureIndex()`, `Users.copyColumn()`)
18
+ - README now documents the real APIs: `db.ensureTable()`, `db.ensureView()`, `db.ensureConstraints()`, `db.copyColumn()`
19
+
20
+ ### Changed
21
+
22
+ - `getColumns()` is now required on Driver interface (was optional with fallback)
23
+ - Removed dialect-switching logic from database.ts - drivers own all dialect-specific behavior
24
+
25
+ ## [0.1.3] - 2025-12-22
26
+
27
+ ### Added
28
+
29
+ - Validation that compound constraints (`indexes`, `unique`, `references` options) have 2+ fields
30
+ - Single-field constraints now throw `TableDefinitionError` with helpful message pointing to field-level API
31
+
32
+ ### Fixed
33
+
34
+ - **TypeScript types now work in published package** - Updated libuild to fix module augmentation and `.d.ts` path resolution
35
+
36
+ ### Changed
37
+
38
+ - New tagline: "Define Zod tables. Write raw SQL. Get typed objects."
39
+
40
+ ### Documentation
41
+
42
+ - Added `.db.inserted()`, `.db.updated()`, `.db.upserted()` documentation with examples
43
+ - Added compound unique constraints example
44
+ - Fixed table naming consistency (all plural)
45
+ - Various README cleanups and improvements
46
+
8
47
  ## [0.1.2] - 2025-12-22
9
48
 
10
49
  ### Added
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ZenDB
2
2
 
3
- The simple database client. Define tables. Write SQL. Get objects.
3
+ Define Zod tables. Write raw SQL. Get typed objects.
4
4
 
5
5
  [Website](https://zendb.org) · [GitHub](https://github.com/bikeshaving/zen) · [npm](https://www.npmjs.com/package/@b9g/zen)
6
6
 
@@ -47,7 +47,7 @@ import SQLiteDriver from "@b9g/zen/sqlite";
47
47
  const driver = new SQLiteDriver("file:app.db");
48
48
 
49
49
  // 1. Define tables
50
- const Users = table("users", {
50
+ let Users = table("users", {
51
51
  id: z.string().uuid().db.primary().db.auto(),
52
52
  email: z.string().email().db.unique(),
53
53
  name: z.string(),
@@ -73,14 +73,14 @@ db.addEventListener("upgradeneeded", (e) => {
73
73
  }
74
74
  if (e.oldVersion < 2) {
75
75
  // Evolve schema: add avatar column (safe, additive)
76
- const UsersV2 = table("users", {
76
+ Users = table("users", {
77
77
  id: z.string().uuid().db.primary().db.auto(),
78
78
  email: z.string().email().db.unique(),
79
79
  name: z.string(),
80
- avatar: z.string().optional(),
80
+ avatar: z.string().optional(), // new field
81
81
  });
82
- await db.ensureTable(UsersV2); // adds missing columns/indexes
83
- await db.ensureConstraints(UsersV2); // applies new constraints on existing data
82
+ await db.ensureTable(Users); // adds missing columns/indexes
83
+ await db.ensureConstraints(Users); // applies new constraints on existing data
84
84
  }
85
85
  })());
86
86
  });
@@ -115,14 +115,8 @@ await db.update(Users, {name: "Alice Smith"}, user.id);
115
115
  Zen is the missing link between SQL and typed data. By writing tables with Zod schema, you get idempotent migration helpers, typed CRUD, normalized object references, and many features other database clients cannot provide.
116
116
 
117
117
  ### What Zen is not:
118
- - **Zen is not a query builder** — Rather than using a fluent query builder interface (`.where().orderBy().limit()`), Zen uses explicit SQL tagged template functions instead
119
- ```
120
- db.get(Posts)`
121
- WHERE ${Posts.cols.published} = ${true}
122
- ORDER BY ${Posts.cols.publishDate}
123
- DESC LIMIT 20
124
- ```.
125
- - **Zen is not an ORM** — Tables are not classes, they are Zod-powered singletons which provide schema-aware SQL-fragment helpers. These tables can be passed to CRUD helpers to validate writes, generate DDL, and normalize joined data into an object graph.
118
+ - **Zen is not a query builder** — Rather than building SQL with fluent chains `.where().orderBy().limit()`, you write it directly with templates: `` db.all(Posts)`WHERE published = ${true} ORDER BY created_at DESC LIMIT 20` `` Helper functions help you write the tedious parts of SQL without hiding it or limiting your queries.
119
+ - **Zen is not an ORM** — Tables are not classes. They are Zod-powered singletons which provide schema-aware utilities. These tables can be used to validate writes, generate DDL, and deduplicate joined data.
126
120
  - **Zen is not a startup** — Zen is an open-source library, not a venture-backed SaaS. There will never be a managed “ZenDB” instance or a “Zen Studio.” The library is a thin wrapper around Zod and JavaScript SQL drivers, with a focus on runtime abstractions rather than complicated tooling.
127
121
 
128
122
  ### Safety
@@ -133,9 +127,6 @@ Zen is the missing link between SQL and typed data. By writing tables with Zod s
133
127
  - **No destructive helpers** — No `dropColumn()`, `dropTable()`, `renameColumn()`
134
128
  - **No automatic migrations** — Schema changes are explicit in upgrade events
135
129
 
136
- Migrations are **additive and idempotent** by design. Use `ensureColumn()`, `ensureIndex()`, `copyColumn()` for safe schema evolution. Breaking changes require multi-step migrations. Rollbacks are new forward migrations.
137
-
138
-
139
130
  ## Table Definitions
140
131
 
141
132
  ```typescript
@@ -214,10 +205,37 @@ const Users = table("users", {
214
205
 
215
206
  // id and createdAt are optional - auto-generated if not provided
216
207
  const user = await db.insert(Users, {name: "Alice"});
217
- user.id; // "550e8400-e29b-41d4-a716-446655440000"
208
+ user.id; // "550e8400-e29b-41d4-a716-446655440000"
218
209
  user.createdAt; // 2024-01-15T10:30:00.000Z
219
210
  ```
220
211
 
212
+ **Default values with `.db.inserted()`, `.db.updated()`, `.db.upserted()`:**
213
+
214
+ These methods set default values for write operations. They accept JS functions or SQL builtins (`NOW`, `TODAY`, `CURRENT_TIMESTAMP`, `CURRENT_DATE`, `CURRENT_TIME`):
215
+
216
+ ```typescript
217
+ import {z, table, NOW} from "@b9g/zen";
218
+
219
+ const Posts = table("posts", {
220
+ id: z.string().uuid().db.primary().db.auto(),
221
+ title: z.string(),
222
+ // JS function — runs client-side
223
+ slug: z.string().db.inserted(() => generateSlug()),
224
+ // SQL builtin — runs database-side
225
+ createdAt: z.date().db.inserted(NOW),
226
+ updatedAt: z.date().db.upserted(NOW), // set on insert AND update
227
+ viewCount: z.number().db.inserted(() => 0).db.updated(() => 0), // reset on update
228
+ });
229
+ ```
230
+
231
+ | Method | When applied | Field becomes optional for |
232
+ |--------|--------------|---------------------------|
233
+ | `.db.inserted(value)` | INSERT only | insert |
234
+ | `.db.updated(value)` | UPDATE only | update |
235
+ | `.db.upserted(value)` | INSERT and UPDATE | insert and update |
236
+
237
+ **Why not Zod's `.default()`?** Zod's `.default()` applies at *parse time*, not *write time*. This means defaults would be applied when reading data, not when inserting. Zen throws an error if you use `.default()` — use `.db.inserted()` instead.
238
+
221
239
  **Automatic JSON encoding/decoding:**
222
240
 
223
241
  Objects (`z.object()`) and arrays (`z.array()`) are automatically serialized to JSON when stored and parsed back when read:
@@ -238,7 +256,7 @@ const settings = await db.insert(Settings, {
238
256
 
239
257
  // On read: JSON strings are parsed back to objects/arrays
240
258
  settings.config.theme; // "dark" (object, not string)
241
- settings.tags[0]; // "admin" (array, not string)
259
+ settings.tags[0]; // "admin" (array, not string)
242
260
  ```
243
261
 
244
262
  **Custom encoding/decoding:**
@@ -246,7 +264,7 @@ settings.tags[0]; // "admin" (array, not string)
246
264
  Override automatic JSON encoding with custom transformations:
247
265
 
248
266
  ```typescript
249
- const Custom = table("custom", {
267
+ const Articles = table("articles", {
250
268
  id: z.string().db.primary(),
251
269
  // Store array as CSV instead of JSON
252
270
  tags: z.array(z.string())
@@ -255,8 +273,8 @@ const Custom = table("custom", {
255
273
  .db.type("TEXT"), // Required: explicit column type for DDL
256
274
  });
257
275
 
258
- await db.insert(Custom, {id: "c1", tags: ["a", "b", "c"]});
259
- // Stored as: tags='a,b,c' (not '["a","b","c"]')
276
+ await db.insert(Articles, {id: "a1", tags: ["news", "tech", "featured"]});
277
+ // Stored as: tags='news,tech,featured' (not '["news","tech","featured"]')
260
278
  ```
261
279
 
262
280
  **Explicit column types:**
@@ -301,14 +319,16 @@ const posts = await db.all([Posts, Users.active])`
301
319
  `;
302
320
  ```
303
321
 
304
- **Compound indexes** via table options:
322
+ **Compound indexes and unique constraints** via table options:
305
323
  ```typescript
306
324
  const Posts = table("posts", {
307
325
  id: z.string().db.primary(),
308
326
  authorId: z.string(),
327
+ slug: z.string(),
309
328
  createdAt: z.date(),
310
329
  }, {
311
330
  indexes: [["authorId", "createdAt"]],
331
+ unique: [["authorId", "slug"]], // unique together
312
332
  });
313
333
  ```
314
334
 
@@ -355,16 +375,16 @@ type Post = Row<typeof Posts>;
355
375
  // Post includes: id, title, authorId, titleUpper, tags
356
376
 
357
377
  const posts = await db.all([Posts, Users, PostTags, Tags])`
358
- JOIN "users" ON ${Users.on(Posts)}
359
- LEFT JOIN "post_tags" ON ${PostTags.cols.postId} = ${Posts.cols.id}
360
- LEFT JOIN "tags" ON ${Tags.on(PostTags)}
378
+ JOIN ${Users} ON ${Users.on(Posts)}
379
+ LEFT JOIN ${PostTags} ON ${PostTags.cols.postId} = ${Posts.cols.id}
380
+ LEFT JOIN ${Tags} ON ${Tags.on(PostTags)}
361
381
  `;
362
382
 
363
383
  const post = posts[0];
364
384
  post.titleUpper; // "HELLO WORLD" — typed as string
365
- post.tags; // ["javascript", "typescript"] — traverses relationships
366
- Object.keys(post); // ["id", "title", "authorId", "author"] (no derived props)
367
- JSON.stringify(post); // Excludes derived properties (non-enumerable)
385
+ post.tags; // ["javascript", "typescript"] — traverses relationships
386
+ Object.keys(post); // ["id", "title", "authorId", "author"] (no derived props)
387
+ JSON.stringify(post); // Excludes derived properties (non-enumerable)
368
388
  ```
369
389
 
370
390
  Derived properties:
@@ -376,9 +396,9 @@ Derived properties:
376
396
 
377
397
  **Partial selects** with `pick()`:
378
398
  ```typescript
379
- const UserSummary = Users.pick("id", "name");
380
- const posts = await db.all([Posts, UserSummary])`
381
- JOIN "users" ON ${UserSummary.on(Posts)}
399
+ const UserSummaries = Users.pick("id", "name");
400
+ const posts = await db.all([Posts, UserSummaries])`
401
+ JOIN ${UserSummaries} ON ${UserSummaries.on(Posts)}
382
402
  `;
383
403
  // posts[0].author has only id and name
384
404
  ```
@@ -470,6 +490,29 @@ await db.exec`CREATE INDEX idx_posts_author ON ${Posts}(${Posts.cols.authorId})`
470
490
  const count = await db.val<number>`SELECT COUNT(*) FROM ${Posts}`;
471
491
  ```
472
492
 
493
+ ## CRUD Helpers
494
+ ```typescript
495
+ // Insert with Zod validation (uses RETURNING to get actual row)
496
+ const user = await db.insert(Users, {
497
+ email: "alice@example.com",
498
+ name: "Alice",
499
+ });
500
+ // Returns actual row from DB, including auto-generated id and DB-computed defaults
501
+ const userId = user.id;
502
+
503
+ // Update by primary key (uses RETURNING)
504
+ const updated = await db.update(Users, {name: "Bob"}, userId);
505
+
506
+ // Delete by primary key
507
+ await db.delete(Users, userId);
508
+
509
+ // Soft delete (sets deletedAt timestamp, requires softDelete() field)
510
+ await db.softDelete(Users, userId);
511
+ ```
512
+
513
+ **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.
514
+
515
+
473
516
  ## Fragment Helpers
474
517
 
475
518
  Type-safe SQL fragments as methods on Table objects:
@@ -519,29 +562,6 @@ const posts = await db.all(Posts)`WHERE ${Posts.in("id", [])}`;
519
562
  // → WHERE 1 = 0
520
563
  ```
521
564
 
522
- ## CRUD Helpers
523
-
524
- ```typescript
525
- // Insert with Zod validation (uses RETURNING to get actual row)
526
- const user = await db.insert(Users, {
527
- email: "alice@example.com",
528
- name: "Alice",
529
- });
530
- // Returns actual row from DB, including auto-generated id and DB-computed defaults
531
- const userId = user.id;
532
-
533
- // Update by primary key (uses RETURNING)
534
- const updated = await db.update(Users, {name: "Bob"}, userId);
535
-
536
- // Delete by primary key
537
- await db.delete(Users, userId);
538
-
539
- // Soft delete (sets deletedAt timestamp, requires softDelete() field)
540
- await db.softDelete(Users, userId);
541
- ```
542
-
543
- **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.
544
-
545
565
  ## Transactions
546
566
 
547
567
  ```typescript
@@ -575,14 +595,16 @@ IndexedDB-style event-based migrations:
575
595
  db.addEventListener("upgradeneeded", (e) => {
576
596
  e.waitUntil((async () => {
577
597
  if (e.oldVersion < 1) {
578
- await db.exec`${Users.ddl()}`;
579
- await db.exec`${Posts.ddl()}`;
598
+ await db.ensureTable(Users);
599
+ await db.ensureTable(Posts);
580
600
  }
581
601
  if (e.oldVersion < 2) {
582
- await db.exec`${Posts.ensureColumn("views")}`;
602
+ // Add a new column - just update the schema and call ensureTable again
603
+ await db.ensureTable(Posts); // Adds missing "views" column
583
604
  }
584
605
  if (e.oldVersion < 3) {
585
- await db.exec`${Posts.ensureIndex(["title"])}`;
606
+ // Add constraints after data cleanup
607
+ await db.ensureConstraints(Posts);
586
608
  }
587
609
  })());
588
610
  });
@@ -603,7 +625,7 @@ await db.open(3); // Opens at version 3, fires upgradeneeded if needed
603
625
  zen provides idempotent helpers that encourage safe, additive-only migrations:
604
626
 
605
627
  ```typescript
606
- // Add a new column (reads from schema)
628
+ // Add a new column - update schema and call ensureTable
607
629
  const Posts = table("posts", {
608
630
  id: z.string().db.primary(),
609
631
  title: z.string(),
@@ -611,24 +633,32 @@ const Posts = table("posts", {
611
633
  });
612
634
 
613
635
  if (e.oldVersion < 2) {
614
- await db.exec`${Posts.ensureColumn("views")}`;
636
+ await db.ensureTable(Posts); // Adds missing columns from schema
615
637
  }
616
- // → ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "views" REAL DEFAULT 0
638
+ // → ALTER TABLE "posts" ADD COLUMN "views" REAL DEFAULT 0
639
+
640
+ // Add indexes - defined in schema, applied by ensureTable
641
+ const Posts = table("posts", {
642
+ id: z.string().db.primary(),
643
+ title: z.string().db.index(), // NEW - add index
644
+ views: z.number().db.inserted(() => 0),
645
+ });
617
646
 
618
- // Add an index
619
647
  if (e.oldVersion < 3) {
620
- await db.exec`${Posts.ensureIndex(["title", "views"])}`;
648
+ await db.ensureTable(Posts); // Adds missing indexes
621
649
  }
622
- // → CREATE INDEX IF NOT EXISTS "idx_posts_title_views" ON "posts"("title", "views")
650
+ // → CREATE INDEX IF NOT EXISTS "idx_posts_title" ON "posts"("title")
623
651
 
624
652
  // Safe column rename (additive, non-destructive)
625
653
  const Users = table("users", {
626
- emailAddress: z.string().email(), // renamed from "email"
654
+ id: z.string().db.primary(),
655
+ email: z.string().email(), // Keep old column
656
+ emailAddress: z.string().email(), // NEW - add new column
627
657
  });
628
658
 
629
659
  if (e.oldVersion < 4) {
630
- await db.exec`${Users.ensureColumn("emailAddress")}`;
631
- await db.exec`${Users.copyColumn("email", "emailAddress")}`;
660
+ await db.ensureTable(Users); // Adds emailAddress column
661
+ await db.copyColumn(Users, "email", "emailAddress"); // Copy data
632
662
  // Keep old "email" column for backwards compat
633
663
  // Drop it in a later migration if needed (manual SQL)
634
664
  }
@@ -636,9 +666,10 @@ if (e.oldVersion < 4) {
636
666
  ```
637
667
 
638
668
  **Helper methods:**
639
- - `table.ensureColumn(fieldName, options?)` - Idempotent ALTER TABLE ADD COLUMN
640
- - `table.ensureIndex(fields, options?)` - Idempotent CREATE INDEX
641
- - `table.copyColumn(from, to)` - Copy data between columns (for safe renames)
669
+ - `db.ensureTable(table)` - Idempotent CREATE TABLE / ADD COLUMN / CREATE INDEX
670
+ - `db.ensureView(view)` - Idempotent DROP + CREATE VIEW
671
+ - `db.ensureConstraints(table)` - Add unique/FK constraints (with preflight checks)
672
+ - `db.copyColumn(table, from, to)` - Copy data between columns (for safe renames)
642
673
 
643
674
  All helpers read from your table schema (single source of truth) and are safe to run multiple times (idempotent).
644
675
 
@@ -818,6 +849,7 @@ const refs = Posts.references(); // [{fieldName: "authorId", table: Users,
818
849
  - Tagged template queries are cached by template object identity (compiled once per call site)
819
850
  - Normalization cost is O(rows) with hash maps per table
820
851
  - Reference resolution is zero-cost after deduplication
852
+ - Zod validation happens on writes, never on reads.
821
853
 
822
854
  ## Driver Interface
823
855
 
@@ -916,25 +948,9 @@ const query = db.print`SELECT * FROM ${Posts} WHERE ${Posts.cols.published} = ${
916
948
  console.log(query.sql); // SELECT * FROM "posts" WHERE "posts"."published" = ?
917
949
  console.log(query.params); // [true]
918
950
 
919
- // Inspect DDL generation
920
- const ddl = db.print`${Posts.ddl()}`;
921
- console.log(ddl.sql); // CREATE TABLE IF NOT EXISTS "posts" (...)
922
-
923
- // Analyze query execution plan
924
- const plan = await db.explain`
925
- SELECT * FROM ${Posts}
926
- WHERE ${Posts.cols.authorId} = ${userId}
927
- `;
928
- console.log(plan);
929
- // SQLite: [{ detail: "SEARCH posts USING INDEX idx_posts_authorId (authorId=?)" }]
930
- // PostgreSQL: [{ "QUERY PLAN": "Index Scan using idx_posts_authorId on posts" }]
931
-
932
951
  // Debug fragments
933
952
  console.log(Posts.set({ title: "Updated" }).toString());
934
953
  // SQLFragment { sql: "\"title\" = ?", params: ["Updated"] }
935
-
936
- console.log(Posts.ddl().toString());
937
- // DDLFragment { type: "create-table", table: "posts" }
938
954
  ```
939
955
 
940
956
  ## Dialect Support
@@ -970,41 +986,41 @@ Override with `.db.type("CUSTOM")` when using custom encode/decode.
970
986
  ```typescript
971
987
  import {
972
988
  // Zod (extended with .db namespace)
973
- z, // Re-exported Zod with .db already available
989
+ z, // Re-exported Zod with .db already available
990
+ extendZod, // Extend a separate Zod instance (advanced)
974
991
 
975
992
  // Table and view definition
976
- table, // Create a table definition from Zod schema
977
- view, // Create a read-only view from a table
978
- isTable, // Type guard for Table objects
979
- isView, // Type guard for View objects
980
- extendZod, // Extend a separate Zod instance (advanced)
993
+ table, // Create a table definition from Zod schema
994
+ view, // Create a read-only view from a table
995
+ isTable, // Type guard for Table objects
996
+ isView, // Type guard for View objects
981
997
 
982
998
  // Database
983
- Database, // Main database class
984
- Transaction, // Transaction context (passed to transaction callbacks)
985
- DatabaseUpgradeEvent, // Event object for "upgradeneeded" handler
999
+ Database, // Main database class
1000
+ Transaction, // Transaction context (passed to transaction callbacks)
1001
+ DatabaseUpgradeEvent, // Event object for "upgradeneeded" handler
986
1002
 
987
1003
  // SQL builtins (for .db.inserted() / .db.updated())
988
- NOW, // CURRENT_TIMESTAMP alias
989
- TODAY, // CURRENT_DATE alias
990
- CURRENT_TIMESTAMP, // SQL CURRENT_TIMESTAMP
991
- CURRENT_DATE, // SQL CURRENT_DATE
992
- CURRENT_TIME, // SQL CURRENT_TIME
1004
+ NOW, // CURRENT_TIMESTAMP alias
1005
+ TODAY, // CURRENT_DATE alias
1006
+ CURRENT_TIMESTAMP, // SQL CURRENT_TIMESTAMP
1007
+ CURRENT_DATE, // SQL CURRENT_DATE
1008
+ CURRENT_TIME, // SQL CURRENT_TIME
993
1009
 
994
1010
  // Errors
995
- DatabaseError, // Base error class
996
- ValidationError, // Schema validation failed
997
- TableDefinitionError, // Invalid table definition
998
- MigrationError, // Migration failed
999
- MigrationLockError, // Failed to acquire migration lock
1000
- QueryError, // SQL execution failed
1001
- NotFoundError, // Entity not found
1002
- AlreadyExistsError, // Unique constraint violated
1011
+ DatabaseError, // Base error class
1012
+ ValidationError, // Schema validation failed
1013
+ TableDefinitionError, // Invalid table definition
1014
+ MigrationError, // Migration failed
1015
+ MigrationLockError, // Failed to acquire migration lock
1016
+ QueryError, // SQL execution failed
1017
+ NotFoundError, // Entity not found
1018
+ AlreadyExistsError, // Unique constraint violated
1003
1019
  ConstraintViolationError, // Database constraint violated
1004
- ConnectionError, // Connection failed
1005
- TransactionError, // Transaction failed
1006
- isDatabaseError, // Type guard for DatabaseError
1007
- hasErrorCode, // Check error code
1020
+ ConnectionError, // Connection failed
1021
+ TransactionError, // Transaction failed
1022
+ isDatabaseError, // Type guard for DatabaseError
1023
+ hasErrorCode, // Check error code
1008
1024
  } from "@b9g/zen";
1009
1025
  ```
1010
1026
 
@@ -1013,34 +1029,34 @@ import {
1013
1029
  ```typescript
1014
1030
  import type {
1015
1031
  // Table types
1016
- Table, // Table definition object
1017
- PartialTable, // Table created via .pick()
1018
- DerivedTable, // Table with derived fields via .derive()
1019
- TableOptions, // Options for table()
1020
- ReferenceInfo, // Foreign key reference metadata
1021
- CompoundReference, // Compound foreign key reference
1032
+ Table, // Table definition object
1033
+ PartialTable, // Table created via .pick()
1034
+ DerivedTable, // Table with derived fields via .derive()
1035
+ TableOptions, // Options for table()
1036
+ ReferenceInfo, // Foreign key reference metadata
1037
+ CompoundReference, // Compound foreign key reference
1022
1038
 
1023
1039
  // Field types
1024
- FieldMeta, // Field metadata for form generation
1025
- FieldType, // Field type enum
1026
- FieldDBMeta, // Database-specific field metadata
1040
+ FieldMeta, // Field metadata for form generation
1041
+ FieldType, // Field type enum
1042
+ FieldDBMeta, // Database-specific field metadata
1027
1043
 
1028
1044
  // Type inference
1029
- Row, // Infer row type from Table (after read)
1030
- Insert, // Infer insert type from Table (respects defaults/.db.auto())
1031
- Update, // Infer update type from Table (all fields optional)
1045
+ Row, // Infer row type from Table (after read)
1046
+ Insert, // Infer insert type from Table (respects defaults/.db.auto())
1047
+ Update, // Infer update type from Table (all fields optional)
1032
1048
 
1033
1049
  // Fragment types
1034
- SetValues, // Values accepted by Table.set()
1035
- SQLTemplate, // SQL template object (return type of set(), on(), etc.)
1036
- SQLDialect, // "sqlite" | "postgresql" | "mysql"
1050
+ SetValues, // Values accepted by Table.set()
1051
+ SQLTemplate, // SQL template object (return type of set(), on(), etc.)
1052
+ SQLDialect, // "sqlite" | "postgresql" | "mysql"
1037
1053
 
1038
1054
  // Driver types
1039
- Driver, // Driver interface for adapters
1040
- TaggedQuery, // Tagged template query function
1055
+ Driver, // Driver interface for adapters
1056
+ TaggedQuery, // Tagged template query function
1041
1057
 
1042
1058
  // Error types
1043
- DatabaseErrorCode, // Error code string literals
1059
+ DatabaseErrorCode, // Error code string literals
1044
1060
  } from "@b9g/zen";
1045
1061
  ```
1046
1062
 
@@ -1064,12 +1080,6 @@ const Posts = table("posts", {
1064
1080
 
1065
1081
  const rows = [{id: "u1", email: "alice@example.com", deletedAt: null}];
1066
1082
 
1067
- // DDL Generation
1068
- Users.ddl(); // DDLFragment for CREATE TABLE
1069
- Users.ensureColumn("emailAddress"); // DDLFragment for ALTER TABLE ADD COLUMN
1070
- Users.ensureIndex(["email"]); // DDLFragment for CREATE INDEX
1071
- Users.copyColumn("email", "emailAddress"); // SQLFragment for UPDATE (copy data)
1072
-
1073
1083
  // Query Fragments
1074
1084
  Users.set({email: "alice@example.com"}); // SQLFragment for SET clause
1075
1085
  Users.values(rows); // SQLFragment for INSERT VALUES
@@ -1136,9 +1146,15 @@ await db.transaction(async (tx) => {
1136
1146
  await tx.exec`SELECT 1`;
1137
1147
  });
1138
1148
 
1149
+ // Schema Management
1150
+ await db.ensureTable(Users); // CREATE TABLE / ADD COLUMN / CREATE INDEX
1151
+ await db.ensureView(AdminUsers); // DROP + CREATE VIEW
1152
+ await db.ensureConstraints(Users); // Add unique/FK constraints
1153
+ await db.copyColumn(Users, "old", "new"); // Copy data between columns
1154
+
1139
1155
  // Debugging
1140
- db.print`SELECT 1`;
1141
- await db.explain`SELECT * FROM ${Users}`;
1156
+ db.print`SELECT 1`; // Returns { sql, params } without executing
1157
+ await db.explain`SELECT * FROM ${Users}`; // Returns query execution plan
1142
1158
  ```
1143
1159
 
1144
1160
  ### Driver Exports
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@b9g/zen",
3
- "version": "0.1.2",
4
- "description": "The simple database client. Define tables. Write SQL. Get objects.",
3
+ "version": "0.1.4",
4
+ "description": "Define Zod tables. Write raw SQL. Get typed objects.",
5
5
  "keywords": [
6
6
  "database",
7
7
  "sql",
@@ -26,7 +26,7 @@
26
26
  "zod": "^4.0.0"
27
27
  },
28
28
  "devDependencies": {
29
- "@b9g/libuild": "^0.1.20",
29
+ "@b9g/libuild": "^0.1.21",
30
30
  "@eslint/js": "^9.39.2",
31
31
  "@types/better-sqlite3": "^7.6.0",
32
32
  "@types/bun": "^1.3.4",
@@ -2,7 +2,7 @@ import {
2
2
  isSQLBuiltin,
3
3
  isSQLIdentifier,
4
4
  resolveSQLBuiltin
5
- } from "./chunk-BEX6VPES.js";
5
+ } from "./chunk-DKLSJISE.js";
6
6
 
7
7
  // src/impl/sql.ts
8
8
  function quoteIdent(name, dialect) {
@@ -4,7 +4,7 @@ import {
4
4
  getViewMeta,
5
5
  ident,
6
6
  makeTemplate
7
- } from "./chunk-BEX6VPES.js";
7
+ } from "./chunk-DKLSJISE.js";
8
8
 
9
9
  // src/impl/ddl.ts
10
10
  import { z } from "zod";
@@ -793,6 +793,30 @@ function table(name, shape, options = {}) {
793
793
  name
794
794
  );
795
795
  }
796
+ for (const idx of options.indexes ?? []) {
797
+ if (idx.length < 2) {
798
+ throw new TableDefinitionError(
799
+ `Compound index in table "${name}" must have at least 2 fields. Use .db.index() for single-field indexes.`,
800
+ name
801
+ );
802
+ }
803
+ }
804
+ for (const u of options.unique ?? []) {
805
+ if (u.length < 2) {
806
+ throw new TableDefinitionError(
807
+ `Compound unique constraint in table "${name}" must have at least 2 fields. Use .db.unique() for single-field constraints.`,
808
+ name
809
+ );
810
+ }
811
+ }
812
+ for (const ref of options.references ?? []) {
813
+ if (ref.fields.length < 2) {
814
+ throw new TableDefinitionError(
815
+ `Compound foreign key in table "${name}" must have at least 2 fields. Use .db.references() for single-field foreign keys.`,
816
+ name
817
+ );
818
+ }
819
+ }
796
820
  const zodShape = {};
797
821
  const meta = {
798
822
  primary: null,
@@ -2,8 +2,8 @@ import {
2
2
  generateColumnDDL,
3
3
  generateDDL,
4
4
  generateViewDDL
5
- } from "./chunk-ARUUB3H4.js";
6
- import "./chunk-BEX6VPES.js";
5
+ } from "./chunk-2R6FDKLS.js";
6
+ import "./chunk-DKLSJISE.js";
7
7
  export {
8
8
  generateColumnDDL,
9
9
  generateDDL,
package/src/bun.d.ts CHANGED
@@ -58,4 +58,5 @@ export default class BunDriver implements Driver {
58
58
  type?: string;
59
59
  notnull?: boolean;
60
60
  }[]>;
61
+ explain(strings: TemplateStringsArray, values: unknown[]): Promise<Record<string, unknown>[]>;
61
62
  }