@b9g/zen 0.1.0 → 0.1.2

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,46 @@ 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.2] - 2025-12-22
9
+
10
+ ### Added
11
+
12
+ - New type exports: `PartialTable`, `DerivedTable`, `SetValues`, `FieldDBMeta`, `ReferenceInfo`, `CompoundReference`, `TaggedQuery`, `SQLDialect`, `isTable`
13
+ - Views documentation section in README
14
+ - `EnsureError` and `SchemaDriftError` documented in error types
15
+
16
+ ### Changed
17
+
18
+ - Reorganized `zen.ts` exports into logical groups
19
+ - README Types section now accurately reflects actual exports (removed non-existent `SQLFragment`, `DDLFragment`, `DBExpression`)
20
+
21
+ ### Fixed
22
+
23
+ - `isTable` type guard now exported (was missing)
24
+
25
+ ## [0.1.1] - 2025-12-21
26
+
27
+ ### Added
28
+
29
+ - Driver-level type encoding/decoding for dialect-specific handling
30
+ - `encodeValue(value, fieldType)` and `decodeValue(value, fieldType)` methods on Driver interface
31
+ - SQLite: Date→ISO string, boolean→1/0, JSON stringify/parse
32
+ - MySQL: Date→"YYYY-MM-DD HH:MM:SS", boolean→1/0, JSON stringify/parse
33
+ - PostgreSQL: Mostly passthrough (pg handles natively), JSON stringify
34
+ - `inferFieldType()` helper to infer field type from Zod schema
35
+ - Node.js tests for encode/decode functionality
36
+
37
+ ### Changed
38
+
39
+ - **Breaking:** Removed deprecated `Infer<T>` type alias (use `Row<T>` instead)
40
+ - Renamed internal types for clarity:
41
+ - `InferRefs` → `RowRefs`
42
+ - `WithRefs` → `JoinedRow`
43
+
44
+ ### Fixed
45
+
46
+ - Invalid datetime values now throw errors instead of returning Invalid Date
47
+
8
48
  ## [0.1.0] - 2025-12-20
9
49
 
10
50
  Initial release of @b9g/zen - the simple database client.
package/README.md CHANGED
@@ -1,9 +1,8 @@
1
1
  # ZenDB
2
- The simple database client.
3
2
 
4
- Define tables. Write SQL. Get objects.
3
+ The simple database client. Define tables. Write SQL. Get objects.
5
4
 
6
- Cultivate your data.
5
+ [Website](https://zendb.org) · [GitHub](https://github.com/bikeshaving/zen) · [npm](https://www.npmjs.com/package/@b9g/zen)
7
6
 
8
7
  ## Installation
9
8
 
@@ -58,7 +57,7 @@ const Posts = table("posts", {
58
57
  id: z.string().uuid().db.primary().db.auto(),
59
58
  authorId: z.string().uuid().db.references(Users, "author"),
60
59
  title: z.string(),
61
- published: z.boolean().default(false),
60
+ published: z.boolean().db.inserted(() => false),
62
61
  });
63
62
 
64
63
  // 2. Create database with migrations
@@ -111,6 +110,32 @@ const post = await db.get(Posts, posts[0].id);
111
110
  await db.update(Users, {name: "Alice Smith"}, user.id);
112
111
  ```
113
112
 
113
+ ## Why Zen?
114
+
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
+
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.
126
+ - **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
+
128
+ ### Safety
129
+
130
+ - **No lazy loading** — Related data comes from your JOINs
131
+ - **No ORM identity map** — Normalization is per-query, not session-wide
132
+ - **No down migrations** — Forward-only versioning (1 → 2 → 3)
133
+ - **No destructive helpers** — No `dropColumn()`, `dropTable()`, `renameColumn()`
134
+ - **No automatic migrations** — Schema changes are explicit in upgrade events
135
+
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
+
114
139
  ## Table Definitions
115
140
 
116
141
  ```typescript
@@ -121,7 +146,7 @@ const Users = table("users", {
121
146
  id: z.string().uuid().db.primary().db.auto(),
122
147
  email: z.string().email().db.unique(),
123
148
  name: z.string().max(100),
124
- role: z.enum(["user", "admin"]).default("user"),
149
+ role: z.enum(["user", "admin"]).db.inserted(() => "user"),
125
150
  createdAt: z.date().db.auto(),
126
151
  });
127
152
 
@@ -130,10 +155,23 @@ const Posts = table("posts", {
130
155
  title: z.string(),
131
156
  content: z.string().optional(),
132
157
  authorId: z.string().uuid().db.references(Users, "author", {onDelete: "cascade"}),
133
- published: z.boolean().default(false),
158
+ published: z.boolean().db.inserted(() => false),
134
159
  });
135
160
  ```
136
161
 
162
+ **Zod to Database Behavior:**
163
+
164
+ | Zod Method | Effect |
165
+ |------------|--------|
166
+ | `.optional()` | Column allows `NULL`; field omittable on insert |
167
+ | `.nullable()` | Column allows `NULL`; must explicitly pass `null` or value |
168
+ | `.string().max(n)` | `VARCHAR(n)` in DDL (if n ≤ 255) |
169
+ | `.string().uuid()` | Used by `.db.auto()` to generate UUIDs |
170
+ | `.number().int()` | `INTEGER` column type |
171
+ | `.date()` | `TIMESTAMPTZ` / `DATETIME` / `TEXT` depending on dialect |
172
+ | `.object()` / `.array()` | Stored as JSON, auto-encoded/decoded |
173
+ | `.default()` | **Throws error** — use `.db.inserted()` instead |
174
+
137
175
  **The `.db` namespace:**
138
176
 
139
177
  The `.db` property is available on all Zod types imported from `@b9g/zen`. It provides database-specific modifiers:
@@ -253,7 +291,14 @@ await db.delete(Users, userId);
253
291
  const activeUsers = await db.all(Users)`
254
292
  WHERE NOT ${Users.deleted()}
255
293
  `;
256
- // → WHERE NOT "users"."deletedAt" IS NOT NULL
294
+
295
+ // Or use the .active view (auto-generated, read-only)
296
+ const activeUsers = await db.all(Users.active)``;
297
+
298
+ // JOINs with .active automatically filter deleted rows
299
+ const posts = await db.all([Posts, Users.active])`
300
+ JOIN "users_active" ON ${Users.active.cols.id} = ${Posts.cols.authorId}
301
+ `;
257
302
  ```
258
303
 
259
304
  **Compound indexes** via table options:
@@ -267,6 +312,28 @@ const Posts = table("posts", {
267
312
  });
268
313
  ```
269
314
 
315
+ **Compound foreign keys** for composite primary keys:
316
+ ```typescript
317
+ const OrderProducts = table("order_products", {
318
+ orderId: z.string().uuid(),
319
+ productId: z.string().uuid(),
320
+ // ... compound primary key
321
+ });
322
+
323
+ const OrderItems = table("order_items", {
324
+ id: z.string().uuid().db.primary(),
325
+ orderId: z.string().uuid(),
326
+ productId: z.string().uuid(),
327
+ quantity: z.number(),
328
+ }, {
329
+ references: [{
330
+ fields: ["orderId", "productId"],
331
+ table: OrderProducts,
332
+ as: "orderProduct",
333
+ }],
334
+ });
335
+ ```
336
+
270
337
  **Derived properties** for client-side transformations:
271
338
  ```typescript
272
339
  const Posts = table("posts", {
@@ -279,27 +346,33 @@ const Posts = table("posts", {
279
346
  derive: {
280
347
  // Pure functions only (no I/O, no side effects)
281
348
  titleUpper: (post) => post.title.toUpperCase(),
282
- tags: (post) => post.postTags?.map(pt => pt.tag) ?? [],
349
+ // Traverse relationships (requires JOIN in query)
350
+ tags: (post) => post.postTags?.map(pt => pt.tag?.name) ?? [],
283
351
  }
284
352
  });
285
353
 
286
- const posts = await db.all([Posts, Users])`
354
+ type Post = Row<typeof Posts>;
355
+ // Post includes: id, title, authorId, titleUpper, tags
356
+
357
+ const posts = await db.all([Posts, Users, PostTags, Tags])`
287
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)}
288
361
  `;
289
362
 
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)
363
+ const post = posts[0];
364
+ 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)
295
368
  ```
296
369
 
297
370
  Derived properties:
298
371
  - Are lazy getters (computed on access, not stored)
299
372
  - Are non-enumerable (hidden from `Object.keys()` and `JSON.stringify()`)
300
373
  - 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
374
+ - Can traverse resolved relationships from the same query
375
+ - Are fully typed via `Row<T>` inference
303
376
 
304
377
  **Partial selects** with `pick()`:
305
378
  ```typescript
@@ -312,6 +385,55 @@ const posts = await db.all([Posts, UserSummary])`
312
385
 
313
386
  **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
387
 
388
+ ## Views
389
+
390
+ Views are read-only projections of tables with predefined WHERE clauses:
391
+
392
+ ```typescript
393
+ import {z, table, view} from "@b9g/zen";
394
+
395
+ const Users = table("users", {
396
+ id: z.string().db.primary(),
397
+ name: z.string(),
398
+ role: z.enum(["user", "admin"]),
399
+ deletedAt: z.date().nullable().db.softDelete(),
400
+ });
401
+
402
+ // Define views with explicit names
403
+ const ActiveUsers = view("active_users", Users)`
404
+ WHERE ${Users.cols.deletedAt} IS NULL
405
+ `;
406
+
407
+ const AdminUsers = view("admin_users", Users)`
408
+ WHERE ${Users.cols.role} = ${"admin"}
409
+ `;
410
+
411
+ // Query from views (same API as tables)
412
+ const admins = await db.all(AdminUsers)``;
413
+ const admin = await db.get(AdminUsers, "u1");
414
+
415
+ // Views are read-only — mutations throw errors
416
+ await db.insert(AdminUsers, {...}); // ✗ Error
417
+ await db.update(AdminUsers, {...}); // ✗ Error
418
+ await db.delete(AdminUsers, "u1"); // ✗ Error
419
+ ```
420
+
421
+ **Auto-generated `.active` view:** Tables with a `.db.softDelete()` field automatically get an `.active` view:
422
+
423
+ ```typescript
424
+ // Equivalent to: view("users_active", Users)`WHERE deletedAt IS NULL`
425
+ const activeUsers = await db.all(Users.active)``;
426
+ ```
427
+
428
+ **Views preserve table relationships:** Views inherit references from their base table, so JOINs work identically:
429
+
430
+ ```typescript
431
+ const posts = await db.all([Posts, AdminUsers])`
432
+ JOIN "admin_users" ON ${AdminUsers.on(Posts)}
433
+ `;
434
+ posts[0].author?.role; // "admin"
435
+ ```
436
+
315
437
  ## Queries
316
438
 
317
439
  Tagged templates with automatic parameterization:
@@ -485,7 +607,7 @@ zen provides idempotent helpers that encourage safe, additive-only migrations:
485
607
  const Posts = table("posts", {
486
608
  id: z.string().db.primary(),
487
609
  title: z.string(),
488
- views: z.number().default(0), // NEW - add to schema
610
+ views: z.number().db.inserted(() => 0), // NEW - add to schema
489
611
  });
490
612
 
491
613
  if (e.oldVersion < 2) {
@@ -727,6 +849,8 @@ interface Driver {
727
849
 
728
850
  **Migration locking**: If the driver provides `withMigrationLock()`, migrations run atomically (PostgreSQL uses advisory locks, MySQL uses `GET_LOCK`, SQLite uses exclusive transactions).
729
851
 
852
+ **Connection pooling**: Handled by the underlying driver. `postgres.js` and `mysql2` pool automatically; `better-sqlite3` uses a single connection (SQLite is single-writer anyway).
853
+
730
854
  ## Error Handling
731
855
 
732
856
  All errors extend `DatabaseError` with typed error codes:
@@ -776,6 +900,8 @@ await db.transaction(async (tx) => {
776
900
  - `AlreadyExistsError` — Unique constraint violated (tableName, field, value)
777
901
  - `QueryError` — SQL execution failed (sql)
778
902
  - `MigrationError` / `MigrationLockError` — Migration failures (fromVersion, toVersion)
903
+ - `EnsureError` — Schema ensure operation failed (operation, table, step)
904
+ - `SchemaDriftError` — Existing schema doesn't match definition (table, drift)
779
905
  - `ConnectionError` / `TransactionError` — Connection/transaction issues
780
906
 
781
907
  ## Debugging
@@ -815,17 +941,27 @@ console.log(Posts.ddl().toString());
815
941
 
816
942
  | Feature | SQLite | PostgreSQL | MySQL |
817
943
  |---------|--------|------------|-------|
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) |
944
+ | RETURNING | ✅ | ✅ | ⚠️ fallback |
945
+ | IF NOT EXISTS (CREATE TABLE) | ✅ | ✅ | |
946
+ | IF NOT EXISTS (ADD COLUMN) | ✅ | ✅ | ⚠️ may error |
947
+ | Migration Locks | BEGIN EXCLUSIVE | pg_advisory_lock | GET_LOCK |
948
+ | Advisory Locks | | | |
949
+
950
+ ### Zod to SQL Type Mapping
951
+
952
+ | Zod Type | SQLite | PostgreSQL | MySQL |
953
+ |----------|--------|------------|-------|
954
+ | `z.string()` | TEXT | TEXT | TEXT |
955
+ | `z.string().max(n)` (n ≤ 255) | TEXT | VARCHAR(n) | VARCHAR(n) |
956
+ | `z.number()` | REAL | DOUBLE PRECISION | REAL |
957
+ | `z.number().int()` | INTEGER | INTEGER | INTEGER |
958
+ | `z.boolean()` | INTEGER | BOOLEAN | BOOLEAN |
959
+ | `z.date()` | TEXT | TIMESTAMPTZ | DATETIME |
960
+ | `z.enum([...])` | TEXT | TEXT | TEXT |
961
+ | `z.object({...})` | TEXT | JSONB | TEXT |
962
+ | `z.array(...)` | TEXT | JSONB | TEXT |
963
+
964
+ Override with `.db.type("CUSTOM")` when using custom encode/decode.
829
965
 
830
966
  ## Public API Reference
831
967
 
@@ -836,9 +972,11 @@ import {
836
972
  // Zod (extended with .db namespace)
837
973
  z, // Re-exported Zod with .db already available
838
974
 
839
- // Table definition
975
+ // Table and view definition
840
976
  table, // Create a table definition from Zod schema
977
+ view, // Create a read-only view from a table
841
978
  isTable, // Type guard for Table objects
979
+ isView, // Type guard for View objects
842
980
  extendZod, // Extend a separate Zod instance (advanced)
843
981
 
844
982
  // Database
@@ -846,13 +984,12 @@ import {
846
984
  Transaction, // Transaction context (passed to transaction callbacks)
847
985
  DatabaseUpgradeEvent, // Event object for "upgradeneeded" handler
848
986
 
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
987
+ // 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
856
993
 
857
994
  // Errors
858
995
  DatabaseError, // Base error class
@@ -895,19 +1032,15 @@ import type {
895
1032
 
896
1033
  // Fragment types
897
1034
  SetValues, // Values accepted by Table.set()
898
- SQLFragment, // SQL fragment object
899
- DDLFragment, // DDL fragment object
1035
+ SQLTemplate, // SQL template object (return type of set(), on(), etc.)
900
1036
  SQLDialect, // "sqlite" | "postgresql" | "mysql"
901
1037
 
902
1038
  // Driver types
903
1039
  Driver, // Driver interface for adapters
904
1040
  TaggedQuery, // Tagged template query function
905
1041
 
906
- // Expression types
907
- DBExpression, // Runtime database expression
908
-
909
1042
  // Error types
910
- DatabaseErrorCode, // Error code string literals
1043
+ DatabaseErrorCode, // Error code string literals
911
1044
  } from "@b9g/zen";
912
1045
  ```
913
1046
 
@@ -961,6 +1094,9 @@ Users.pick("id", "email"); // PartialTable with subset of fields
961
1094
  Users.derive("hasEmail", z.boolean())`
962
1095
  ${Users.cols.email} IS NOT NULL
963
1096
  `;
1097
+
1098
+ // Views
1099
+ Users.active; // View excluding soft-deleted rows (read-only)
964
1100
  ```
965
1101
 
966
1102
  ### Database Methods
@@ -1020,25 +1156,3 @@ import PostgresDriver from "@b9g/zen/postgres";
1020
1156
  // MySQL (mysql2)
1021
1157
  import MySQLDriver from "@b9g/zen/mysql";
1022
1158
  ```
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
@@ -1,9 +1,10 @@
1
1
  import {
2
2
  createTemplate,
3
3
  getTableMeta,
4
+ getViewMeta,
4
5
  ident,
5
6
  makeTemplate
6
- } from "./chunk-56M5Z3A6.js";
7
+ } from "./chunk-BEX6VPES.js";
7
8
 
8
9
  // src/impl/ddl.ts
9
10
  import { z } from "zod";
@@ -303,8 +304,32 @@ CREATE INDEX ${indexExists}`;
303
304
  }
304
305
  return createTemplate(makeTemplate(strings), values);
305
306
  }
307
+ function generateViewDDL(viewObj, _options = {}) {
308
+ const viewMeta = getViewMeta(viewObj);
309
+ const strings = [];
310
+ const values = [];
311
+ strings.push("DROP VIEW IF EXISTS ");
312
+ values.push(ident(viewObj.name));
313
+ strings.push(";\n\n");
314
+ strings[strings.length - 1] += "CREATE VIEW ";
315
+ values.push(ident(viewObj.name));
316
+ strings.push(" AS SELECT * FROM ");
317
+ values.push(ident(viewMeta.baseTable.name));
318
+ strings.push(" ");
319
+ const whereTemplate = viewMeta.whereTemplate;
320
+ const whereStrings = whereTemplate[0];
321
+ const whereValues = whereTemplate.slice(1);
322
+ strings[strings.length - 1] += whereStrings[0];
323
+ for (let i = 0; i < whereValues.length; i++) {
324
+ values.push(whereValues[i]);
325
+ strings.push(whereStrings[i + 1]);
326
+ }
327
+ strings[strings.length - 1] += ";";
328
+ return createTemplate(makeTemplate(strings), values);
329
+ }
306
330
 
307
331
  export {
308
332
  generateColumnDDL,
309
- generateDDL
333
+ generateDDL,
334
+ generateViewDDL
310
335
  };