From 8a60f67336d9d7cf33f99e736edee6ad1ae13b9f Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 14 Jul 2026 17:34:52 -0400 Subject: [PATCH 1/2] Make Insert<> respect .db.auto()/.db.inserted(), and typecheck the public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README's own Quick Start did not compile: const user = await db.insert(Users, {email: "alice@...", name: "Alice"}); // ^ Property 'id' is missing even though `id` is `.db.primary().db.auto()`. The runtime was correct — it generated the UUID — but TypeScript rejected the call, so the headline example of a library whose pitch is "get typed objects" did not typecheck. Cause: Insert was just `z.input`. The .db.*() metadata lives in a runtime side-channel that the type system cannot see, so `z.input` saw `id: z.string()` and made it required. Both the Insert JSDoc ("respects defaults and .db.auto() fields") and auto()'s own JSDoc ("Field becomes optional for insert") already claimed the behaviour; only the types disagreed. Fix: .db.auto(), .db.inserted() and .db.upserted() now brand their return type with a phantom DBInsertOptional, exactly as .db.references() already brands with __refTable/__refAs. Insert reads the brand and makes those keys optional. Because `db` is typed as ZodDBMethods, the brand survives further chaining in any order, so .db.auto().db.primary() and .db.primary().db.auto() both work. Update is already Partial<>, so it needed no change. Why 597 passing tests missed this: nothing typechecks the public API. `bun test` does not typecheck, and tsconfig included only `src/**`, so test files and the README were never compiled. A type-level bug in the public surface could not be caught. So this also closes that hole: test/types/public-api.ts holds compile-time tests (including the README Quick Start verbatim), and `npm run typecheck` now runs against tsconfig.typecheck.json which includes them. Verified by mutation: reverting the brand on auto() makes typecheck fail with the original error. test/*.test.ts is deliberately still excluded — those files have pre-existing type errors, and folding them in would make the check useless. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016Vvtr747kUp1NobqCBeZJi --- package.json | 2 +- src/impl/table.ts | 58 ++++++++++++++++--- test/types/public-api.ts | 118 +++++++++++++++++++++++++++++++++++++++ tsconfig.typecheck.json | 18 ++++++ 4 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 test/types/public-api.ts create mode 100644 tsconfig.typecheck.json diff --git a/package.json b/package.json index 721b39e..91809a8 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "test": "bun test && npm run test:node", "test:bun": "bun test", "test:node": "node --import tsx --test src/impl/*.node-test.ts", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "prepublishOnly": "echo 'ERROR: Cannot publish from root directory. Use libuild publish instead.' && exit 1" }, diff --git a/src/impl/table.ts b/src/impl/table.ts index 3236b23..df5df22 100644 --- a/src/impl/table.ts +++ b/src/impl/table.ts @@ -2849,19 +2849,55 @@ export type FullTableOnly = T extends {meta: {isPartial: true}} ? never : T; +/** + * Phantom brand applied by `.db.auto()`, `.db.inserted()` and `.db.upserted()`. + * + * These methods mean "the value is supplied for you on INSERT", so the field + * must not be required in `Insert`. The metadata itself lives in a runtime + * side-channel, which the type system cannot see — hence the brand. It rides on + * the Zod field type in the table's shape, and because `db` is typed as + * `ZodDBMethods`, it survives further chaining in any order + * (`.db.auto().db.primary()` and `.db.primary().db.auto()` both keep it). + */ +export interface DBInsertOptional { + readonly __dbInsertOptional: true; +} + +/** Keys of a table shape whose values are generated on INSERT. */ +type InsertOptionalKeys = { + [K in keyof Shape]: Shape[K] extends DBInsertOptional ? K : never; +}[keyof Shape]; + +/** Make the given keys of `O` optional, leaving the rest required. */ +type OptionalizeKeys = Omit & + Partial>>; + +/** Flatten an intersection so hovers and errors read as a single object. */ +type Flatten = {[K in keyof O]: O[K]} & {}; + /** * Infer the insert type (respects defaults and .db.auto() fields). * Returns `never` for partial or derived tables to prevent insert at compile time. * + * Fields marked `.db.auto()`, `.db.inserted()` or `.db.upserted()` are optional, + * because the value is generated for you. Everything else stays required. + * * @example - * const Users = table("users", {...}); - * type NewUser = Insert; + * const Users = table("users", { + * id: z.string().uuid().db.primary().db.auto(), + * email: z.string().email(), + * }); + * type NewUser = Insert; // {id?: string; email: string} */ export type Insert> = T extends {meta: {isPartial: true}} ? never : T extends {meta: {isDerived: true}} ? never - : z.input; + : T extends Table + ? Flatten< + OptionalizeKeys>, InsertOptionalKeys> + > + : z.input; /** * Infer the update type (all fields optional, excludes primary key and insert-only fields). @@ -3025,8 +3061,11 @@ export interface ZodDBMethods { */ inserted( value: import("./database.js").SQLBuiltin | (() => z.infer), - ): Schema; - inserted(strings: TemplateStringsArray, ...values: unknown[]): Schema; + ): Schema & DBInsertOptional; + inserted( + strings: TemplateStringsArray, + ...values: unknown[] + ): Schema & DBInsertOptional; /** * Set a value to apply on UPDATE only. @@ -3059,8 +3098,11 @@ export interface ZodDBMethods { */ upserted( value: import("./database.js").SQLBuiltin | (() => z.infer), - ): Schema; - upserted(strings: TemplateStringsArray, ...values: unknown[]): Schema; + ): Schema & DBInsertOptional; + upserted( + strings: TemplateStringsArray, + ...values: unknown[] + ): Schema & DBInsertOptional; /** * Auto-generate value on insert based on field type. @@ -3084,7 +3126,7 @@ export interface ZodDBMethods { * createdAt: z.date().db.auto() * // → NOW on insert */ - auto(): Schema; + auto(): Schema & DBInsertOptional; } declare module "zod" { diff --git a/test/types/public-api.ts b/test/types/public-api.ts new file mode 100644 index 0000000..3582ce0 --- /dev/null +++ b/test/types/public-api.ts @@ -0,0 +1,118 @@ +/** + * Compile-time tests for the public API. + * + * This file is never executed. It exists because nothing else typechecks the + * public surface: `bun test` does not typecheck at all, and `tsconfig.json` + * used to include only `src/**`. That gap is how `db.insert()` shipped unable + * to compile the README's own Quick Start — the runtime generated the id + * correctly, but TypeScript rejected the call. + * + * Anything asserted here is checked by `npm run typecheck`. Type errors in this + * file are test failures. + */ +import { + z, + table, + Database, + NOW, + type Insert, + type Row, + type Update, +} from "../../src/zen.js"; + +declare const db: Database; + +// ============================================================================ +// The README Quick Start, verbatim. This must compile. +// ============================================================================ + +const Users = table("users", { + id: z.string().uuid().db.primary().db.auto(), + email: z.string().email().db.unique(), + name: z.string(), +}); + +const Posts = table("posts", { + id: z.string().uuid().db.primary().db.auto(), + authorId: z.string().uuid().db.references(Users, "author"), + title: z.string(), + published: z.boolean().db.inserted(() => false), +}); + +export async function readmeQuickStart() { + // `id` is omitted: it is .db.auto(). + const user = await db.insert(Users, { + email: "alice@example.com", + name: "Alice", + }); + + // `id` and `published` are omitted: .db.auto() and .db.inserted(). + await db.insert(Posts, {authorId: user.id, title: "Hello"}); + + // The generated id is present on the returned row. + const id: string = user.id; + return id; +} + +// ============================================================================ +// Insert: generated fields optional, everything else still required. +// ============================================================================ + +const Timestamps = table("timestamps", { + id: z.string().uuid().db.primary().db.auto(), + title: z.string(), + createdAt: z.date().db.inserted(NOW), + updatedAt: z.date().db.upserted(NOW), +}); + +// Every generated field may be omitted... +export const minimalInsert: Insert = {title: "Hello"}; + +// ...and may still be supplied explicitly. +export const explicitInsert: Insert = { + id: "e5b7c1f0-0000-4000-8000-000000000000", + title: "Hello", + createdAt: new Date(), + updatedAt: new Date(), +}; + +// A field with no .db generator stays required. +// @ts-expect-error - `title` is required +export const missingRequired: Insert = {}; + +// @ts-expect-error - `name` is required +export const missingName: Insert = {email: "a@b.com"}; + +// Unknown fields are still rejected. (The excess-property error is reported on +// the offending property, so the directive has to sit directly above it.) +export const unknownField: Insert = { + email: "a@b.com", + name: "Alice", + // @ts-expect-error - `nope` is not a field + nope: true, +}; + +// The brand must survive chaining in either order. +const EitherOrder = table("either_order", { + a: z.string().uuid().db.primary().db.auto(), + b: z.string().uuid().db.auto().db.unique(), + c: z.string(), +}); +export const chainOrder: Insert = {c: "required"}; + +// ============================================================================ +// Row: generated fields are present after a read, not optional. +// ============================================================================ + +export function rowKeepsGeneratedFields(row: Row) { + const id: string = row.id; + const createdAt: Date = row.createdAt; + return {id, createdAt}; +} + +// ============================================================================ +// Update: still all-optional. +// ============================================================================ + +export const emptyUpdate: Update = {}; +export const partialUpdate: Update = {name: "Bob"}; diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json new file mode 100644 index 0000000..b49741a --- /dev/null +++ b/tsconfig.typecheck.json @@ -0,0 +1,18 @@ +{ + // Typecheck-only config. The base tsconfig drives the build (rootDir: ./src), + // so it cannot include files outside src/. This one adds the compile-time + // tests in test/types/ — which assert things `bun test` cannot, because + // `bun test` does not typecheck. + // + // test/*.test.ts is deliberately NOT included: those files have pre-existing + // type errors, and folding them in would make this check useless. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "declaration": false, + "declarationMap": false + }, + "include": ["src/**/*", "test/types/**/*"], + "exclude": ["node_modules", "dist"] +} From 2120efa1a19e9a5d253468d5528a72709228eb92 Mon Sep 17 00:00:00 2001 From: Brian Kim Date: Tue, 11 Aug 2026 23:46:59 -0400 Subject: [PATCH 2/2] Fix lint errors in the public-API type tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Timestamps` and `EitherOrder` are built at runtime but only read through `typeof`, so no-unused-vars flagged them as type-only. Export them, matching how the assertions in the same file are already declared — this is a compile-time test module, so its declarations are the surface. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sxss5Fa7yjQr3on4VArnuk --- test/types/public-api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/types/public-api.ts b/test/types/public-api.ts index 3582ce0..53307cf 100644 --- a/test/types/public-api.ts +++ b/test/types/public-api.ts @@ -58,7 +58,7 @@ export async function readmeQuickStart() { // Insert: generated fields optional, everything else still required. // ============================================================================ -const Timestamps = table("timestamps", { +export const Timestamps = table("timestamps", { id: z.string().uuid().db.primary().db.auto(), title: z.string(), createdAt: z.date().db.inserted(NOW), @@ -93,7 +93,7 @@ export const unknownField: Insert = { }; // The brand must survive chaining in either order. -const EitherOrder = table("either_order", { +export const EitherOrder = table("either_order", { a: z.string().uuid().db.primary().db.auto(), b: z.string().uuid().db.auto().db.unique(), c: z.string(),