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..53307cf --- /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. +// ============================================================================ + +export 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. +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(), +}); +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"] +}