From 6b1175ae2d0b0971c6e3a916a7e7ea03f74148ab Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 14 Jul 2026 17:28:49 -0400 Subject: [PATCH 1/2] Add @b9g/zen/schema entrypoint for client-side usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #7. A table is just a Zod schema plus metadata, so it is useful well away from a database connection: form validation, API request/response contracts, and types shared between client and server. None of that needs Database, drivers, migrations, query building or DDL generation. @b9g/zen/schema exposes table/view definition, the extended zod, field metadata (for form generation), the SQL builtins and the validation errors — and nothing that reaches impl/database.js. One subtlety worth noting: the main entrypoint re-exports the SQL builtins *from* impl/database.js, so importing NOW from "@b9g/zen" pulls in the whole database runtime. This entrypoint re-exports them from impl/builtins.js instead. Same symbols (they are Symbol.for(), so identity holds across both), none of the runtime. extendZod is idempotent, so importing both entrypoints is safe. Bundled for the browser with zod external: @b9g/zen/schema 37,896 bytes @b9g/zen 108,829 bytes (65% larger) The isolation is asserted structurally rather than trusted: the tests bundle the entrypoint and assert Database/DatabaseUpgradeEvent/"upgradeneeded" are absent, with a control test asserting they ARE present in the main bundle, so the assertions cannot quietly become vacuous if the import graph changes. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016Vvtr747kUp1NobqCBeZJi --- package.json | 8 +++ src/schema.ts | 120 ++++++++++++++++++++++++++++++++++++++ test/schema.test.ts | 139 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 src/schema.ts create mode 100644 test/schema.test.ts diff --git a/package.json b/package.json index 721b39e..6888047 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,14 @@ "types": "./dist/src/mysql.d.ts", "import": "./dist/src/mysql.js" }, + "./schema": { + "types": "./dist/src/schema.d.ts", + "import": "./dist/src/schema.js" + }, + "./schema.js": { + "types": "./dist/src/schema.d.ts", + "import": "./dist/src/schema.js" + }, "./bun.js": { "types": "./dist/src/bun.d.ts", "import": "./dist/src/bun.js" diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..879d242 --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,120 @@ +/** + * @b9g/zen/schema - Table definitions without the database runtime. + * + * A table is just a Zod schema plus metadata, which makes it useful well away + * from a database connection: form validation, API request/response contracts, + * and types shared between client and server. + * + * This entrypoint exposes exactly that surface and deliberately does not reach + * `impl/database.js`, so importing it cannot pull `Database`, migrations, query + * building, normalization or DDL generation into a client bundle. (Note that + * the main entrypoint re-exports the SQL builtins *from* `impl/database.js`, so + * they are re-exported here from `impl/builtins.js` instead — same symbols, + * none of the runtime.) + * + * Import from "@b9g/zen" instead when you need to actually talk to a database. + */ + +import {z as zod} from "zod"; +import {extendZod} from "./impl/table.js"; + +// Extend zod on module load, exactly as the main entrypoint does. extendZod is +// idempotent, so importing both entrypoints is safe. +extendZod(zod); + +// Re-export extended zod +export {zod as z}; + +// ============================================================================ +// Table Definition +// ============================================================================ + +export { + // Functions + table, + view, + extendZod, + + // Type guards + isTable, + isView, + + // View helpers + getViewMeta, + + // Table types + type Table, + type PartialTable, + type DerivedTable, + type View, + type Queryable, + type TableOptions, + + // Row types + type Row, + type Insert, + type Update, + type SetValues, + + // Field types (for form generation) + type FieldMeta, + type FieldType, + type FieldDBMeta, + + // Reference types + type Relation, + type ReferenceInfo, + type CompoundReference, + + // View types + type ViewMeta, +} from "./impl/table.js"; + +// ============================================================================ +// SQL Builtins +// +// Plain `Symbol.for()` values used by .db.inserted() / .db.updated(). They +// carry no runtime behind them, so a table using NOW() still round-trips +// through this entrypoint. +// ============================================================================ + +export { + NOW, + TODAY, + CURRENT_TIMESTAMP, + CURRENT_DATE, + CURRENT_TIME, + isSQLBuiltin, + type SQLBuiltin, +} from "./impl/builtins.js"; + +export { + // SQL identifiers + ident, + isSQLIdentifier, + + // SQL templates + type SQLTemplate, + isSQLTemplate, +} from "./impl/template.js"; + +// ============================================================================ +// Errors +// +// Only the errors schema-side code can actually raise. Query, migration and +// connection errors live behind the database runtime and are not re-exported. +// ============================================================================ + +export { + // Base error + DatabaseError, + isDatabaseError, + hasErrorCode, + + // Validation errors + ValidationError, + TableDefinitionError, + + // Error types + type DatabaseErrorCode, +} from "./impl/errors.js"; diff --git a/test/schema.test.ts b/test/schema.test.ts new file mode 100644 index 0000000..8470f81 --- /dev/null +++ b/test/schema.test.ts @@ -0,0 +1,139 @@ +import {test, expect, describe} from "bun:test"; +import { + z, + table, + view, + isTable, + isView, + extendZod, + NOW, + CURRENT_TIMESTAMP, + ValidationError, + type Row, + type Insert, +} from "../src/schema.js"; + +describe("@b9g/zen/schema", () => { + describe("table definition without a database", () => { + test("defines tables and validates, with no driver or connection", () => { + const Users = table("users", { + id: z.string().uuid().db.primary().db.auto(), + email: z.string().email().db.unique(), + name: z.string().max(100), + role: z.enum(["user", "admin"]), + }); + + expect(isTable(Users)).toBe(true); + expect(Users.name).toBe("users"); + + // The whole point: validate a form payload client-side. + const ok = Users.schema.safeParse({ + id: "550e8400-e29b-41d4-a716-446655440000", + email: "alice@example.com", + name: "Alice", + role: "admin", + }); + expect(ok.success).toBe(true); + + const bad = Users.schema.safeParse({ + id: "550e8400-e29b-41d4-a716-446655440000", + email: "not-an-email", + name: "Alice", + role: "admin", + }); + expect(bad.success).toBe(false); + }); + + test("field metadata is available for form generation", () => { + const Users = table("users", { + id: z.string().uuid().db.primary(), + email: z.string().email().db.unique(), + name: z.string(), + }); + + const fields = Users.fields(); + expect(fields.email.name).toBe("email"); + expect(fields.email.db.unique).toBe(true); + expect(fields.id.db.primaryKey).toBe(true); + }); + + test("views work", () => { + const Users = table("users", { + id: z.string().db.primary(), + role: z.enum(["user", "admin"]), + }); + const Admins = view("admin_users", Users)` + WHERE ${Users.cols.role} = ${"admin"} + `; + expect(isView(Admins)).toBe(true); + }); + + test("SQL builtins are plain registered symbols", () => { + // They carry no runtime, which is why they can cross this entrypoint. + expect(typeof NOW).toBe("symbol"); + expect(NOW).toBe(CURRENT_TIMESTAMP); + expect(NOW).toBe(Symbol.for("@b9g/zen:CURRENT_TIMESTAMP")); + + const Posts = table("posts", { + id: z.string().db.primary(), + createdAt: z.date().db.inserted(NOW), + }); + expect(isTable(Posts)).toBe(true); + }); + + test("exports validation errors and extendZod", () => { + expect(typeof extendZod).toBe("function"); + expect(typeof ValidationError).toBe("function"); + }); + + test("row/insert types are re-exported", () => { + const Users = table("users", { + id: z.string().uuid().db.primary(), + email: z.string().email(), + }); + const row: Row = {id: "x", email: "a@b.com"}; + const draft: Insert = {id: "x", email: "a@b.com"}; + expect(row.id).toBe("x"); + expect(draft.email).toBe("a@b.com"); + }); + }); + + // The reason this entrypoint exists (#7): it must not drag the database + // runtime into a client bundle. Assert that structurally rather than trusting + // the import graph to stay clean. + describe("bundle isolation", () => { + async function bundle(entry: string): Promise { + const built = await Bun.build({ + entrypoints: [entry], + target: "browser", + external: ["zod"], + }); + expect(built.success).toBe(true); + return await built.outputs[0].text(); + } + + // NB: match on unambiguous markers. "class Database" is a substring of + // "class DatabaseError", which legitimately *is* in the schema bundle. + test("schema entrypoint excludes Database, Transaction and migrations", async () => { + const code = await bundle("./src/schema.ts"); + + expect(code).not.toContain("class Database extends EventTarget"); + expect(code).not.toContain("class DatabaseUpgradeEvent"); + expect(code).not.toContain("upgradeneeded"); + }); + + test("main entrypoint does include it (control)", async () => { + // If this ever stops being true, the assertions above prove nothing. + const code = await bundle("./src/zen.ts"); + + expect(code).toContain("class Database extends EventTarget"); + expect(code).toContain("upgradeneeded"); + }); + + test("schema bundle is substantially smaller than the main bundle", async () => { + const schema = await bundle("./src/schema.ts"); + const main = await bundle("./src/zen.ts"); + expect(schema.length).toBeLessThan(main.length); + }); + }); +}); From 66125a5aad27107927c8457931e8229c44c4faed Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 11 Aug 2026 23:46:22 -0400 Subject: [PATCH 2/2] Fix lint error in schema.test.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Users` was constructed at runtime but only read through `typeof Users`, so no-unused-vars flagged it as type-only. Assert on the table's name so the value is genuinely used — which also pins that table() survives the re-export, not just its types. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sxss5Fa7yjQr3on4VArnuk --- test/schema.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/schema.test.ts b/test/schema.test.ts index 8470f81..6dae398 100644 --- a/test/schema.test.ts +++ b/test/schema.test.ts @@ -93,6 +93,7 @@ describe("@b9g/zen/schema", () => { }); const row: Row = {id: "x", email: "a@b.com"}; const draft: Insert = {id: "x", email: "a@b.com"}; + expect(Users.name).toBe("users"); expect(row.id).toBe("x"); expect(draft.email).toBe("a@b.com"); });