@b9g/zen 0.1.3 → 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,23 @@ 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
+
8
25
  ## [0.1.3] - 2025-12-22
9
26
 
10
27
  ### Added
package/README.md CHANGED
@@ -595,14 +595,16 @@ IndexedDB-style event-based migrations:
595
595
  db.addEventListener("upgradeneeded", (e) => {
596
596
  e.waitUntil((async () => {
597
597
  if (e.oldVersion < 1) {
598
- await db.exec`${Users.ddl()}`;
599
- await db.exec`${Posts.ddl()}`;
598
+ await db.ensureTable(Users);
599
+ await db.ensureTable(Posts);
600
600
  }
601
601
  if (e.oldVersion < 2) {
602
- 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
603
604
  }
604
605
  if (e.oldVersion < 3) {
605
- await db.exec`${Posts.ensureIndex(["title"])}`;
606
+ // Add constraints after data cleanup
607
+ await db.ensureConstraints(Posts);
606
608
  }
607
609
  })());
608
610
  });
@@ -623,7 +625,7 @@ await db.open(3); // Opens at version 3, fires upgradeneeded if needed
623
625
  zen provides idempotent helpers that encourage safe, additive-only migrations:
624
626
 
625
627
  ```typescript
626
- // Add a new column (reads from schema)
628
+ // Add a new column - update schema and call ensureTable
627
629
  const Posts = table("posts", {
628
630
  id: z.string().db.primary(),
629
631
  title: z.string(),
@@ -631,24 +633,32 @@ const Posts = table("posts", {
631
633
  });
632
634
 
633
635
  if (e.oldVersion < 2) {
634
- await db.exec`${Posts.ensureColumn("views")}`;
636
+ await db.ensureTable(Posts); // Adds missing columns from schema
635
637
  }
636
- // → 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
+ });
637
646
 
638
- // Add an index
639
647
  if (e.oldVersion < 3) {
640
- await db.exec`${Posts.ensureIndex(["title", "views"])}`;
648
+ await db.ensureTable(Posts); // Adds missing indexes
641
649
  }
642
- // → 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")
643
651
 
644
652
  // Safe column rename (additive, non-destructive)
645
653
  const Users = table("users", {
646
- 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
647
657
  });
648
658
 
649
659
  if (e.oldVersion < 4) {
650
- await db.exec`${Users.ensureColumn("emailAddress")}`;
651
- await db.exec`${Users.copyColumn("email", "emailAddress")}`;
660
+ await db.ensureTable(Users); // Adds emailAddress column
661
+ await db.copyColumn(Users, "email", "emailAddress"); // Copy data
652
662
  // Keep old "email" column for backwards compat
653
663
  // Drop it in a later migration if needed (manual SQL)
654
664
  }
@@ -656,9 +666,10 @@ if (e.oldVersion < 4) {
656
666
  ```
657
667
 
658
668
  **Helper methods:**
659
- - `table.ensureColumn(fieldName, options?)` - Idempotent ALTER TABLE ADD COLUMN
660
- - `table.ensureIndex(fields, options?)` - Idempotent CREATE INDEX
661
- - `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)
662
673
 
663
674
  All helpers read from your table schema (single source of truth) and are safe to run multiple times (idempotent).
664
675
 
@@ -937,25 +948,9 @@ const query = db.print`SELECT * FROM ${Posts} WHERE ${Posts.cols.published} = ${
937
948
  console.log(query.sql); // SELECT * FROM "posts" WHERE "posts"."published" = ?
938
949
  console.log(query.params); // [true]
939
950
 
940
- // Inspect DDL generation
941
- const ddl = db.print`${Posts.ddl()}`;
942
- console.log(ddl.sql); // CREATE TABLE IF NOT EXISTS "posts" (...)
943
-
944
- // Analyze query execution plan
945
- const plan = await db.explain`
946
- SELECT * FROM ${Posts}
947
- WHERE ${Posts.cols.authorId} = ${userId}
948
- `;
949
- console.log(plan);
950
- // SQLite: [{ detail: "SEARCH posts USING INDEX idx_posts_authorId (authorId=?)" }]
951
- // PostgreSQL: [{ "QUERY PLAN": "Index Scan using idx_posts_authorId on posts" }]
952
-
953
951
  // Debug fragments
954
952
  console.log(Posts.set({ title: "Updated" }).toString());
955
953
  // SQLFragment { sql: "\"title\" = ?", params: ["Updated"] }
956
-
957
- console.log(Posts.ddl().toString());
958
- // DDLFragment { type: "create-table", table: "posts" }
959
954
  ```
960
955
 
961
956
  ## Dialect Support
@@ -1085,12 +1080,6 @@ const Posts = table("posts", {
1085
1080
 
1086
1081
  const rows = [{id: "u1", email: "alice@example.com", deletedAt: null}];
1087
1082
 
1088
- // DDL Generation
1089
- Users.ddl(); // DDLFragment for CREATE TABLE
1090
- Users.ensureColumn("emailAddress"); // DDLFragment for ALTER TABLE ADD COLUMN
1091
- Users.ensureIndex(["email"]); // DDLFragment for CREATE INDEX
1092
- Users.copyColumn("email", "emailAddress"); // SQLFragment for UPDATE (copy data)
1093
-
1094
1083
  // Query Fragments
1095
1084
  Users.set({email: "alice@example.com"}); // SQLFragment for SET clause
1096
1085
  Users.values(rows); // SQLFragment for INSERT VALUES
@@ -1157,9 +1146,15 @@ await db.transaction(async (tx) => {
1157
1146
  await tx.exec`SELECT 1`;
1158
1147
  });
1159
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
+
1160
1155
  // Debugging
1161
- db.print`SELECT 1`;
1162
- 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
1163
1158
  ```
1164
1159
 
1165
1160
  ### Driver Exports
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@b9g/zen",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Define Zod tables. Write raw SQL. Get typed objects.",
5
5
  "keywords": [
6
6
  "database",
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
  }
package/src/bun.js CHANGED
@@ -370,7 +370,9 @@ var BunDriver = class {
370
370
  },
371
371
  transaction: async () => {
372
372
  throw new Error("Nested transactions are not supported");
373
- }
373
+ },
374
+ getColumns: this.getColumns.bind(this),
375
+ explain: this.explain.bind(this)
374
376
  };
375
377
  return await fn(txDriver);
376
378
  });
@@ -524,6 +526,15 @@ var BunDriver = class {
524
526
  async getColumns(tableName) {
525
527
  return await this.#getColumns(tableName);
526
528
  }
529
+ async explain(strings, values) {
530
+ await this.#ensureSqliteInit();
531
+ const { sql, params } = buildSQL(strings, values, this.#dialect);
532
+ const explainPrefix = this.#dialect === "sqlite" ? "EXPLAIN QUERY PLAN " : "EXPLAIN ";
533
+ return await this.#sql.unsafe(
534
+ explainPrefix + sql,
535
+ params
536
+ );
537
+ }
527
538
  // ==========================================================================
528
539
  // Introspection Helpers (private)
529
540
  // ==========================================================================
@@ -127,12 +127,14 @@ export interface Driver {
127
127
  * @returns Number of rows updated
128
128
  */
129
129
  copyColumn?<T extends Table<any>>(table: T, fromField: string, toField: string): Promise<number>;
130
- /** Optional introspection: list columns for a table (name, type, nullability). */
131
- getColumns?(tableName: string): Promise<{
130
+ /** Introspection: list columns for a table (name, type, nullability). */
131
+ getColumns(tableName: string): Promise<{
132
132
  name: string;
133
133
  type?: string;
134
134
  notnull?: boolean;
135
135
  }[]>;
136
+ /** Get the query execution plan for a SQL query. */
137
+ explain(strings: TemplateStringsArray, values: unknown[]): Promise<Record<string, unknown>[]>;
136
138
  }
137
139
  /**
138
140
  * Result from ensure operations.
@@ -396,6 +398,20 @@ export declare class Database extends EventTarget {
396
398
  sql: string;
397
399
  params: unknown[];
398
400
  };
401
+ /**
402
+ * Get the query execution plan without running the query.
403
+ *
404
+ * Returns the database's EXPLAIN output for the given query.
405
+ * The format varies by database:
406
+ * - SQLite: EXPLAIN QUERY PLAN output
407
+ * - PostgreSQL: EXPLAIN output
408
+ * - MySQL: EXPLAIN output
409
+ *
410
+ * @example
411
+ * const plan = await db.explain`SELECT * FROM ${Users} WHERE email = ${"test@example.com"}`;
412
+ * console.log(plan);
413
+ */
414
+ explain(strings: TemplateStringsArray, ...values: unknown[]): Promise<Record<string, unknown>[]>;
399
415
  /**
400
416
  * Ensure a table exists with its columns and indexes.
401
417
  *
package/src/mysql.d.ts CHANGED
@@ -71,4 +71,10 @@ export default class MySQLDriver implements Driver {
71
71
  * Applies unique and foreign key constraints with preflight checks.
72
72
  */
73
73
  ensureConstraints<T extends Table<any>>(table: T): Promise<EnsureResult>;
74
+ getColumns(tableName: string): Promise<{
75
+ name: string;
76
+ type: string;
77
+ notnull: boolean;
78
+ }[]>;
79
+ explain(strings: TemplateStringsArray, values: unknown[]): Promise<Record<string, unknown>[]>;
74
80
  }
package/src/mysql.js CHANGED
@@ -268,7 +268,9 @@ var MySQLDriver = class {
268
268
  },
269
269
  transaction: async () => {
270
270
  throw new Error("Nested transactions are not supported");
271
- }
271
+ },
272
+ getColumns: this.getColumns.bind(this),
273
+ explain: this.explain.bind(this)
272
274
  };
273
275
  const result = await fn(txDriver);
274
276
  await connection.query("COMMIT");
@@ -443,7 +445,7 @@ var MySQLDriver = class {
443
445
  );
444
446
  return (rows[0]?.count ?? 0) > 0;
445
447
  }
446
- async #getColumns(tableName) {
448
+ async getColumns(tableName) {
447
449
  const [rows] = await this.#pool.execute(
448
450
  `SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? ORDER BY ordinal_position`,
449
451
  [tableName]
@@ -454,6 +456,11 @@ var MySQLDriver = class {
454
456
  notnull: row.IS_NULLABLE === "NO"
455
457
  }));
456
458
  }
459
+ async explain(strings, values) {
460
+ const { sql, params } = buildSQL(strings, values);
461
+ const [rows] = await this.#pool.execute(`EXPLAIN ${sql}`, params);
462
+ return rows;
463
+ }
457
464
  async #getIndexes(tableName) {
458
465
  const [rows] = await this.#pool.execute(
459
466
  `SELECT
@@ -508,7 +515,7 @@ var MySQLDriver = class {
508
515
  return constraints;
509
516
  }
510
517
  async #ensureMissingColumns(table) {
511
- const existingCols = await this.#getColumns(table.name);
518
+ const existingCols = await this.getColumns(table.name);
512
519
  const existingColNames = new Set(existingCols.map((c) => c.name));
513
520
  const schemaFields = Object.keys(table.schema.shape);
514
521
  let applied = false;
package/src/postgres.d.ts CHANGED
@@ -71,4 +71,10 @@ export default class PostgresDriver implements Driver {
71
71
  * Applies unique and foreign key constraints with preflight checks.
72
72
  */
73
73
  ensureConstraints<T extends Table<any>>(table: T): Promise<EnsureResult>;
74
+ getColumns(tableName: string): Promise<{
75
+ name: string;
76
+ type: string;
77
+ notnull: boolean;
78
+ }[]>;
79
+ explain(strings: TemplateStringsArray, values: unknown[]): Promise<Record<string, unknown>[]>;
74
80
  }
package/src/postgres.js CHANGED
@@ -235,7 +235,9 @@ var PostgresDriver = class {
235
235
  },
236
236
  transaction: async () => {
237
237
  throw new Error("Nested transactions are not supported");
238
- }
238
+ },
239
+ getColumns: this.getColumns.bind(this),
240
+ explain: this.explain.bind(this)
239
241
  };
240
242
  return await fn(txDriver);
241
243
  });
@@ -397,7 +399,7 @@ var PostgresDriver = class {
397
399
  `;
398
400
  return result[0]?.exists ?? false;
399
401
  }
400
- async #getColumns(tableName) {
402
+ async getColumns(tableName) {
401
403
  const result = await this.#sql`
402
404
  SELECT column_name, data_type, is_nullable
403
405
  FROM information_schema.columns
@@ -411,6 +413,13 @@ var PostgresDriver = class {
411
413
  notnull: row.is_nullable === "NO"
412
414
  }));
413
415
  }
416
+ async explain(strings, values) {
417
+ const { sql, params } = buildSQL(strings, values);
418
+ return await this.#sql.unsafe(
419
+ `EXPLAIN ${sql}`,
420
+ params
421
+ );
422
+ }
414
423
  async #getIndexes(tableName) {
415
424
  const result = await this.#sql`
416
425
  SELECT indexname, indexdef
@@ -474,7 +483,7 @@ var PostgresDriver = class {
474
483
  });
475
484
  }
476
485
  async #ensureMissingColumns(table) {
477
- const existingCols = await this.#getColumns(table.name);
486
+ const existingCols = await this.getColumns(table.name);
478
487
  const existingColNames = new Set(existingCols.map((c) => c.name));
479
488
  const schemaFields = Object.keys(table.schema.shape);
480
489
  let applied = false;
package/src/sqlite.d.ts CHANGED
@@ -51,4 +51,10 @@ export default class SQLiteDriver implements Driver {
51
51
  ensureTable<T extends Table<any>>(table: T): Promise<EnsureResult>;
52
52
  ensureView<T extends View<any>>(viewObj: T): Promise<EnsureResult>;
53
53
  ensureConstraints<T extends Table<any>>(table: T): Promise<EnsureResult>;
54
+ getColumns(tableName: string): Promise<{
55
+ name: string;
56
+ type: string;
57
+ notnull: boolean;
58
+ }[]>;
59
+ explain(strings: TemplateStringsArray, values: unknown[]): Promise<Record<string, unknown>[]>;
54
60
  }
package/src/sqlite.js CHANGED
@@ -343,7 +343,7 @@ var SQLiteDriver = class {
343
343
  const result = this.#db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).all(tableName);
344
344
  return result.length > 0;
345
345
  }
346
- async #getColumns(tableName) {
346
+ async getColumns(tableName) {
347
347
  const result = this.#db.prepare(`PRAGMA table_info(${quoteIdent2(tableName)})`).all();
348
348
  return result.map((row) => ({
349
349
  name: row.name,
@@ -351,6 +351,10 @@ var SQLiteDriver = class {
351
351
  notnull: row.notnull === 1
352
352
  }));
353
353
  }
354
+ async explain(strings, values) {
355
+ const { sql, params } = buildSQL(strings, values);
356
+ return this.#db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...params);
357
+ }
354
358
  async #getIndexes(tableName) {
355
359
  const indexList = this.#db.prepare(`PRAGMA index_list(${quoteIdent2(tableName)})`).all();
356
360
  const indexes = [];
@@ -403,7 +407,7 @@ var SQLiteDriver = class {
403
407
  // Schema Ensure Helpers (private)
404
408
  // ==========================================================================
405
409
  async #ensureMissingColumns(table) {
406
- const existingCols = await this.#getColumns(table.name);
410
+ const existingCols = await this.getColumns(table.name);
407
411
  const existingColNames = new Set(existingCols.map((c) => c.name));
408
412
  const schemaFields = Object.keys(table.schema.shape);
409
413
  let applied = false;
package/src/zen.js CHANGED
@@ -2018,6 +2018,26 @@ var Database = class extends EventTarget {
2018
2018
  }
2019
2019
  return { sql, params: expandedValues };
2020
2020
  }
2021
+ /**
2022
+ * Get the query execution plan without running the query.
2023
+ *
2024
+ * Returns the database's EXPLAIN output for the given query.
2025
+ * The format varies by database:
2026
+ * - SQLite: EXPLAIN QUERY PLAN output
2027
+ * - PostgreSQL: EXPLAIN output
2028
+ * - MySQL: EXPLAIN output
2029
+ *
2030
+ * @example
2031
+ * const plan = await db.explain`SELECT * FROM ${Users} WHERE email = ${"test@example.com"}`;
2032
+ * console.log(plan);
2033
+ */
2034
+ async explain(strings, ...values) {
2035
+ const { strings: expandedStrings, values: expandedValues } = expandFragments(
2036
+ strings,
2037
+ values
2038
+ );
2039
+ return await this.#driver.explain(expandedStrings, expandedValues);
2040
+ }
2021
2041
  // ==========================================================================
2022
2042
  // Schema Ensure Methods
2023
2043
  // ==========================================================================
@@ -2190,34 +2210,8 @@ var Database = class extends EventTarget {
2190
2210
  * Queries the actual table structure to verify column existence.
2191
2211
  */
2192
2212
  async #checkColumnExists(tableName, columnName) {
2193
- if (this.#driver.getColumns) {
2194
- const columns = await this.#driver.getColumns(tableName);
2195
- return columns.some((col) => col.name === columnName);
2196
- }
2197
- try {
2198
- const pragmaStrings = makeTemplate(["PRAGMA table_info(", ")"]);
2199
- const pragmaValues = [ident(tableName)];
2200
- const columns = await this.#driver.all(
2201
- pragmaStrings,
2202
- pragmaValues
2203
- );
2204
- if (columns.length > 0) {
2205
- return columns.some((col) => col.name === columnName);
2206
- }
2207
- } catch {
2208
- }
2209
- try {
2210
- const schemaStrings = makeTemplate([
2211
- "SELECT column_name FROM information_schema.columns WHERE table_name = ",
2212
- " AND column_name = ",
2213
- " LIMIT 1"
2214
- ]);
2215
- const schemaValues = [tableName, columnName];
2216
- const result = await this.#driver.all(schemaStrings, schemaValues);
2217
- return result.length > 0;
2218
- } catch {
2219
- return true;
2220
- }
2213
+ const columns = await this.#driver.getColumns(tableName);
2214
+ return columns.some((col) => col.name === columnName);
2221
2215
  }
2222
2216
  // ==========================================================================
2223
2217
  // Transactions