@b9g/zen 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +240 -125
- package/package.json +12 -3
- package/{chunk-W7JTNEM4.js → src/_chunks/chunk-2C6KOX4F.js} +1 -1
- package/{chunk-CHF7L5PC.js → src/_chunks/chunk-2R6FDKLS.js} +1 -1
- package/{chunk-XHXMCOSW.js → src/_chunks/chunk-DKLSJISE.js} +40 -0
- package/{ddl-2A2UFUR3.js → src/_chunks/ddl-32B7E53E.js} +2 -2
- package/src/bun.js +4 -4
- package/src/impl/builtins.d.ts +52 -0
- package/src/impl/database.d.ts +495 -0
- package/src/impl/ddl.d.ts +34 -0
- package/src/impl/errors.d.ts +195 -0
- package/src/impl/query.d.ts +249 -0
- package/src/impl/sql.d.ts +47 -0
- package/src/impl/table.d.ts +961 -0
- package/src/impl/template.d.ts +75 -0
- package/src/mysql.js +3 -3
- package/src/postgres.js +3 -3
- package/src/sqlite.js +3 -3
- package/src/zen.d.ts +6 -7
- package/src/zen.js +5 -2
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.3] - 2025-12-22
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Validation that compound constraints (`indexes`, `unique`, `references` options) have 2+ fields
|
|
13
|
+
- Single-field constraints now throw `TableDefinitionError` with helpful message pointing to field-level API
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **TypeScript types now work in published package** - Updated libuild to fix module augmentation and `.d.ts` path resolution
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- New tagline: "Define Zod tables. Write raw SQL. Get typed objects."
|
|
22
|
+
|
|
23
|
+
### Documentation
|
|
24
|
+
|
|
25
|
+
- Added `.db.inserted()`, `.db.updated()`, `.db.upserted()` documentation with examples
|
|
26
|
+
- Added compound unique constraints example
|
|
27
|
+
- Fixed table naming consistency (all plural)
|
|
28
|
+
- Various README cleanups and improvements
|
|
29
|
+
|
|
30
|
+
## [0.1.2] - 2025-12-22
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- New type exports: `PartialTable`, `DerivedTable`, `SetValues`, `FieldDBMeta`, `ReferenceInfo`, `CompoundReference`, `TaggedQuery`, `SQLDialect`, `isTable`
|
|
35
|
+
- Views documentation section in README
|
|
36
|
+
- `EnsureError` and `SchemaDriftError` documented in error types
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- Reorganized `zen.ts` exports into logical groups
|
|
41
|
+
- README Types section now accurately reflects actual exports (removed non-existent `SQLFragment`, `DDLFragment`, `DBExpression`)
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- `isTable` type guard now exported (was missing)
|
|
46
|
+
|
|
8
47
|
## [0.1.1] - 2025-12-21
|
|
9
48
|
|
|
10
49
|
### Added
|
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
|
+
Define Zod tables. Write raw SQL. Get typed objects.
|
|
5
4
|
|
|
6
|
-
|
|
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
|
|
|
@@ -48,7 +47,7 @@ import SQLiteDriver from "@b9g/zen/sqlite";
|
|
|
48
47
|
const driver = new SQLiteDriver("file:app.db");
|
|
49
48
|
|
|
50
49
|
// 1. Define tables
|
|
51
|
-
|
|
50
|
+
let Users = table("users", {
|
|
52
51
|
id: z.string().uuid().db.primary().db.auto(),
|
|
53
52
|
email: z.string().email().db.unique(),
|
|
54
53
|
name: z.string(),
|
|
@@ -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().
|
|
60
|
+
published: z.boolean().db.inserted(() => false),
|
|
62
61
|
});
|
|
63
62
|
|
|
64
63
|
// 2. Create database with migrations
|
|
@@ -74,14 +73,14 @@ db.addEventListener("upgradeneeded", (e) => {
|
|
|
74
73
|
}
|
|
75
74
|
if (e.oldVersion < 2) {
|
|
76
75
|
// Evolve schema: add avatar column (safe, additive)
|
|
77
|
-
|
|
76
|
+
Users = table("users", {
|
|
78
77
|
id: z.string().uuid().db.primary().db.auto(),
|
|
79
78
|
email: z.string().email().db.unique(),
|
|
80
79
|
name: z.string(),
|
|
81
|
-
avatar: z.string().optional(),
|
|
80
|
+
avatar: z.string().optional(), // new field
|
|
82
81
|
});
|
|
83
|
-
await db.ensureTable(
|
|
84
|
-
await db.ensureConstraints(
|
|
82
|
+
await db.ensureTable(Users); // adds missing columns/indexes
|
|
83
|
+
await db.ensureConstraints(Users); // applies new constraints on existing data
|
|
85
84
|
}
|
|
86
85
|
})());
|
|
87
86
|
});
|
|
@@ -111,6 +110,23 @@ 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 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.
|
|
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.
|
|
121
|
+
|
|
122
|
+
### Safety
|
|
123
|
+
|
|
124
|
+
- **No lazy loading** — Related data comes from your JOINs
|
|
125
|
+
- **No ORM identity map** — Normalization is per-query, not session-wide
|
|
126
|
+
- **No down migrations** — Forward-only versioning (1 → 2 → 3)
|
|
127
|
+
- **No destructive helpers** — No `dropColumn()`, `dropTable()`, `renameColumn()`
|
|
128
|
+
- **No automatic migrations** — Schema changes are explicit in upgrade events
|
|
129
|
+
|
|
114
130
|
## Table Definitions
|
|
115
131
|
|
|
116
132
|
```typescript
|
|
@@ -121,7 +137,7 @@ const Users = table("users", {
|
|
|
121
137
|
id: z.string().uuid().db.primary().db.auto(),
|
|
122
138
|
email: z.string().email().db.unique(),
|
|
123
139
|
name: z.string().max(100),
|
|
124
|
-
role: z.enum(["user", "admin"]).
|
|
140
|
+
role: z.enum(["user", "admin"]).db.inserted(() => "user"),
|
|
125
141
|
createdAt: z.date().db.auto(),
|
|
126
142
|
});
|
|
127
143
|
|
|
@@ -130,10 +146,23 @@ const Posts = table("posts", {
|
|
|
130
146
|
title: z.string(),
|
|
131
147
|
content: z.string().optional(),
|
|
132
148
|
authorId: z.string().uuid().db.references(Users, "author", {onDelete: "cascade"}),
|
|
133
|
-
published: z.boolean().
|
|
149
|
+
published: z.boolean().db.inserted(() => false),
|
|
134
150
|
});
|
|
135
151
|
```
|
|
136
152
|
|
|
153
|
+
**Zod to Database Behavior:**
|
|
154
|
+
|
|
155
|
+
| Zod Method | Effect |
|
|
156
|
+
|------------|--------|
|
|
157
|
+
| `.optional()` | Column allows `NULL`; field omittable on insert |
|
|
158
|
+
| `.nullable()` | Column allows `NULL`; must explicitly pass `null` or value |
|
|
159
|
+
| `.string().max(n)` | `VARCHAR(n)` in DDL (if n ≤ 255) |
|
|
160
|
+
| `.string().uuid()` | Used by `.db.auto()` to generate UUIDs |
|
|
161
|
+
| `.number().int()` | `INTEGER` column type |
|
|
162
|
+
| `.date()` | `TIMESTAMPTZ` / `DATETIME` / `TEXT` depending on dialect |
|
|
163
|
+
| `.object()` / `.array()` | Stored as JSON, auto-encoded/decoded |
|
|
164
|
+
| `.default()` | **Throws error** — use `.db.inserted()` instead |
|
|
165
|
+
|
|
137
166
|
**The `.db` namespace:**
|
|
138
167
|
|
|
139
168
|
The `.db` property is available on all Zod types imported from `@b9g/zen`. It provides database-specific modifiers:
|
|
@@ -176,10 +205,37 @@ const Users = table("users", {
|
|
|
176
205
|
|
|
177
206
|
// id and createdAt are optional - auto-generated if not provided
|
|
178
207
|
const user = await db.insert(Users, {name: "Alice"});
|
|
179
|
-
user.id;
|
|
208
|
+
user.id; // "550e8400-e29b-41d4-a716-446655440000"
|
|
180
209
|
user.createdAt; // 2024-01-15T10:30:00.000Z
|
|
181
210
|
```
|
|
182
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
|
+
|
|
183
239
|
**Automatic JSON encoding/decoding:**
|
|
184
240
|
|
|
185
241
|
Objects (`z.object()`) and arrays (`z.array()`) are automatically serialized to JSON when stored and parsed back when read:
|
|
@@ -200,7 +256,7 @@ const settings = await db.insert(Settings, {
|
|
|
200
256
|
|
|
201
257
|
// On read: JSON strings are parsed back to objects/arrays
|
|
202
258
|
settings.config.theme; // "dark" (object, not string)
|
|
203
|
-
settings.tags[0];
|
|
259
|
+
settings.tags[0]; // "admin" (array, not string)
|
|
204
260
|
```
|
|
205
261
|
|
|
206
262
|
**Custom encoding/decoding:**
|
|
@@ -208,7 +264,7 @@ settings.tags[0]; // "admin" (array, not string)
|
|
|
208
264
|
Override automatic JSON encoding with custom transformations:
|
|
209
265
|
|
|
210
266
|
```typescript
|
|
211
|
-
const
|
|
267
|
+
const Articles = table("articles", {
|
|
212
268
|
id: z.string().db.primary(),
|
|
213
269
|
// Store array as CSV instead of JSON
|
|
214
270
|
tags: z.array(z.string())
|
|
@@ -217,8 +273,8 @@ const Custom = table("custom", {
|
|
|
217
273
|
.db.type("TEXT"), // Required: explicit column type for DDL
|
|
218
274
|
});
|
|
219
275
|
|
|
220
|
-
await db.insert(
|
|
221
|
-
// Stored as: tags='
|
|
276
|
+
await db.insert(Articles, {id: "a1", tags: ["news", "tech", "featured"]});
|
|
277
|
+
// Stored as: tags='news,tech,featured' (not '["news","tech","featured"]')
|
|
222
278
|
```
|
|
223
279
|
|
|
224
280
|
**Explicit column types:**
|
|
@@ -263,14 +319,38 @@ const posts = await db.all([Posts, Users.active])`
|
|
|
263
319
|
`;
|
|
264
320
|
```
|
|
265
321
|
|
|
266
|
-
**Compound indexes** via table options:
|
|
322
|
+
**Compound indexes and unique constraints** via table options:
|
|
267
323
|
```typescript
|
|
268
324
|
const Posts = table("posts", {
|
|
269
325
|
id: z.string().db.primary(),
|
|
270
326
|
authorId: z.string(),
|
|
327
|
+
slug: z.string(),
|
|
271
328
|
createdAt: z.date(),
|
|
272
329
|
}, {
|
|
273
330
|
indexes: [["authorId", "createdAt"]],
|
|
331
|
+
unique: [["authorId", "slug"]], // unique together
|
|
332
|
+
});
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
**Compound foreign keys** for composite primary keys:
|
|
336
|
+
```typescript
|
|
337
|
+
const OrderProducts = table("order_products", {
|
|
338
|
+
orderId: z.string().uuid(),
|
|
339
|
+
productId: z.string().uuid(),
|
|
340
|
+
// ... compound primary key
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const OrderItems = table("order_items", {
|
|
344
|
+
id: z.string().uuid().db.primary(),
|
|
345
|
+
orderId: z.string().uuid(),
|
|
346
|
+
productId: z.string().uuid(),
|
|
347
|
+
quantity: z.number(),
|
|
348
|
+
}, {
|
|
349
|
+
references: [{
|
|
350
|
+
fields: ["orderId", "productId"],
|
|
351
|
+
table: OrderProducts,
|
|
352
|
+
as: "orderProduct",
|
|
353
|
+
}],
|
|
274
354
|
});
|
|
275
355
|
```
|
|
276
356
|
|
|
@@ -286,39 +366,94 @@ const Posts = table("posts", {
|
|
|
286
366
|
derive: {
|
|
287
367
|
// Pure functions only (no I/O, no side effects)
|
|
288
368
|
titleUpper: (post) => post.title.toUpperCase(),
|
|
289
|
-
|
|
369
|
+
// Traverse relationships (requires JOIN in query)
|
|
370
|
+
tags: (post) => post.postTags?.map(pt => pt.tag?.name) ?? [],
|
|
290
371
|
}
|
|
291
372
|
});
|
|
292
373
|
|
|
293
|
-
|
|
294
|
-
|
|
374
|
+
type Post = Row<typeof Posts>;
|
|
375
|
+
// Post includes: id, title, authorId, titleUpper, tags
|
|
376
|
+
|
|
377
|
+
const posts = await db.all([Posts, Users, PostTags, Tags])`
|
|
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)}
|
|
295
381
|
`;
|
|
296
382
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
post.
|
|
300
|
-
Object.keys(post);
|
|
301
|
-
JSON.stringify(post);
|
|
383
|
+
const post = posts[0];
|
|
384
|
+
post.titleUpper; // "HELLO WORLD" — typed as string
|
|
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)
|
|
302
388
|
```
|
|
303
389
|
|
|
304
390
|
Derived properties:
|
|
305
391
|
- Are lazy getters (computed on access, not stored)
|
|
306
392
|
- Are non-enumerable (hidden from `Object.keys()` and `JSON.stringify()`)
|
|
307
393
|
- Must be pure functions (no I/O, no database queries)
|
|
308
|
-
-
|
|
309
|
-
- Are
|
|
394
|
+
- Can traverse resolved relationships from the same query
|
|
395
|
+
- Are fully typed via `Row<T>` inference
|
|
310
396
|
|
|
311
397
|
**Partial selects** with `pick()`:
|
|
312
398
|
```typescript
|
|
313
|
-
const
|
|
314
|
-
const posts = await db.all([Posts,
|
|
315
|
-
JOIN
|
|
399
|
+
const UserSummaries = Users.pick("id", "name");
|
|
400
|
+
const posts = await db.all([Posts, UserSummaries])`
|
|
401
|
+
JOIN ${UserSummaries} ON ${UserSummaries.on(Posts)}
|
|
316
402
|
`;
|
|
317
403
|
// posts[0].author has only id and name
|
|
318
404
|
```
|
|
319
405
|
|
|
320
406
|
**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.
|
|
321
407
|
|
|
408
|
+
## Views
|
|
409
|
+
|
|
410
|
+
Views are read-only projections of tables with predefined WHERE clauses:
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
import {z, table, view} from "@b9g/zen";
|
|
414
|
+
|
|
415
|
+
const Users = table("users", {
|
|
416
|
+
id: z.string().db.primary(),
|
|
417
|
+
name: z.string(),
|
|
418
|
+
role: z.enum(["user", "admin"]),
|
|
419
|
+
deletedAt: z.date().nullable().db.softDelete(),
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// Define views with explicit names
|
|
423
|
+
const ActiveUsers = view("active_users", Users)`
|
|
424
|
+
WHERE ${Users.cols.deletedAt} IS NULL
|
|
425
|
+
`;
|
|
426
|
+
|
|
427
|
+
const AdminUsers = view("admin_users", Users)`
|
|
428
|
+
WHERE ${Users.cols.role} = ${"admin"}
|
|
429
|
+
`;
|
|
430
|
+
|
|
431
|
+
// Query from views (same API as tables)
|
|
432
|
+
const admins = await db.all(AdminUsers)``;
|
|
433
|
+
const admin = await db.get(AdminUsers, "u1");
|
|
434
|
+
|
|
435
|
+
// Views are read-only — mutations throw errors
|
|
436
|
+
await db.insert(AdminUsers, {...}); // ✗ Error
|
|
437
|
+
await db.update(AdminUsers, {...}); // ✗ Error
|
|
438
|
+
await db.delete(AdminUsers, "u1"); // ✗ Error
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
**Auto-generated `.active` view:** Tables with a `.db.softDelete()` field automatically get an `.active` view:
|
|
442
|
+
|
|
443
|
+
```typescript
|
|
444
|
+
// Equivalent to: view("users_active", Users)`WHERE deletedAt IS NULL`
|
|
445
|
+
const activeUsers = await db.all(Users.active)``;
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
**Views preserve table relationships:** Views inherit references from their base table, so JOINs work identically:
|
|
449
|
+
|
|
450
|
+
```typescript
|
|
451
|
+
const posts = await db.all([Posts, AdminUsers])`
|
|
452
|
+
JOIN "admin_users" ON ${AdminUsers.on(Posts)}
|
|
453
|
+
`;
|
|
454
|
+
posts[0].author?.role; // "admin"
|
|
455
|
+
```
|
|
456
|
+
|
|
322
457
|
## Queries
|
|
323
458
|
|
|
324
459
|
Tagged templates with automatic parameterization:
|
|
@@ -355,6 +490,29 @@ await db.exec`CREATE INDEX idx_posts_author ON ${Posts}(${Posts.cols.authorId})`
|
|
|
355
490
|
const count = await db.val<number>`SELECT COUNT(*) FROM ${Posts}`;
|
|
356
491
|
```
|
|
357
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
|
+
|
|
358
516
|
## Fragment Helpers
|
|
359
517
|
|
|
360
518
|
Type-safe SQL fragments as methods on Table objects:
|
|
@@ -404,29 +562,6 @@ const posts = await db.all(Posts)`WHERE ${Posts.in("id", [])}`;
|
|
|
404
562
|
// → WHERE 1 = 0
|
|
405
563
|
```
|
|
406
564
|
|
|
407
|
-
## CRUD Helpers
|
|
408
|
-
|
|
409
|
-
```typescript
|
|
410
|
-
// Insert with Zod validation (uses RETURNING to get actual row)
|
|
411
|
-
const user = await db.insert(Users, {
|
|
412
|
-
email: "alice@example.com",
|
|
413
|
-
name: "Alice",
|
|
414
|
-
});
|
|
415
|
-
// Returns actual row from DB, including auto-generated id and DB-computed defaults
|
|
416
|
-
const userId = user.id;
|
|
417
|
-
|
|
418
|
-
// Update by primary key (uses RETURNING)
|
|
419
|
-
const updated = await db.update(Users, {name: "Bob"}, userId);
|
|
420
|
-
|
|
421
|
-
// Delete by primary key
|
|
422
|
-
await db.delete(Users, userId);
|
|
423
|
-
|
|
424
|
-
// Soft delete (sets deletedAt timestamp, requires softDelete() field)
|
|
425
|
-
await db.softDelete(Users, userId);
|
|
426
|
-
```
|
|
427
|
-
|
|
428
|
-
**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.
|
|
429
|
-
|
|
430
565
|
## Transactions
|
|
431
566
|
|
|
432
567
|
```typescript
|
|
@@ -492,7 +627,7 @@ zen provides idempotent helpers that encourage safe, additive-only migrations:
|
|
|
492
627
|
const Posts = table("posts", {
|
|
493
628
|
id: z.string().db.primary(),
|
|
494
629
|
title: z.string(),
|
|
495
|
-
views: z.number().
|
|
630
|
+
views: z.number().db.inserted(() => 0), // NEW - add to schema
|
|
496
631
|
});
|
|
497
632
|
|
|
498
633
|
if (e.oldVersion < 2) {
|
|
@@ -703,6 +838,7 @@ const refs = Posts.references(); // [{fieldName: "authorId", table: Users,
|
|
|
703
838
|
- Tagged template queries are cached by template object identity (compiled once per call site)
|
|
704
839
|
- Normalization cost is O(rows) with hash maps per table
|
|
705
840
|
- Reference resolution is zero-cost after deduplication
|
|
841
|
+
- Zod validation happens on writes, never on reads.
|
|
706
842
|
|
|
707
843
|
## Driver Interface
|
|
708
844
|
|
|
@@ -734,6 +870,8 @@ interface Driver {
|
|
|
734
870
|
|
|
735
871
|
**Migration locking**: If the driver provides `withMigrationLock()`, migrations run atomically (PostgreSQL uses advisory locks, MySQL uses `GET_LOCK`, SQLite uses exclusive transactions).
|
|
736
872
|
|
|
873
|
+
**Connection pooling**: Handled by the underlying driver. `postgres.js` and `mysql2` pool automatically; `better-sqlite3` uses a single connection (SQLite is single-writer anyway).
|
|
874
|
+
|
|
737
875
|
## Error Handling
|
|
738
876
|
|
|
739
877
|
All errors extend `DatabaseError` with typed error codes:
|
|
@@ -783,6 +921,8 @@ await db.transaction(async (tx) => {
|
|
|
783
921
|
- `AlreadyExistsError` — Unique constraint violated (tableName, field, value)
|
|
784
922
|
- `QueryError` — SQL execution failed (sql)
|
|
785
923
|
- `MigrationError` / `MigrationLockError` — Migration failures (fromVersion, toVersion)
|
|
924
|
+
- `EnsureError` — Schema ensure operation failed (operation, table, step)
|
|
925
|
+
- `SchemaDriftError` — Existing schema doesn't match definition (table, drift)
|
|
786
926
|
- `ConnectionError` / `TransactionError` — Connection/transaction issues
|
|
787
927
|
|
|
788
928
|
## Debugging
|
|
@@ -851,40 +991,41 @@ Override with `.db.type("CUSTOM")` when using custom encode/decode.
|
|
|
851
991
|
```typescript
|
|
852
992
|
import {
|
|
853
993
|
// Zod (extended with .db namespace)
|
|
854
|
-
z,
|
|
994
|
+
z, // Re-exported Zod with .db already available
|
|
995
|
+
extendZod, // Extend a separate Zod instance (advanced)
|
|
855
996
|
|
|
856
|
-
// Table definition
|
|
857
|
-
table,
|
|
858
|
-
|
|
859
|
-
|
|
997
|
+
// Table and view definition
|
|
998
|
+
table, // Create a table definition from Zod schema
|
|
999
|
+
view, // Create a read-only view from a table
|
|
1000
|
+
isTable, // Type guard for Table objects
|
|
1001
|
+
isView, // Type guard for View objects
|
|
860
1002
|
|
|
861
1003
|
// Database
|
|
862
|
-
Database,
|
|
863
|
-
Transaction,
|
|
864
|
-
DatabaseUpgradeEvent,
|
|
1004
|
+
Database, // Main database class
|
|
1005
|
+
Transaction, // Transaction context (passed to transaction callbacks)
|
|
1006
|
+
DatabaseUpgradeEvent, // Event object for "upgradeneeded" handler
|
|
865
1007
|
|
|
866
|
-
//
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
//
|
|
871
|
-
|
|
872
|
-
getDBMeta, // Get database metadata from a Zod schema
|
|
1008
|
+
// SQL builtins (for .db.inserted() / .db.updated())
|
|
1009
|
+
NOW, // CURRENT_TIMESTAMP alias
|
|
1010
|
+
TODAY, // CURRENT_DATE alias
|
|
1011
|
+
CURRENT_TIMESTAMP, // SQL CURRENT_TIMESTAMP
|
|
1012
|
+
CURRENT_DATE, // SQL CURRENT_DATE
|
|
1013
|
+
CURRENT_TIME, // SQL CURRENT_TIME
|
|
873
1014
|
|
|
874
1015
|
// Errors
|
|
875
|
-
DatabaseError,
|
|
876
|
-
ValidationError,
|
|
877
|
-
TableDefinitionError,
|
|
878
|
-
MigrationError,
|
|
879
|
-
MigrationLockError,
|
|
880
|
-
QueryError,
|
|
881
|
-
NotFoundError,
|
|
882
|
-
AlreadyExistsError,
|
|
1016
|
+
DatabaseError, // Base error class
|
|
1017
|
+
ValidationError, // Schema validation failed
|
|
1018
|
+
TableDefinitionError, // Invalid table definition
|
|
1019
|
+
MigrationError, // Migration failed
|
|
1020
|
+
MigrationLockError, // Failed to acquire migration lock
|
|
1021
|
+
QueryError, // SQL execution failed
|
|
1022
|
+
NotFoundError, // Entity not found
|
|
1023
|
+
AlreadyExistsError, // Unique constraint violated
|
|
883
1024
|
ConstraintViolationError, // Database constraint violated
|
|
884
|
-
ConnectionError,
|
|
885
|
-
TransactionError,
|
|
886
|
-
isDatabaseError,
|
|
887
|
-
hasErrorCode,
|
|
1025
|
+
ConnectionError, // Connection failed
|
|
1026
|
+
TransactionError, // Transaction failed
|
|
1027
|
+
isDatabaseError, // Type guard for DatabaseError
|
|
1028
|
+
hasErrorCode, // Check error code
|
|
888
1029
|
} from "@b9g/zen";
|
|
889
1030
|
```
|
|
890
1031
|
|
|
@@ -893,38 +1034,34 @@ import {
|
|
|
893
1034
|
```typescript
|
|
894
1035
|
import type {
|
|
895
1036
|
// Table types
|
|
896
|
-
Table,
|
|
897
|
-
PartialTable,
|
|
898
|
-
DerivedTable,
|
|
899
|
-
TableOptions,
|
|
900
|
-
ReferenceInfo,
|
|
901
|
-
CompoundReference,
|
|
1037
|
+
Table, // Table definition object
|
|
1038
|
+
PartialTable, // Table created via .pick()
|
|
1039
|
+
DerivedTable, // Table with derived fields via .derive()
|
|
1040
|
+
TableOptions, // Options for table()
|
|
1041
|
+
ReferenceInfo, // Foreign key reference metadata
|
|
1042
|
+
CompoundReference, // Compound foreign key reference
|
|
902
1043
|
|
|
903
1044
|
// Field types
|
|
904
|
-
FieldMeta,
|
|
905
|
-
FieldType,
|
|
906
|
-
FieldDBMeta,
|
|
1045
|
+
FieldMeta, // Field metadata for form generation
|
|
1046
|
+
FieldType, // Field type enum
|
|
1047
|
+
FieldDBMeta, // Database-specific field metadata
|
|
907
1048
|
|
|
908
1049
|
// Type inference
|
|
909
|
-
Row,
|
|
910
|
-
Insert,
|
|
911
|
-
Update,
|
|
1050
|
+
Row, // Infer row type from Table (after read)
|
|
1051
|
+
Insert, // Infer insert type from Table (respects defaults/.db.auto())
|
|
1052
|
+
Update, // Infer update type from Table (all fields optional)
|
|
912
1053
|
|
|
913
1054
|
// Fragment types
|
|
914
|
-
SetValues,
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
SQLDialect, // "sqlite" | "postgresql" | "mysql"
|
|
1055
|
+
SetValues, // Values accepted by Table.set()
|
|
1056
|
+
SQLTemplate, // SQL template object (return type of set(), on(), etc.)
|
|
1057
|
+
SQLDialect, // "sqlite" | "postgresql" | "mysql"
|
|
918
1058
|
|
|
919
1059
|
// Driver types
|
|
920
|
-
Driver,
|
|
921
|
-
TaggedQuery,
|
|
922
|
-
|
|
923
|
-
// Expression types
|
|
924
|
-
DBExpression, // Runtime database expression
|
|
1060
|
+
Driver, // Driver interface for adapters
|
|
1061
|
+
TaggedQuery, // Tagged template query function
|
|
925
1062
|
|
|
926
1063
|
// Error types
|
|
927
|
-
DatabaseErrorCode,
|
|
1064
|
+
DatabaseErrorCode, // Error code string literals
|
|
928
1065
|
} from "@b9g/zen";
|
|
929
1066
|
```
|
|
930
1067
|
|
|
@@ -1040,25 +1177,3 @@ import PostgresDriver from "@b9g/zen/postgres";
|
|
|
1040
1177
|
// MySQL (mysql2)
|
|
1041
1178
|
import MySQLDriver from "@b9g/zen/mysql";
|
|
1042
1179
|
```
|
|
1043
|
-
|
|
1044
|
-
## What This Library Does Not Do
|
|
1045
|
-
|
|
1046
|
-
**Query Generation:**
|
|
1047
|
-
- **No model classes** — Tables are plain definitions, not class instances
|
|
1048
|
-
- **No hidden JOINs** — You write all SQL explicitly
|
|
1049
|
-
- **No implicit query building** — No `.where().orderBy().limit()` chains
|
|
1050
|
-
- **No lazy loading** — Related data comes from your JOINs
|
|
1051
|
-
- **No ORM identity map** — Normalization is per-query, not session-wide
|
|
1052
|
-
|
|
1053
|
-
**Migrations:**
|
|
1054
|
-
- **No down migrations** — Forward-only, monotonic versioning (1 → 2 → 3)
|
|
1055
|
-
- **No destructive helpers** — No `dropColumn()`, `dropTable()`, `renameColumn()` methods
|
|
1056
|
-
- **No automatic migrations** — DDL must be written explicitly in upgrade events
|
|
1057
|
-
- **No migration files** — Event handlers replace traditional migration folders
|
|
1058
|
-
- **No branching versions** — Linear version history only
|
|
1059
|
-
|
|
1060
|
-
**Safety Philosophy:**
|
|
1061
|
-
- Migrations are **additive and idempotent** by design
|
|
1062
|
-
- Use `ensureColumn()`, `ensureIndex()`, `copyColumn()` for safe schema changes
|
|
1063
|
-
- Breaking changes require multi-step migrations (add, migrate data, deprecate)
|
|
1064
|
-
- Version numbers never decrease — rollbacks are new forward migrations
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@b9g/zen",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Define Zod tables. Write raw SQL. Get typed objects.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"database",
|
|
7
7
|
"sql",
|
|
@@ -12,12 +12,21 @@
|
|
|
12
12
|
"postgres",
|
|
13
13
|
"mysql"
|
|
14
14
|
],
|
|
15
|
+
"author": "Brian Kim <briankimdev@gmail.com>",
|
|
15
16
|
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/bikeshaving/zen.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/bikeshaving/zen/issues"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://zendb.org",
|
|
16
25
|
"dependencies": {
|
|
17
26
|
"zod": "^4.0.0"
|
|
18
27
|
},
|
|
19
28
|
"devDependencies": {
|
|
20
|
-
"@b9g/libuild": "^0.1.
|
|
29
|
+
"@b9g/libuild": "^0.1.21",
|
|
21
30
|
"@eslint/js": "^9.39.2",
|
|
22
31
|
"@types/better-sqlite3": "^7.6.0",
|
|
23
32
|
"@types/bun": "^1.3.4",
|