Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
58 changes: 50 additions & 8 deletions src/impl/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2849,19 +2849,55 @@ export type FullTableOnly<T> = 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<T>`. 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<this>`, 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<Shape extends ZodRawShape> = {
[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<O, K extends PropertyKey> = Omit<O, K> &
Partial<Pick<O, Extract<K, keyof O>>>;

/** Flatten an intersection so hovers and errors read as a single object. */
type Flatten<O> = {[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<typeof Users>;
* const Users = table("users", {
* id: z.string().uuid().db.primary().db.auto(),
* email: z.string().email(),
* });
* type NewUser = Insert<typeof Users>; // {id?: string; email: string}
*/
export type Insert<T extends Table<any>> = T extends {meta: {isPartial: true}}
? never
: T extends {meta: {isDerived: true}}
? never
: z.input<T["schema"]>;
: T extends Table<infer Shape, any, any>
? Flatten<
OptionalizeKeys<z.input<ZodObject<Shape>>, InsertOptionalKeys<Shape>>
>
: z.input<T["schema"]>;

/**
* Infer the update type (all fields optional, excludes primary key and insert-only fields).
Expand Down Expand Up @@ -3025,8 +3061,11 @@ export interface ZodDBMethods<Schema extends ZodType> {
*/
inserted(
value: import("./database.js").SQLBuiltin | (() => z.infer<Schema>),
): Schema;
inserted(strings: TemplateStringsArray, ...values: unknown[]): Schema;
): Schema & DBInsertOptional;
inserted(
strings: TemplateStringsArray,
...values: unknown[]
): Schema & DBInsertOptional;

/**
* Set a value to apply on UPDATE only.
Expand Down Expand Up @@ -3059,8 +3098,11 @@ export interface ZodDBMethods<Schema extends ZodType> {
*/
upserted(
value: import("./database.js").SQLBuiltin | (() => z.infer<Schema>),
): 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.
Expand All @@ -3084,7 +3126,7 @@ export interface ZodDBMethods<Schema extends ZodType> {
* createdAt: z.date().db.auto()
* // → NOW on insert
*/
auto(): Schema;
auto(): Schema & DBInsertOptional;
}

declare module "zod" {
Expand Down
118 changes: 118 additions & 0 deletions test/types/public-api.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Timestamps> = {title: "Hello"};

// ...and may still be supplied explicitly.
export const explicitInsert: Insert<typeof Timestamps> = {
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<typeof Timestamps> = {};

// @ts-expect-error - `name` is required
export const missingName: Insert<typeof Users> = {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<typeof Users> = {
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<typeof EitherOrder> = {c: "required"};

// ============================================================================
// Row: generated fields are present after a read, not optional.
// ============================================================================

export function rowKeepsGeneratedFields(row: Row<typeof Timestamps>) {
const id: string = row.id;
const createdAt: Date = row.createdAt;
return {id, createdAt};
}

// ============================================================================
// Update: still all-optional.
// ============================================================================

export const emptyUpdate: Update<typeof Users> = {};
export const partialUpdate: Update<typeof Users> = {name: "Bob"};
18 changes: 18 additions & 0 deletions tsconfig.typecheck.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading