diff --git a/packages/convex-helpers/README.md b/packages/convex-helpers/README.md index 76200676..a36b2020 100644 --- a/packages/convex-helpers/README.md +++ b/packages/convex-helpers/README.md @@ -432,6 +432,258 @@ export const myComplexQuery = zodQuery({ }); ``` +### Zod v4 usage + +If you are using Zod v4 (peer dependency zod >= 4.1.12), use the v4-native helper entrypoint at `convex-helpers/server/zod4`: + +```ts +import { z } from "zod"; +import { zCustomQuery, zid, zodToConvex, zodOutputToConvex } from "convex-helpers/server/zod4"; + +// Define this once - and customize like you would customQuery +const zodQuery = zCustomQuery(query, NoOp); + +export const myComplexQuery = zodQuery({ + args: { + userId: zid("users"), + email: z.string().email(), + num: z.number().min(0), + nullableBigint: z.nullable(z.bigint()), + boolWithDefault: z.boolean().default(true), + array: z.array(z.string()), + optionalObject: z.object({ a: z.string(), b: z.number() }).optional(), + union: z.union([z.string(), z.number()]), + discriminatedUnion: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("a"), a: z.string() }), + z.object({ kind: z.literal("b"), b: z.number() }), + ]), + readonly: z.object({ a: z.string(), b: z.number() }).readonly(), + pipeline: z.number().pipe(z.coerce.string()), + }, + handler: async (ctx, args) => { + // args are fully typed according to Zod v4 parsing + }, +}); +``` + +#### Full Zod v4 guide (using convex-helpers/server/zod4) + +This mirrors the structure from zodvex, adapted for the zod4 monolith in convex-helpers. + +##### Installation + +Ensure peer dependency `zod` is v4.1.12 or later. + +```bash +npm i zod convex convex-helpers +``` + +##### Quick Start + +Define reusable builders using `zCustomQuery`, `zCustomMutation`, `zCustomAction` with your preferred customization (NoOp is fine to start): + +```ts +// convex/util.ts +import { query, mutation, action } from "./_generated/server"; +import { zCustomQuery, zCustomMutation, zCustomAction } from "convex-helpers/server/zod4"; +import { NoOp } from "convex-helpers/server/customFunctions"; + +export const zq = zCustomQuery(query, NoOp); +export const zm = zCustomMutation(mutation, NoOp); +export const za = zCustomAction(action, NoOp); +``` + +Use the builders in functions: + +```ts +// convex/users.ts +import { z } from "zod"; +import { zid } from "convex-helpers/server/zod4"; +import { zq, zm } from "./util"; + +export const getUser = zq({ + args: { id: zid("users") }, + returns: z.object({ _id: z.string(), name: z.string() }).nullable(), + handler: async (ctx, { id }) => ctx.db.get(id), +}); + +export const createUser = zm({ + args: { name: z.string(), email: z.string().email() }, + returns: zid("users"), + handler: async (ctx, user) => ctx.db.insert("users", user), +}); +``` + +##### Defining Schemas + +Author your schemas as plain object shapes for best inference: + +```ts +import { z } from "zod"; +import { zid } from "convex-helpers/server/zod4"; + +export const userShape = { + name: z.string(), + email: z.string().email(), + age: z.number().optional(), + avatarUrl: z.string().url().nullable(), + teamId: zid("teams").optional(), +}; + +export const User = z.object(userShape); +``` + +##### Table Definitions (using zodToConvexFields) + +Use Convex's `defineTable` with `zodToConvexFields(shape)` to derive the validators: + +```ts +// convex/schema.ts +import { defineSchema, defineTable } from "convex/server"; +import { zodToConvexFields } from "convex-helpers/server/zod4"; +import { userShape } from "./tables/users"; + +export default defineSchema({ + users: defineTable(zodToConvexFields(userShape)) + .index("by_email", ["email"]) // you can add indexes as usual +}); +``` + +##### Defining Functions + +```ts +import { z } from "zod"; +import { zid } from "convex-helpers/server/zod4"; +import { zq, zm } from "./util"; + +export const listUsers = zq({ + args: {}, + returns: z.array(z.object({ _id: z.string(), name: z.string() })), + handler: async (ctx) => ctx.db.query("users").collect(), +}); + +export const deleteUser = zm({ + args: { id: zid("users") }, + returns: z.null(), + handler: async (ctx, { id }) => { + await ctx.db.delete(id); + return null; + }, +}); + +export const createUser = zm({ + args: userShape, + returns: zid("users"), + handler: async (ctx, user) => ctx.db.insert("users", user), +}); +``` + +##### Working with Subsets + +Use Zod's `.pick()` or object shape manipulation: + +```ts +const UpdateFields = User.pick({ name: true, email: true }); + +export const updateUserProfile = zm({ + args: { id: zid("users"), ...UpdateFields.shape }, + handler: async (ctx, { id, ...fields }) => { + await ctx.db.patch(id, fields); + }, +}); +``` + +##### Form Validation + +Use your Zod schemas for client-side form validation (e.g. with react-hook-form). Parse/validate on the server using the same schema via the zod4 builders. + +##### API Reference (zod4 subset) + +- Builders: `zCustomQuery`, `zCustomMutation`, `zCustomAction` +- Mapping: `zodToConvex`, `zodToConvexFields`, `zodOutputToConvex` +- Zid: `zid(tableName)` +- Codecs: `toConvexJS`, `fromConvexJS`, `convexCodec` + +Mapping helpers examples: + +```ts +import { z } from "zod"; +import { zodToConvex, zodToConvexFields } from "convex-helpers/server/zod4"; + +const v1 = zodToConvex(z.string().optional()); // → v.optional(v.string()) + +const fields = zodToConvexFields({ + name: z.string(), + age: z.number().nullable(), +}); +// → { name: v.string(), age: v.union(v.float64(), v.null()) } +``` + +Codecs: + +```ts +import { convexCodec } from "convex-helpers/server/zod4"; +import { z } from "zod"; + +const UserSchema = z.object({ name: z.string(), birthday: z.date().optional() }); +const codec = convexCodec(UserSchema); + +const encoded = codec.encode({ name: "Alice", birthday: new Date("1990-01-01") }); +// → { name: 'Alice', birthday: 631152000000 } + +const decoded = codec.decode(encoded); +// → { name: 'Alice', birthday: Date('1990-01-01') } +``` + +Supported types (Zod → Convex): + +| Zod Type | Convex Validator | +| ----------------- | -------------------------------- | +| `z.string()` | `v.string()` | +| `z.number()` | `v.float64()` | +| `z.bigint()` | `v.int64()` | +| `z.boolean()` | `v.boolean()` | +| `z.date()` | `v.float64()` (timestamp) | +| `z.null()` | `v.null()` | +| `z.array(T)` | `v.array(T)` | +| `z.object({...})` | `v.object({...})` | +| `z.record(T)` | `v.record(v.string(), T)` | +| `z.union([...])` | `v.union(...)` | +| `z.literal(x)` | `v.literal(x)` | +| `z.enum([...])` | `v.union(literals...)` | +| `z.optional(T)` | `v.optional(T)` | +| `z.nullable(T)` | `v.union(T, v.null())` | +| `zid('table')` | `v.id('table')` (via `zid`) | + +##### Advanced Usage: Custom Context Builders + +Inject auth/permissions logic using `customCtx` from `server/customFunctions` and compose with zod4 builders: + +```ts +import { customCtx } from "convex-helpers/server/customFunctions"; +import { zCustomQuery, zCustomMutation } from "convex-helpers/server/zod4"; +import { query, mutation } from "./_generated/server"; + +const authQuery = zCustomQuery(query, customCtx(async (ctx) => { + const user = await getUserOrThrow(ctx); + return { ctx: { user }, args: {} }; +})); + +export const updateProfile = authQuery({ + args: { name: z.string() }, + returns: z.null(), + handler: async (ctx, { name }) => { + await ctx.db.patch(ctx.user._id, { name }); + return null; + }, +}); +``` + +##### Date Handling + +Dates are automatically encoded/decoded by codecs. When mapping, `z.date()` becomes a `v.float64()` timestamp. Builders allow you to validate returns with `z.date()` and roundtrip via `toConvexJS`/`fromConvexJS` where needed. + + ## Hono for advanced HTTP endpoint definitions [Hono](https://hono.dev/) is an optimized web framework you can use to define diff --git a/packages/convex-helpers/package.json b/packages/convex-helpers/package.json index 56701c3b..8b28e9a2 100644 --- a/packages/convex-helpers/package.json +++ b/packages/convex-helpers/package.json @@ -115,6 +115,10 @@ "types": "./server/zod.d.ts", "default": "./server/zod.js" }, + "./server/zod4": { + "types": "./server/zod4.d.ts", + "default": "./server/zod4.js" + }, "./react/*": { "types": "./react/*.d.ts", "default": "./react/*.js" @@ -160,7 +164,7 @@ "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", - "zod": "^3.22.4 || ^4.0.15" + "zod": "^3.22.4 || ^4.1.12" }, "peerDependenciesMeta": { "@standard-schema/spec": { diff --git a/packages/convex-helpers/server/zod4.test.ts b/packages/convex-helpers/server/zod4.test.ts new file mode 100644 index 00000000..4dc9c9fb --- /dev/null +++ b/packages/convex-helpers/server/zod4.test.ts @@ -0,0 +1,99 @@ +import { expect, expectTypeOf, test } from "vitest"; +import { z } from "zod"; +import { v } from "convex/values"; +import { zodToConvexFields, convexToZod, zid, zodToConvex } from "./zod4.js"; +import { toStandardSchema } from "../standardSchema.js"; + +const exampleId = v.id("user"); +const exampleConvexNestedObj = v.object({ + id: exampleId, + requireString: v.string(), +}); + +const exampleConvexObj = v.object({ + requiredId: exampleId, + optionalId: v.optional(exampleId), + nullableId: v.union(exampleId, v.null()), + requiredString: v.string(), + optionalString: v.optional(v.string()), + nullableNumber: v.union(v.number(), v.null()), + requiredNested: exampleConvexNestedObj, + optionalNested: v.optional(exampleConvexNestedObj), +}); + +const exampleZid = zid("user"); +const exampleZodNestedObj = z.object({ + id: exampleZid, + requireString: z.string(), +}); + +const exampleZodObj = z.object({ + requiredId: exampleZid, + optionalId: z.optional(exampleZid), + nullableId: z.union([exampleZid, z.null()]), + requiredString: z.string(), + optionalString: z.optional(z.string()), + nullableNumber: z.union([z.number(), z.null()]), + requiredNested: exampleZodNestedObj, + optionalNested: z.optional(exampleZodNestedObj), +}); + +// Minimal smoke test to ensure zod4 surface compiles and runs a basic roundtrip +test("zod4 basic roundtrip", () => { + const shape = { a: z.string(), b: z.number().optional() }; + const vObj = zodToConvexFields(shape); + expect(vObj.a.kind).toBe("string"); + expect(vObj.b.isOptional).toBe("optional"); + const zObj = convexToZod(v.object(vObj)); + expect(zObj.constructor.name).toBe("ZodObject"); +}); + +test("convert zod validation to convex", () => { + const obj = zodToConvex(exampleZodObj); + + expect(obj.fields.requiredId.kind).toBe("id"); + expect(obj.fields.optionalId.isOptional).toEqual("optional"); + expect(obj.fields.optionalId.kind).toEqual("id"); + expect(obj.fields.nullableId.kind).toEqual("union"); + expect(obj.fields.optionalNested.kind).toEqual("object"); + expect(obj.fields.optionalNested.isOptional).toEqual("optional"); +}); + +test("convert convex validation to zod", () => { + const obj = convexToZod(exampleConvexObj); + + expect(obj.constructor.name).toBe("ZodObject"); + expect(obj.shape.requiredId._tableName).toBe("user"); + expect(obj.shape.requiredString.type).toBe("string"); + expect(obj.shape.optionalString.def.innerType.type).toBe("string"); + expect(obj.shape.optionalString.def.type).toBe("optional"); + expect(obj.shape.optionalId.def.innerType.type).toBe("pipe"); + // @ts-expect-error + expect(obj.shape.optionalId.def.innerType["_tableName"]).toBe("user"); + expect(obj.shape.optionalId.def.type).toBe("optional"); + expect(obj.shape.nullableNumber.def.options.map((o) => o.type)).toEqual([ + "number", + "null", + ]); + expect(obj.shape.nullableId.def.options.map((o) => o.type)).toEqual([ + "pipe", + "null", + ]); + expect( + // @ts-expect-error + obj.shape.nullableId.def.options.find((o) => o["_tableName"])._tableName, + ).toBe("user"); + expect(obj.shape.optionalNested.def.innerType.type).toBe("object"); + expect(obj.shape.optionalNested.def.type).toBe("optional"); + + obj.parse({ + requiredId: "user", + nullableId: null, + requiredString: "hello world", + nullableNumber: 124, + requiredNested: { + id: "user", + requireString: "hello world", + }, + }); +}); diff --git a/packages/convex-helpers/server/zod4.ts b/packages/convex-helpers/server/zod4.ts new file mode 100644 index 00000000..888d4eaf --- /dev/null +++ b/packages/convex-helpers/server/zod4.ts @@ -0,0 +1,20 @@ +export type { CustomBuilder, ZCustomCtx } from "./zod4/builder.js"; +export type { Zid } from "./zod4/id.js"; + +export { + zodToConvex, + zodToConvexFields, + zodOutputToConvex, + zodOutputToConvexFields, +} from "./zod4/zodToConvex.js"; + +export { convexToZod, convexToZodFields } from "./zod4/convexToZod.js"; + +export { zid, isZid } from "./zod4/id.js"; +export { withSystemFields, zBrand } from "./zod4/helpers.js"; +export { + customFnBuilder, + zCustomQuery, + zCustomAction, + zCustomMutation, +} from "./zod4/builder.js"; diff --git a/packages/convex-helpers/server/zod4/builder.ts b/packages/convex-helpers/server/zod4/builder.ts new file mode 100644 index 00000000..2f9142ff --- /dev/null +++ b/packages/convex-helpers/server/zod4/builder.ts @@ -0,0 +1,531 @@ +import type { Customization } from "convex-helpers/server/customFunctions"; +import type { + ActionBuilder, + GenericActionCtx, + GenericDataModel, + GenericMutationCtx, + GenericQueryCtx, + MutationBuilder, + QueryBuilder, +} from "convex/server"; +import type { Value } from "convex/values"; + +import { pick } from "convex-helpers"; +import { NoOp } from "convex-helpers/server/customFunctions"; +import { addFieldsToValidator } from "convex-helpers/validators"; +import { ConvexError } from "convex/values"; + +import { fromConvexJS, toConvexJS } from "./codec.js"; +import { zodOutputToConvex, zodToConvexFields } from "./zodToConvex.js"; + +import type { FunctionVisibility } from "convex/server"; +import type { PropertyValidators } from "convex/values"; +import type { Expand, OneArgArray, Overwrite, ZodValidator } from "./types.js"; + +import * as z from "zod/v4/core"; +import { ZodObject, ZodType, z as zValidate } from "zod"; + +type NullToUndefinedOrNull = T extends null ? T | undefined | void : T; +type Returns = Promise> | NullToUndefinedOrNull; + +// The return value before it's been validated: returned by the handler +type ReturnValueInput< + ReturnsValidator extends z.$ZodType | ZodValidator | void, +> = [ReturnsValidator] extends [z.$ZodType] + ? Returns> + : [ReturnsValidator] extends [ZodValidator] + ? Returns>> + : any; + +// The args after they've been validated: passed to the handler +type ArgsOutput | void> = + [ArgsValidator] extends [z.$ZodObject] + ? [z.output] + : [ArgsValidator] extends [ZodValidator] + ? [z.output>] + : OneArgArray; + +type ArgsForHandlerType< + OneOrZeroArgs extends [] | [Record], + CustomMadeArgs extends Record, +> = + CustomMadeArgs extends Record + ? OneOrZeroArgs + : OneOrZeroArgs extends [infer A] + ? [Expand] + : [CustomMadeArgs]; + +/** + * Useful to get the input context type for a custom function using zod. + */ +export type ZCustomCtx = + Builder extends CustomBuilder< + any, + any, + infer CustomCtx, + any, + infer InputCtx, + any, + any + > + ? Overwrite + : never; + +/** + * A builder that customizes a Convex function, whether or not it validates + * arguments. If the customization requires arguments, however, the resulting + * builder will require argument validation too. + */ +export type CustomBuilder< + _FuncType extends "query" | "mutation" | "action", + _CustomArgsValidator extends PropertyValidators, + CustomCtx extends Record, + CustomMadeArgs extends Record, + InputCtx, + Visibility extends FunctionVisibility, + ExtraArgs extends Record, +> = { + < + ArgsValidator extends ZodValidator | z.$ZodObject | void, + ReturnsZodValidator extends z.$ZodType | ZodValidator | void = void, + ReturnValue extends ReturnValueInput = any, + >( + func: + | ({ + args?: ArgsValidator; + handler: ( + ctx: Overwrite, + ...args: ArgsForHandlerType< + ArgsOutput, + CustomMadeArgs + > + ) => ReturnValue; + returns?: ReturnsZodValidator; + skipConvexValidation?: boolean; + } & { + [key in keyof ExtraArgs as key extends + | "args" + | "handler" + | "skipConvexValidation" + | "returns" + ? never + : key]: ExtraArgs[key]; + }) + | { + ( + ctx: Overwrite, + ...args: ArgsForHandlerType< + ArgsOutput, + CustomMadeArgs + > + ): ReturnValue; + }, + ): import("convex/server").RegisteredQuery & + import("convex/server").RegisteredMutation & + import("convex/server").RegisteredAction; +}; + +function handleZodValidationError( + e: unknown, + context: "args" | "returns", +): never { + if (e instanceof z.$ZodError) { + const issues = JSON.parse(JSON.stringify(e.issues, null, 2)) as Value[]; + throw new ConvexError({ + ZodError: issues, + context, + } as unknown as Record); + } + throw e; +} + +export function customFnBuilder( + builder: (args: any) => any, + customization: Customization, +) { + const customInput = customization.input ?? NoOp.input; + const inputArgs = customization.args ?? NoOp.args; + + return function customBuilder(fn: any): any { + const { args, handler = fn, returns: maybeObject, ...extra } = fn; + const returns = + maybeObject && !(maybeObject instanceof z.$ZodType) + ? zValidate.object(maybeObject) + : maybeObject; + const returnValidator = + returns && !fn.skipConvexValidation + ? { returns: zodOutputToConvex(returns) } + : undefined; + + if (args && !fn.skipConvexValidation) { + let argsValidator = args as + | Record + | z.$ZodObject; + let argsSchema: ZodObject; + + if (argsValidator instanceof ZodType) { + if (argsValidator instanceof ZodObject) { + argsSchema = argsValidator; + argsValidator = argsValidator.shape; + } else { + throw new Error( + "Unsupported non-object Zod schema for args; " + + "please provide an args schema using z.object({...})", + ); + } + } else { + argsSchema = zValidate.object(argsValidator); + } + + const convexValidator = zodToConvexFields( + argsValidator as Record, + ); + + return builder({ + args: addFieldsToValidator(convexValidator, inputArgs), + ...returnValidator, + handler: async (ctx: any, allArgs: any) => { + const added = await customInput( + ctx, + pick(allArgs, Object.keys(inputArgs)) as any, + extra, + ); + const argKeys = Object.keys( + argsValidator as Record, + ); + const rawArgs = pick(allArgs, argKeys); + const decoded = fromConvexJS(rawArgs, argsSchema); + const parsed = argsSchema.safeParse(decoded); + + if (!parsed.success) handleZodValidationError(parsed.error, "args"); + + const finalCtx = { + ...ctx, + ...(added?.ctx ?? {}), + }; + const baseArgs = parsed.data as Record; + const addedArgs = (added?.args as Record) ?? {}; + const finalArgs = { ...baseArgs, ...addedArgs }; + const ret = await handler(finalCtx, finalArgs); + + if (returns && !fn.skipConvexValidation) { + let validated: any; + try { + validated = (returns as ZodType).parse(ret); + } catch (e) { + handleZodValidationError(e, "returns"); + } + + if (added?.onSuccess) + await added.onSuccess({ + ctx, + args: parsed.data, + result: validated, + }); + + return toConvexJS(returns as z.$ZodType, validated); + } + + if (added?.onSuccess) + await added.onSuccess({ + ctx, + args: parsed.data, + result: ret, + }); + + return ret; + }, + }); + } + + return builder({ + args: inputArgs, + ...returnValidator, + handler: async (ctx: any, allArgs: any) => { + const added = await customInput( + ctx, + pick(allArgs, Object.keys(inputArgs)) as any, + extra, + ); + const finalCtx = { ...ctx, ...(added?.ctx ?? {}) }; + const baseArgs = allArgs as Record; + const addedArgs = (added?.args as Record) ?? {}; + const finalArgs = { ...baseArgs, ...addedArgs }; + const ret = await handler(finalCtx, finalArgs); + + if (returns && !fn.skipConvexValidation) { + let validated: any; + try { + validated = (returns as ZodType).parse(ret); + } catch (e) { + handleZodValidationError(e, "returns"); + } + + if (added?.onSuccess) + await added.onSuccess({ + ctx, + args: allArgs, + result: validated, + }); + + return toConvexJS(returns as z.$ZodType, validated); + } + + if (added?.onSuccess) + await added.onSuccess({ + ctx, + args: allArgs, + result: ret, + }); + + return ret; + }, + }); + }; +} + +/** + * zCustomQuery is like customQuery, but allows validation via zod. + * You can define custom behavior on top of `query` or `internalQuery` + * by passing a function that modifies the ctx and args. Or NoOp to do nothing. + * + * Example usage: + * ```js + * const myQueryBuilder = zCustomQuery(query, { + * args: { sessionId: v.id("sessions") }, + * input: async (ctx, args) => { + * const user = await getUserOrNull(ctx); + * const session = await db.get(sessionId); + * const db = wrapDatabaseReader({ user }, ctx.db, rlsRules); + * return { ctx: { db, user, session }, args: {} }; + * }, + * }); + * + * // Using the custom builder + * export const getSomeData = myQueryBuilder({ + * args: { someArg: z.string() }, + * handler: async (ctx, args) => { + * const { db, user, session, scheduler } = ctx; + * const { someArg } = args; + * // ... + * } + * }); + * ``` + * + * Simple usage only modifying ctx: + * ```js + * const myInternalQuery = zCustomQuery( + * internalQuery, + * customCtx(async (ctx) => { + * return { + * // Throws an exception if the user isn't logged in + * user: await getUserByTokenIdentifier(ctx), + * }; + * }) + * ); + * + * // Using it + * export const getUser = myInternalQuery({ + * args: { email: z.string().email() }, + * handler: async (ctx, args) => { + * console.log(args.email); + * return ctx.user; + * }, + * }); + * + * @param query The query to be modified. Usually `query` or `internalQuery` + * from `_generated/server`. + * @param customization The customization to be applied to the query, changing ctx and args. + * @returns A new query builder using zod validation to define queries. + */ +export function zCustomQuery< + CustomArgsValidator extends PropertyValidators, + CustomCtx extends Record, + CustomMadeArgs extends Record, + Visibility extends FunctionVisibility, + DataModel extends GenericDataModel, + ExtraArgs extends Record = object, +>( + query: QueryBuilder, + customization: Customization< + GenericQueryCtx, + CustomArgsValidator, + CustomCtx, + CustomMadeArgs, + ExtraArgs + >, +) { + return customFnBuilder(query, customization) as CustomBuilder< + "query", + CustomArgsValidator, + CustomCtx, + CustomMadeArgs, + GenericQueryCtx, + Visibility, + ExtraArgs + >; +} + +/** + * zCustomMutation is like customMutation, but allows validation via zod. + * You can define custom behavior on top of `mutation` or `internalMutation` + * by passing a function that modifies the ctx and args. Or NoOp to do nothing. + * + * Example usage: + * ```js + * const myMutationBuilder = zCustomMutation(mutation, { + * args: { sessionId: v.id("sessions") }, + * input: async (ctx, args) => { + * const user = await getUserOrNull(ctx); + * const session = await db.get(sessionId); + * const db = wrapDatabaseReader({ user }, ctx.db, rlsRules); + * return { ctx: { db, user, session }, args: {} }; + * }, + * }); + * + * // Using the custom builder + * export const getSomeData = myMutationBuilder({ + * args: { someArg: z.string() }, + * handler: async (ctx, args) => { + * const { db, user, session, scheduler } = ctx; + * const { someArg } = args; + * // ... + * } + * }); + * ``` + * + * Simple usage only modifying ctx: + * ```js + * const myInternalMutation = zCustomMutation( + * internalMutation, + * customCtx(async (ctx) => { + * return { + * // Throws an exception if the user isn't logged in + * user: await getUserByTokenIdentifier(ctx), + * }; + * }) + * ); + * + * // Using it + * export const getUser = myInternalMutation({ + * args: { email: z.string().email() }, + * handler: async (ctx, args) => { + * console.log(args.email); + * return ctx.user; + * }, + * }); + * + * @param mutation The mutation to be modified. Usually `mutation` or `internalMutation` + * from `_generated/server`. + * @param customization The customization to be applied to the mutation, changing ctx and args. + * @returns A new mutation builder using zod validation to define queries. + */ +export function zCustomMutation< + CustomArgsValidator extends PropertyValidators, + CustomCtx extends Record, + CustomMadeArgs extends Record, + Visibility extends FunctionVisibility, + DataModel extends GenericDataModel, + ExtraArgs extends Record = object, +>( + mutation: MutationBuilder, + customization: Customization< + GenericMutationCtx, + CustomArgsValidator, + CustomCtx, + CustomMadeArgs, + ExtraArgs + >, +) { + return customFnBuilder(mutation, customization) as CustomBuilder< + "mutation", + CustomArgsValidator, + CustomCtx, + CustomMadeArgs, + GenericMutationCtx, + Visibility, + ExtraArgs + >; +} + +/** + * zCustomAction is like customAction, but allows validation via zod. + * You can define custom behavior on top of `action` or `internalAction` + * by passing a function that modifies the ctx and args. Or NoOp to do nothing. + * + * Example usage: + * ```js + * const myActionBuilder = zCustomAction(action, { + * args: { sessionId: v.id("sessions") }, + * input: async (ctx, args) => { + * const user = await getUserOrNull(ctx); + * const session = await db.get(sessionId); + * const db = wrapDatabaseReader({ user }, ctx.db, rlsRules); + * return { ctx: { db, user, session }, args: {} }; + * }, + * }); + * + * // Using the custom builder + * export const getSomeData = myActionBuilder({ + * args: { someArg: z.string() }, + * handler: async (ctx, args) => { + * const { db, user, session, scheduler } = ctx; + * const { someArg } = args; + * // ... + * } + * }); + * ``` + * + * Simple usage only modifying ctx: + * ```js + * const myInternalAction = zCustomAction( + * internalAction, + * customCtx(async (ctx) => { + * return { + * // Throws an exception if the user isn't logged in + * user: await getUserByTokenIdentifier(ctx), + * }; + * }) + * ); + * + * // Using it + * export const getUser = myInternalAction({ + * args: { email: z.string().email() }, + * handler: async (ctx, args) => { + * console.log(args.email); + * return ctx.user; + * }, + * }); + * + * @param action The action to be modified. Usually `action` or `internalAction` + * from `_generated/server`. + * @param customization The customization to be applied to the action, changing ctx and args. + * @returns A new action builder using zod validation to define queries. + */ +export function zCustomAction< + CustomArgsValidator extends PropertyValidators, + CustomCtx extends Record, + CustomMadeArgs extends Record, + Visibility extends FunctionVisibility, + DataModel extends GenericDataModel, + ExtraArgs extends Record = object, +>( + action: ActionBuilder, + customization: Customization< + GenericActionCtx, + CustomArgsValidator, + CustomCtx, + CustomMadeArgs, + ExtraArgs + >, +) { + return customFnBuilder(action, customization) as CustomBuilder< + "action", + CustomArgsValidator, + CustomCtx, + CustomMadeArgs, + GenericActionCtx, + Visibility, + ExtraArgs + >; +} diff --git a/packages/convex-helpers/server/zod4/codec.ts b/packages/convex-helpers/server/zod4/codec.ts new file mode 100644 index 00000000..305af2e1 --- /dev/null +++ b/packages/convex-helpers/server/zod4/codec.ts @@ -0,0 +1,271 @@ +import { + ZodArray, + ZodDefault, + ZodNullable, + ZodObject, + ZodOptional, + ZodRecord, + ZodUnion, +} from "zod"; +import * as z from "zod/v4/core"; + +import { isDateSchema } from "./helpers.js"; +import { zodToConvex } from "./zodToConvex.js"; + +// Registry for base type codecs +type BaseCodec = { + check: (schema: any) => boolean; + toValidator: (schema: any) => any; + fromConvex: (value: any, schema: any) => any; + toConvex: (value: any, schema: any) => any; +}; + +const baseCodecs: BaseCodec[] = []; + +export type ConvexCodec = { + validator: any; + encode: (value: T) => any; + decode: (value: any) => T; + pick: (keys: K[]) => ConvexCodec>; +}; + +export function registerBaseCodec(codec: BaseCodec): void { + baseCodecs.unshift(codec); // Add to front for priority +} + +export function findBaseCodec(schema: any): BaseCodec | undefined { + return baseCodecs.find((codec) => codec.check(schema)); +} + +// Helper to convert Zod's internal types to ZodTypeAny +function asZodType(schema: T): z.$ZodType { + return schema as unknown as z.$ZodType; +} + +export function convexCodec(schema: z.$ZodType): ConvexCodec { + const validator = zodToConvex(schema); + + return { + validator, + encode: (value: T) => toConvexJS(schema, value), + decode: (value: any) => fromConvexJS(value, schema), + pick: (keys: K[] | Record) => { + if (!(schema instanceof ZodObject)) { + throw new Error("pick() can only be called on object schemas"); + } + // Handle both array and object formats + const pickObj = Array.isArray(keys) + ? keys.reduce((acc, k) => ({ ...acc, [k]: true }), {} as any) + : keys; + const pickedSchema = schema.pick(pickObj as any); + return convexCodec(pickedSchema) as ConvexCodec>; + }, + }; +} + +function schemaToConvex(value: any, schema: any): any { + if (value === undefined || value === null) return value; + + // Check base codec registry first + const codec = findBaseCodec(schema); + if (codec) { + return codec.toConvex(value, schema); + } + + // Handle wrapper types + if ( + schema instanceof ZodOptional || + schema instanceof ZodNullable || + schema instanceof ZodDefault + ) { + // Use unwrap() method which is available on these types + const inner = schema.unwrap(); + return schemaToConvex(value, asZodType(inner)); + } + + // Handle Date specifically + if (schema instanceof z.$ZodDate && value instanceof Date) { + return value.getTime(); + } + + // Handle arrays + if (schema instanceof ZodArray) { + if (!Array.isArray(value)) return value; + return value.map((item) => schemaToConvex(item, schema.element)); + } + + // Handle objects + if (schema instanceof ZodObject) { + if (!value || typeof value !== "object") return value; + const shape = schema.shape; + const result: any = {}; + for (const [k, v] of Object.entries(value)) { + if (v !== undefined) { + result[k] = shape[k] ? schemaToConvex(v, shape[k]) : JSToConvex(v); + } + } + return result; + } + + // Handle unions + if (schema instanceof ZodUnion) { + // Try each option to see which one matches + for (const option of schema.options) { + try { + (option as any).parse(value); // Validate against this option + return schemaToConvex(value, option); + } catch { + // Try next option + } + } + } + + // Handle records + if (schema instanceof ZodRecord) { + if (!value || typeof value !== "object") return value; + const result: any = {}; + for (const [k, v] of Object.entries(value)) { + if (v !== undefined) { + result[k] = schemaToConvex(v, schema.valueType); + } + } + return result; + } + + // Default passthrough + return JSToConvex(value); +} + +// Convert JS values to Convex-safe JSON (handle Dates, remove undefined) +export function toConvexJS(schema?: any, value?: any): any { + // If no schema provided, do basic conversion + if (!schema || arguments.length === 1) { + value = schema; + return JSToConvex(value); + } + + // Use schema-aware conversion + return schemaToConvex(value, schema); +} + +export function JSToConvex(value: any): any { + if (value === undefined) return undefined; + if (value === null) return null; + if (value instanceof Date) return value.getTime(); + + if (Array.isArray(value)) { + return value.map(JSToConvex); + } + + if (value && typeof value === "object") { + const result: any = {}; + for (const [k, v] of Object.entries(value)) { + if (v !== undefined) { + result[k] = JSToConvex(v); + } + } + return result; + } + + return value; +} + +// Convert Convex JSON back to JS values (handle timestamps -> Dates) +export function fromConvexJS(value: any, schema: any): any { + if (value === undefined || value === null) return value; + + // Check base codec registry first + const codec = findBaseCodec(schema); + if (codec) { + return codec.fromConvex(value, schema); + } + + // Handle wrapper types + if ( + schema instanceof ZodOptional || + schema instanceof ZodNullable || + schema instanceof ZodDefault + ) { + // Use unwrap() method which is available on these types + const inner = schema.unwrap(); + return fromConvexJS(value, asZodType(inner)); + } + + // Handle Date specifically + if (schema instanceof z.$ZodDate && typeof value === "number") { + return new Date(value); + } + + // Check if schema is a Date through effects/transforms + if (isDateSchema(schema) && typeof value === "number") { + return new Date(value); + } + + // Handle arrays + if (schema instanceof ZodArray) { + if (!Array.isArray(value)) return value; + return value.map((item) => fromConvexJS(item, schema.element)); + } + + // Handle objects + if (schema instanceof ZodObject) { + if (!value || typeof value !== "object") return value; + const shape = schema.shape; + const result: any = {}; + for (const [k, v] of Object.entries(value)) { + result[k] = shape[k] ? fromConvexJS(v, shape[k]) : v; + } + return result; + } + + // Handle unions + if (schema instanceof ZodUnion) { + // Try to decode with each option + for (const option of schema.options) { + try { + const decoded = fromConvexJS(value, option); + (option as any).parse(decoded); // Validate the decoded value + return decoded; + } catch { + // Try next option + } + } + } + + // Handle records + if (schema instanceof ZodRecord) { + if (!value || typeof value !== "object") return value; + const result: any = {}; + for (const [k, v] of Object.entries(value)) { + result[k] = fromConvexJS(v, schema.valueType); + } + return result; + } + + // Handle effects and transforms + // Note: ZodPipe doesn't exist in Zod v4, only ZodTransform + if (schema instanceof z.$ZodTransform) { + // Cannot access inner schema without _def, return value as-is + return value; + } + + return value; +} + +// Built-in codec for Date +// registerBaseCodec({ +// check: (schema) => schema instanceof z.$ZodDate, +// toValidator: () => v.float64(), +// fromConvex: (value) => { +// if (typeof value === "number") { +// return new Date(value); +// } +// return value; +// }, +// toConvex: (value) => { +// if (value instanceof Date) { +// return value.getTime(); +// } +// return value; +// }, +// }); diff --git a/packages/convex-helpers/server/zod4/convexToZod.ts b/packages/convex-helpers/server/zod4/convexToZod.ts new file mode 100644 index 00000000..6a5b0752 --- /dev/null +++ b/packages/convex-helpers/server/zod4/convexToZod.ts @@ -0,0 +1,183 @@ +import type { + GenericValidator, + PropertyValidators, + VId, + VLiteral, + VObject, + VUnion, +} from "convex/values"; +import type { GenericDataModel } from "convex/server"; +import type { + GenericId, + Validator, + VArray, + VBoolean, + VFloat64, + VInt64, + VNull, + VRecord, + VString, +} from "convex/values"; + +import z from "zod"; + +import { zid, type Zid } from "./id.js"; + +type GetValidatorT = + V extends Validator ? T : never; + +export type ZodFromValidatorBase = + GetValidatorT extends GenericId + ? Zid + : V extends VString + ? T extends string & { _: infer Brand extends string } + ? z.core.$ZodBranded + : z.ZodString + : V extends VFloat64 + ? z.ZodNumber + : V extends VInt64 + ? z.ZodBigInt + : V extends VBoolean + ? z.ZodBoolean + : V extends VNull + ? z.ZodNull + : V extends VLiteral< + infer T extends string | number | boolean | bigint | null, + any + > + ? z.ZodLiteral + : V extends VObject + ? // @ts-ignore TS2589 + z.ZodObject< + { + [K in keyof Fields]: ZodValidatorFromConvex; + }, + z.core.$strip + > + : V extends VRecord + ? Key extends VId> + ? z.ZodRecord< + z.core.$ZodRecordKey extends Zid + ? Zid + : z.ZodString, + ZodValidatorFromConvex + > + : z.ZodRecord< + z.core.$ZodString<"string">, + ZodValidatorFromConvex + > + : V extends VUnion< + any, + [ + infer A extends GenericValidator, + infer B extends GenericValidator, + ...infer Rest extends GenericValidator[], + ], + any, + any + > + ? V extends VArray + ? z.ZodArray> + : z.ZodUnion< + [ + ZodValidatorFromConvex, + ZodValidatorFromConvex, + ...{ + [K in keyof Rest]: ZodValidatorFromConvex< + Rest[K] + >; + }, + ] + > + : z.ZodType; + +export type ZodValidatorFromConvex = + V extends Validator + ? z.ZodOptional> + : ZodFromValidatorBase; + +/** + * Converts Convex validators back to Zod schemas + */ +export function convexToZod( + convexValidator: V, +): ZodValidatorFromConvex { + const isOptional = (convexValidator as any).isOptional === "optional"; + + let zodValidator; + + switch (convexValidator.kind) { + case "id": + zodValidator = zid((convexValidator as VId).tableName); + break; + case "string": + zodValidator = z.string(); + break; + case "float64": + zodValidator = z.number(); + break; + case "int64": + zodValidator = z.bigint(); + break; + case "boolean": + zodValidator = z.boolean(); + break; + case "null": + zodValidator = z.null(); + break; + case "any": + zodValidator = z.any(); + break; + case "array": { + // + // @ts-ignore TS2589 + zodValidator = z.array(convexToZod((convexValidator as any).element)); + break; + } + case "object": { + const objectValidator = convexValidator as VObject; + zodValidator = z.object(convexToZodFields(objectValidator.fields)); + break; + } + case "union": { + const unionValidator = convexValidator as VUnion; + const memberValidators = unionValidator.members.map( + (member: GenericValidator) => convexToZod(member), + ); + zodValidator = z.union([ + memberValidators[0], + memberValidators[1], + ...memberValidators.slice(2), + ]); + break; + } + case "literal": { + const literalValidator = convexValidator as VLiteral; + zodValidator = z.literal(literalValidator.value); + break; + } + case "record": { + zodValidator = z.record( + z.string(), + convexToZod((convexValidator as any).value), + ); + break; + } + default: + throw new Error(`Unknown convex validator type: ${convexValidator.kind}`); + } + + const data = isOptional + ? (z.optional(zodValidator) as ZodValidatorFromConvex) + : (zodValidator as ZodValidatorFromConvex); + + return data; +} + +export function convexToZodFields( + convexValidators: C, +) { + return Object.fromEntries( + Object.entries(convexValidators).map(([k, v]) => [k, convexToZod(v)]), + ) as { [k in keyof C]: ZodValidatorFromConvex }; +} diff --git a/packages/convex-helpers/server/zod4/helpers.ts b/packages/convex-helpers/server/zod4/helpers.ts new file mode 100644 index 00000000..5f62d02f --- /dev/null +++ b/packages/convex-helpers/server/zod4/helpers.ts @@ -0,0 +1,46 @@ +import type { ZodType } from "zod"; + +import { ZodNullable, ZodOptional, z as zValidate } from "zod"; +import * as z from "zod/v4/core"; + +import { zid } from "./id.js"; + +export const withSystemFields = < + Table extends string, + T extends { [key: string]: z.$ZodType }, +>( + tableName: Table, + zObject: T, +) => { + return { + ...zObject, + _id: zid(tableName), + _creationTime: zValidate.number(), + } as const; +}; + +export function zBrand( + validator: T, + brand?: B, +) { + return validator.brand(brand); +} + +// Helper to convert Zod's internal types to ZodTypeAny +function asZodType(schema: T): z.$ZodType { + return schema as unknown as z.$ZodType; +} + +// Helper to check if a schema is a Date type through the registry +export function isDateSchema(schema: any): boolean { + if (schema instanceof z.$ZodDate) return true; + + // Check through optional/nullable (these have public unwrap()) + if (schema instanceof ZodOptional || schema instanceof ZodNullable) { + return isDateSchema(asZodType(schema.unwrap())); + } + + // Cannot check transforms/pipes without _def access + // This is a limitation of using only public APIs + return false; +} diff --git a/packages/convex-helpers/server/zod4/id.ts b/packages/convex-helpers/server/zod4/id.ts new file mode 100644 index 00000000..6e0cd98b --- /dev/null +++ b/packages/convex-helpers/server/zod4/id.ts @@ -0,0 +1,87 @@ +import type { GenericId } from "convex/values"; + +import type { GenericDataModel, TableNamesInDataModel } from "convex/server"; +import { z as zValidate } from "zod"; +import * as z from "zod/v4/core"; + +// Simple registry for metadata +const metadata = new WeakMap(); + +export const registryHelpers = { + getMetadata: (type: z.$ZodType) => metadata.get(type), + setMetadata: (type: z.$ZodType, meta: any) => metadata.set(type, meta), +}; + +/** + * Create a Zod validator for a Convex Id + * + * Uses the string → transform → brand pattern for proper type narrowing with ctx.db.get() + * This aligns with Zod v4 best practices and matches convex-helpers implementation + */ +// export function zid< +// DataModel extends GenericDataModel, +// TableName extends TableNamesInDataModel, +// >(tableName: TableName) { +// const base = zValidate +// .string() +// .refine((s) => typeof s === 'string' && s.length > 0, { +// message: `Invalid ID for table "${tableName}"`, +// }) +// .transform((s) => s as GenericId) +// .brand(`ConvexId_${tableName}`) +// .describe(`convexId:${tableName}`); + +// registryHelpers.setMetadata(base, { isConvexId: true, tableName }); +// return base as z.$ZodType>; +// } + +export function zid< + DataModel extends GenericDataModel, + TableName extends + TableNamesInDataModel = TableNamesInDataModel, +>( + tableName: TableName, +): zValidate.ZodType> & { _tableName: TableName } { + // Use the string → transform → brand pattern (aligned with Zod v4 best practices) + const baseSchema = zValidate + .string() + .refine((val) => typeof val === "string" && val.length > 0, { + message: `Invalid ID for table "${tableName}"`, + }) + .transform((val) => { + // Cast to GenericId while keeping the string value + return val as string & GenericId; + }) + .brand(`ConvexId_${tableName}`) // Use native Zod v4 .brand() method + // Add a human-readable marker for client-side introspection utilities + // used in apps/native (e.g., to detect relationship fields in dynamic forms). + .describe(`convexId:${tableName}`); + + // Store metadata for registry lookup so mapping can convert to v.id(tableName) + registryHelpers.setMetadata(baseSchema, { + isConvexId: true, + tableName, + }); + + // Add the tableName property for type-level detection + const branded = baseSchema as any; + branded._tableName = tableName; + + return branded as zValidate.ZodType> & { + _tableName: TableName; + }; +} + +export function isZid(schema: z.$ZodType): schema is Zid { + // Check our metadata registry for ConvexId marker + const metadata = registryHelpers.getMetadata(schema); + return ( + metadata?.isConvexId === true && + metadata?.tableName && + typeof metadata.tableName === "string" + ); +} + +export type Zid = ReturnType< + typeof zid +>; diff --git a/packages/convex-helpers/server/zod4/types.ts b/packages/convex-helpers/server/zod4/types.ts new file mode 100644 index 00000000..cd67adb9 --- /dev/null +++ b/packages/convex-helpers/server/zod4/types.ts @@ -0,0 +1,11 @@ +import type { DefaultFunctionArgs } from "convex/server"; +import * as z from "zod/v4/core"; + +export type Overwrite = keyof U extends never ? T : Omit & U; +export type Expand> = { [K in keyof T]: T[K] }; + +export type OneArgArray< + ArgsObject extends DefaultFunctionArgs = DefaultFunctionArgs, +> = [ArgsObject]; + +export type ZodValidator = Record; diff --git a/packages/convex-helpers/server/zod4/zodToConvex.ts b/packages/convex-helpers/server/zod4/zodToConvex.ts new file mode 100644 index 00000000..3be0bb9b --- /dev/null +++ b/packages/convex-helpers/server/zod4/zodToConvex.ts @@ -0,0 +1,909 @@ +import type { + GenericValidator, + PropertyValidators, + Validator, +} from "convex/values"; +import type { ZodValidator } from "./types.js"; + +import { v } from "convex/values"; +import { + ZodDefault, + ZodNullable, + ZodObject, + ZodOptional, + type ZodRawShape, +} from "zod"; +import * as z from "zod/v4/core"; + +import { isZid, registryHelpers } from "./id.js"; +import { findBaseCodec } from "./codec.js"; + +import type { + GenericId, + VAny, + VArray, + VBoolean, + VFloat64, + VId, + VInt64, + VLiteral, + VNull, + VObject, + VOptional, + VRecord, + VString, + VUnion, +} from "convex/values"; + +type IsZid = T extends { _tableName: infer _TableName extends string } + ? true + : false; + +type ExtractTableName = T extends { _tableName: infer TableName } + ? TableName + : never; + +type EnumToLiteralsTuple = + T["length"] extends 1 + ? [VLiteral] + : T["length"] extends 2 + ? [VLiteral, VLiteral] + : [ + VLiteral, + VLiteral, + ...{ + [K in keyof T]: K extends "0" | "1" + ? never + : K extends keyof T + ? VLiteral + : never; + }[keyof T & number][], + ]; + +// Auto-detect optional fields and apply appropriate constraints +export type ConvexValidatorFromZodFieldsAuto = + { + [K in keyof T]: T[K] extends z.$ZodOptional + ? ConvexValidatorFromZod + : T[K] extends z.$ZodDefault + ? ConvexValidatorFromZod + : T[K] extends z.$ZodNullable + ? ConvexValidatorFromZod + : T[K] extends z.$ZodEnum + ? ConvexValidatorFromZod + : T[K] extends z.$ZodType + ? ConvexValidatorFromZod + : VAny<"required">; + }; + +// Base type mapper that never produces VOptional +type ConvexValidatorFromZodBase = + IsZid extends true + ? ExtractTableName extends infer TableName extends string + ? VId, "required"> + : VAny<"required"> + : Z extends z.$ZodString + ? VString, "required"> + : Z extends z.$ZodNumber + ? VFloat64, "required"> + : Z extends z.$ZodDate + ? VFloat64 + : Z extends z.$ZodBigInt + ? VInt64, "required"> + : Z extends z.$ZodBoolean + ? VBoolean, "required"> + : Z extends z.$ZodNull + ? VNull + : Z extends z.$ZodArray + ? VArray< + z.infer, + ConvexValidatorFromZodRequired, + "required" + > + : Z extends z.$ZodObject + ? VObject< + z.infer, + ConvexValidatorFromZodFieldsAuto, + "required", + string + > + : Z extends z.$ZodUnion + ? T extends readonly [ + z.$ZodType, + z.$ZodType, + ...z.$ZodType[], + ] + ? VUnion, any[], "required"> + : never + : Z extends z.$ZodLiteral + ? VLiteral + : Z extends z.$ZodEnum + ? T extends readonly [string, ...string[]] + ? T["length"] extends 1 + ? VLiteral + : T["length"] extends 2 + ? VUnion< + T[number], + [ + VLiteral, + VLiteral, + ], + "required", + never + > + : VUnion< + T[number], + EnumToLiteralsTuple, + "required", + never + > + : T extends Record + ? VUnion< + T[keyof T], + Array>, + "required", + never + > + : VUnion + : Z extends z.$ZodRecord< + z.$ZodString, + infer V extends z.$ZodType + > + ? VRecord< + Record>, + VString, + ConvexValidatorFromZodRequired, + "required", + string + > + : Z extends z.$ZodNullable< + infer Inner extends z.$ZodType + > + ? Inner extends z.$ZodOptional< + infer InnerInner extends z.$ZodType + > + ? VOptional< + VUnion< + z.infer | null, + [ + ConvexValidatorFromZodBase, + VNull, + ], + "required" + > + > + : VUnion< + z.infer | null, + [ + ConvexValidatorFromZodBase, + VNull, + ], + "required" + > + : Z extends z.$ZodAny + ? VAny<"required"> + : Z extends z.$ZodUnknown + ? VAny<"required"> + : VAny<"required">; + +type ConvexValidatorFromZodRequired = + Z extends z.$ZodOptional + ? VUnion | null, any[], "required"> + : ConvexValidatorFromZodBase; + +type ConvexValidatorFromZodFields< + T extends { [key: string]: any }, + Constraint extends "required" | "optional" = "required", +> = { + [K in keyof T]: T[K] extends z.$ZodType + ? ConvexValidatorFromZod + : VAny<"required">; +}; + +// Main type mapper with constraint system +export type ConvexValidatorFromZod< + Z extends z.$ZodType, + Constraint extends "required" | "optional" = "required", +> = Z extends z.$ZodAny + ? VAny<"required"> + : Z extends z.$ZodUnknown + ? VAny<"required"> + : Z extends z.$ZodDefault + ? ConvexValidatorFromZod + : Z extends z.$ZodOptional + ? T extends z.$ZodNullable + ? VOptional | null, any[], "required">> + : Constraint extends "required" + ? VUnion, any[], "required"> + : VOptional> + : Z extends z.$ZodNullable + ? VUnion | null, any[], Constraint> + : IsZid extends true + ? ExtractTableName extends infer TableName extends string + ? VId, Constraint> + : VAny<"required"> + : Z extends z.$ZodString + ? VString, Constraint> + : Z extends z.$ZodNumber + ? VFloat64, Constraint> + : Z extends z.$ZodDate + ? VFloat64 + : Z extends z.$ZodBigInt + ? VInt64, Constraint> + : Z extends z.$ZodBoolean + ? VBoolean, Constraint> + : Z extends z.$ZodNull + ? VNull + : Z extends z.$ZodArray + ? VArray< + z.infer, + ConvexValidatorFromZodRequired, + Constraint + > + : Z extends z.$ZodObject + ? VObject< + z.infer, + ConvexValidatorFromZodFields, + Constraint, + string + > + : Z extends z.$ZodUnion + ? T extends readonly [ + z.$ZodType, + z.$ZodType, + ...z.$ZodType[], + ] + ? VUnion, any[], Constraint> + : never + : Z extends z.$ZodLiteral + ? VLiteral + : Z extends z.$ZodEnum + ? T extends readonly [string, ...string[]] + ? T["length"] extends 1 + ? VLiteral + : T["length"] extends 2 + ? VUnion< + T[number], + [ + VLiteral, + VLiteral, + ], + Constraint, + never + > + : VUnion< + T[number], + EnumToLiteralsTuple, + Constraint, + never + > + : T extends Record + ? VUnion< + T[keyof T], + Array< + VLiteral + >, + Constraint, + never + > + : VUnion + : Z extends z.$ZodRecord< + z.$ZodString, + infer V extends z.$ZodType + > + ? VRecord< + Record>, + VString, + ConvexValidatorFromZodRequired, + Constraint, + string + > + : VAny<"required">; + +function convertEnumType(actualValidator: z.$ZodEnum): GenericValidator { + const options = (actualValidator as any).options; + if (options && Array.isArray(options) && options.length > 0) { + // Filter out undefined/null and convert to Convex validators + const validLiterals = options + .filter((opt: any) => opt !== undefined && opt !== null) + .map((opt: any) => v.literal(opt)); + + if (validLiterals.length === 1) { + const [first] = validLiterals; + return first as Validator; + } else if (validLiterals.length >= 2) { + const [first, second, ...rest] = validLiterals; + return v.union( + first as Validator, + second as Validator, + ...rest, + ); + } else { + return v.any(); + } + } else { + return v.any(); + } +} + +function convertRecordType( + actualValidator: z.$ZodRecord, + visited: Set, + zodToConvexInternal: (schema: z.$ZodType, visited: Set) => any, +): GenericValidator { + // In Zod v4, when z.record(z.string()) is used with one argument, + // the argument becomes the value type and key defaults to string. + // The valueType is stored in _def.valueType (or undefined if single arg) + let valueType = (actualValidator as any)._def?.valueType; + + // If valueType is undefined, it means single argument form was used + // where the argument is actually the value type (stored in keyType) + if (!valueType) { + // Workaround: Zod v4 stores the value type in _def.keyType for single-argument z.record(). + // This accesses a private property as there is no public API for this in Zod v4. + valueType = (actualValidator as any)._def?.keyType; + } + + if (valueType && valueType instanceof z.$ZodType) { + // First check if the Zod value type is optional before conversion + const isZodOptional = + valueType instanceof z.$ZodOptional || + valueType instanceof z.$ZodDefault || + (valueType instanceof z.$ZodDefault && + valueType._zod.def.innerType instanceof z.$ZodOptional); + + if (isZodOptional) { + // For optional record values, we need to handle this specially + let innerType: z.$ZodType; + let recordDefaultValue: any = undefined; + let recordHasDefault = false; + + if (valueType instanceof z.$ZodDefault) { + // Handle ZodDefault wrapper + recordHasDefault = true; + recordDefaultValue = valueType._zod.def.defaultValue; + const innerFromDefault = valueType._zod.def.innerType; + if (innerFromDefault instanceof ZodOptional) { + innerType = innerFromDefault.unwrap() as z.$ZodType; + } else { + innerType = innerFromDefault as z.$ZodType; + } + } else if (valueType instanceof ZodOptional) { + // Direct ZodOptional + innerType = valueType.unwrap() as z.$ZodType; + } else { + // Shouldn't happen based on isZodOptional check + innerType = valueType as z.$ZodType; + } + + // Convert the inner type to Convex and wrap in union with null + const innerConvex = zodToConvexInternal(innerType, visited); + const unionValidator = v.union(innerConvex, v.null()); + + // Add default metadata if present + if (recordHasDefault) { + (unionValidator as any)._zodDefault = recordDefaultValue; + } + + return v.record(v.string(), unionValidator); + } else { + // Non-optional values can be converted normally + return v.record(v.string(), zodToConvexInternal(valueType, visited)); + } + } else { + return v.record(v.string(), v.any()); + } +} + +export function convertNullableType( + actualValidator: ZodNullable, + visited: Set, + zodToConvexInternal: (schema: z.$ZodType, visited: Set) => any, +): { validator: GenericValidator; isOptional: boolean } { + const innerSchema = actualValidator.unwrap(); + if (innerSchema && innerSchema instanceof z.$ZodType) { + // Check if the inner schema is optional + if (innerSchema instanceof ZodOptional) { + // For nullable(optional(T)), we want optional(union(T, null)) + const innerInnerSchema = innerSchema.unwrap(); + const innerInnerValidator = zodToConvexInternal( + innerInnerSchema as z.$ZodType, + visited, + ); + return { + validator: v.union(innerInnerValidator, v.null()), + isOptional: true, // Mark as optional so it gets wrapped later + }; + } else { + const innerValidator = zodToConvexInternal(innerSchema, visited); + return { + validator: v.union(innerValidator, v.null()), + isOptional: false, + }; + } + } else { + return { + validator: v.any(), + isOptional: false, + }; + } +} + +function convertDiscriminatedUnionType( + actualValidator: z.$ZodDiscriminatedUnion, + visited: Set, + zodToConvexInternal: (schema: z.$ZodType, visited: Set) => any, +): GenericValidator { + const options = + (actualValidator as any).def?.options || + (actualValidator as any).def?.optionsMap?.values(); + if (options) { + const opts = Array.isArray(options) ? options : Array.from(options); + if (opts.length >= 2) { + const convexOptions = opts.map((opt: any) => + zodToConvexInternal(opt, visited), + ) as Validator[]; + const [first, second, ...rest] = convexOptions; + return v.union( + first as Validator, + second as Validator, + ...rest, + ); + } else { + return v.any(); + } + } else { + return v.any(); + } +} + +function convertUnionType( + actualValidator: z.$ZodUnion, + visited: Set, + zodToConvexInternal: (schema: z.$ZodType, visited: Set) => any, +): GenericValidator { + const options = (actualValidator as any).options; + if (options && Array.isArray(options) && options.length > 0) { + if (options.length === 1) { + return zodToConvexInternal(options[0], visited); + } else { + // Convert each option recursively + const convexOptions = options.map((opt: any) => + zodToConvexInternal(opt, visited), + ) as Validator[]; + if (convexOptions.length >= 2) { + const [first, second, ...rest] = convexOptions; + return v.union( + first as Validator, + second as Validator, + ...rest, + ); + } else { + return v.any(); + } + } + } else { + return v.any(); + } +} + +function asValidator(x: unknown): any { + return x as unknown as any; +} + +function zodToConvexInternal( + zodValidator: Z, + visited: Set = new Set(), +): ConvexValidatorFromZod { + // Guard against undefined/null validators (can happen with { field: undefined } in args) + if (!zodValidator) { + return v.any() as ConvexValidatorFromZod; + } + + // Detect circular references to prevent infinite recursion + if (visited.has(zodValidator)) { + return v.any() as ConvexValidatorFromZod; + } + visited.add(zodValidator); + + // Check for default and optional wrappers + let actualValidator = zodValidator; + let isOptional = false; + let defaultValue: any = undefined; + let hasDefault = false; + + // Handle ZodDefault (which wraps ZodOptional when using .optional().default()) + // Note: We access _def properties directly because Zod v4 doesn't expose public APIs + // for unwrapping defaults. The removeDefault() method exists but returns a new schema + // without preserving references, which breaks our visited Set tracking. + if (zodValidator instanceof ZodDefault) { + hasDefault = true; + defaultValue = (zodValidator as any).def?.defaultValue; + actualValidator = (zodValidator as any).def?.innerType as Z; + } + + // Check for optional (may be wrapped inside ZodDefault) + if (actualValidator instanceof ZodOptional) { + isOptional = true; + actualValidator = actualValidator.unwrap() as Z; + + // If the unwrapped type is ZodDefault, handle it here + if (actualValidator instanceof ZodDefault) { + hasDefault = true; + defaultValue = (actualValidator as any).def?.defaultValue; + actualValidator = (actualValidator as any).def?.innerType as Z; + } + } + + let convexValidator: GenericValidator; + + // Check for Zid first (special case) + if (isZid(actualValidator)) { + const metadata = registryHelpers.getMetadata(actualValidator); + const tableName = metadata?.tableName || "unknown"; + convexValidator = v.id(tableName); + } else { + // Use def.type for robust, performant type detection instead of instanceof checks. + // Rationale: + // 1. Performance: Single switch statement vs. cascading instanceof checks + // 2. Completeness: def.type covers ALL Zod variants including formats (email, url, uuid, etc.) + // 3. Future-proof: Zod's internal structure is stable; instanceof checks can miss custom types + // 4. Precision: def.type distinguishes between semantically different types (date vs number) + // This private API access is intentional and necessary for comprehensive type coverage. + // + // Compatibility: This code relies on the internal `.def.type` property of ZodType. + // This structure has been stable across Zod v3.x and v4.x. If upgrading Zod major versions, + // verify that `.def.type` is still present and unchanged. + const defType = (actualValidator as any).def?.type; + + switch (defType) { + case "string": + // This catches ZodString and ALL string format types (email, url, uuid, etc.) + convexValidator = v.string(); + break; + case "number": + convexValidator = v.float64(); + break; + case "bigint": + convexValidator = v.int64(); + break; + case "boolean": + convexValidator = v.boolean(); + break; + case "date": + convexValidator = v.float64(); // Dates are stored as timestamps in Convex + break; + case "null": + convexValidator = v.null(); + break; + case "nan": + convexValidator = v.float64(); + break; + case "array": { + // Use classic API: ZodArray has .element property + if (actualValidator instanceof z.$ZodArray) { + const element = (actualValidator as any).element; + if (element && element instanceof z.$ZodType) { + convexValidator = v.array(zodToConvexInternal(element, visited)); + } else { + convexValidator = v.array(v.any()); + } + } else { + convexValidator = v.array(v.any()); + } + break; + } + case "object": { + // Use classic API: ZodObject has .shape property + if (actualValidator instanceof ZodObject) { + const shape = actualValidator.shape; + const convexShape: PropertyValidators = {}; + for (const [key, value] of Object.entries(shape)) { + if (value && value instanceof z.$ZodType) { + convexShape[key] = zodToConvexInternal(value, visited); + } + } + convexValidator = v.object(convexShape); + } else { + convexValidator = v.object({}); + } + break; + } + case "union": { + if (actualValidator instanceof z.$ZodUnion) { + convexValidator = convertUnionType( + actualValidator, + visited, + zodToConvexInternal, + ); + } else { + convexValidator = v.any(); + } + break; + } + case "discriminatedUnion": { + convexValidator = convertDiscriminatedUnionType( + actualValidator as any, + visited, + zodToConvexInternal, + ); + break; + } + case "literal": { + // Use classic API: ZodLiteral has .value property + if (actualValidator instanceof z.$ZodLiteral) { + const literalValue = (actualValidator as any).value; + if (literalValue !== undefined && literalValue !== null) { + convexValidator = v.literal(literalValue); + } else { + convexValidator = v.any(); + } + } else { + convexValidator = v.any(); + } + break; + } + case "enum": { + if (actualValidator instanceof z.$ZodEnum) { + convexValidator = convertEnumType(actualValidator); + } else { + convexValidator = v.any(); + } + break; + } + case "record": { + if (actualValidator instanceof z.$ZodRecord) { + convexValidator = convertRecordType( + actualValidator, + visited, + zodToConvexInternal, + ); + } else { + convexValidator = v.record(v.string(), v.any()); + } + break; + } + case "transform": + case "pipe": { + // Check for registered codec first + const codec = findBaseCodec(actualValidator); + if (codec) { + convexValidator = codec.toValidator(actualValidator); + } else { + // Check for brand metadata + const metadata = registryHelpers.getMetadata(actualValidator); + if (metadata?.brand && metadata?.originalSchema) { + // For branded types created by our zBrand function, use the original schema + convexValidator = zodToConvexInternal( + metadata.originalSchema, + visited, + ); + } else { + // For non-registered transforms, return v.any() + convexValidator = v.any(); + } + } + break; + } + case "nullable": { + if (actualValidator instanceof ZodNullable) { + const result = convertNullableType( + actualValidator, + visited, + zodToConvexInternal, + ); + convexValidator = result.validator; + if (result.isOptional) { + isOptional = true; + } + } else { + convexValidator = v.any(); + } + break; + } + case "tuple": { + // Handle tuple types as objects with numeric keys + if (actualValidator instanceof z.$ZodTuple) { + const items = (actualValidator as any).def?.items as + | z.$ZodType[] + | undefined; + if (items && items.length > 0) { + const convexShape: PropertyValidators = {}; + items.forEach((item, index) => { + convexShape[`_${index}`] = zodToConvexInternal(item, visited); + }); + convexValidator = v.object(convexShape); + } else { + convexValidator = v.object({}); + } + } else { + convexValidator = v.object({}); + } + break; + } + case "lazy": { + // Handle lazy schemas by resolving them + // Circular references are protected by the visited set check at function start + if (actualValidator instanceof z.$ZodLazy) { + try { + const getter = (actualValidator as any).def?.getter; + if (getter) { + const resolvedSchema = getter(); + if (resolvedSchema && resolvedSchema instanceof z.$ZodType) { + convexValidator = zodToConvexInternal(resolvedSchema, visited); + } else { + convexValidator = v.any(); + } + } else { + convexValidator = v.any(); + } + } catch { + // If resolution fails, fall back to 'any' + convexValidator = v.any(); + } + } else { + convexValidator = v.any(); + } + break; + } + case "any": + // Handle z.any() directly + convexValidator = v.any(); + break; + case "unknown": + // Handle z.unknown() as any + convexValidator = v.any(); + break; + case "undefined": + case "void": + case "never": + // These types don't have good Convex equivalents + convexValidator = v.any(); + break; + case "intersection": + // Can't properly handle intersections + convexValidator = v.any(); + break; + default: + // For any unrecognized def.type, return v.any() + // No instanceof fallbacks - keep it simple and performant + if (process.env.NODE_ENV !== "production") { + console.warn( + `[zodvex] Unrecognized Zod type "${defType}" encountered. Falling back to v.any().`, + "Schema:", + actualValidator, + ); + } + convexValidator = v.any(); + break; + } + } + + // For optional or default fields, always use v.optional() + const finalValidator = + isOptional || hasDefault ? v.optional(convexValidator) : convexValidator; + + // Add metadata if there's a default value + if ( + hasDefault && + typeof finalValidator === "object" && + finalValidator !== null + ) { + (finalValidator as any)._zodDefault = defaultValue; + } + + return finalValidator as ConvexValidatorFromZod; +} + +function zodOutputToConvexInternal( + zodValidator: z.$ZodType, + visited: Set = new Set(), +): GenericValidator { + if (!zodValidator) return v.any(); + if (visited.has(zodValidator)) return v.any(); + visited.add(zodValidator); + + if (zodValidator instanceof ZodDefault) { + const inner = zodValidator.unwrap() as unknown as z.$ZodDefault; + return zodOutputToConvexInternal(inner, visited); + } + + if (zodValidator instanceof z.$ZodTransform) { + return v.any(); + } + + if (zodValidator instanceof z.$ZodReadonly) { + return zodOutputToConvexInternal( + (zodValidator as any).innerType as unknown as z.$ZodType, + visited, + ); + } + + if (zodValidator instanceof ZodOptional) { + const inner = zodValidator.unwrap() as unknown as z.$ZodType; + return v.optional(asValidator(zodOutputToConvexInternal(inner, visited))); + } + + if (zodValidator instanceof ZodNullable) { + const inner = zodValidator.unwrap() as unknown as z.$ZodType; + return v.union( + asValidator(zodOutputToConvexInternal(inner, visited)), + v.null(), + ); + } + + return zodToConvexInternal(zodValidator, visited); +} + +/** + * Convert Zod schema/object to Convex validator + */ +export function zodToConvex( + zod: Z, +): Z extends z.$ZodType + ? ConvexValidatorFromZod + : Z extends ZodValidator + ? ConvexValidatorFromZodFieldsAuto + : never { + if (typeof zod === "object" && zod !== null && !(zod instanceof z.$ZodType)) { + return zodToConvexFields(zod as ZodValidator) as any; + } + + return zodToConvexInternal(zod as z.$ZodType) as any; +} + +/** + * Like zodToConvex, but it takes in a bare object, as expected by Convex + * function arguments, or the argument to defineTable. + * + * @param zod Object with string keys and Zod validators as values + * @returns Object with the same keys, but with Convex validators as values + */ +export function zodToConvexFields( + zod: Z, +): ConvexValidatorFromZodFieldsAuto { + // If it's a ZodObject, extract the shape + const fields = zod instanceof ZodObject ? zod.shape : zod; + + // Build the result object directly to preserve types + const result: any = {}; + for (const [key, value] of Object.entries(fields)) { + result[key] = zodToConvexInternal(value as z.$ZodType); + } + + return result as ConvexValidatorFromZodFieldsAuto; +} + +/** + * Like zodToConvex, but it takes in a bare object, as expected by Convex + * function arguments, or the argument to defineTable. + * + * @param zod Object with string keys and Zod validators as values + * @returns Object with the same keys, but with Convex validators as values + */ +export function zodOutputToConvex(zodSchema: Z) { + if (zodSchema instanceof z.$ZodType) { + return zodOutputToConvexInternal(zodSchema); + } + const out: Record = {}; + for (const [k, v_] of Object.entries(zodSchema)) { + out[k] = zodOutputToConvexInternal(v_); + } + return out; +} + +/** + * Like zodOutputToConvex, but it takes in a bare object, as expected by Convex + * function arguments, or the argument to defineTable. + * This is different from zodToConvexFields because it generates the Convex + * validator for the output of the zod validator, not the input. + * + * @param zod Object with string keys and Zod validators as values + * @returns Object with the same keys, but with Convex validators as values + */ +export function zodOutputToConvexFields(zodShape: Z) { + const out: Record = {}; + for (const [k, v_] of Object.entries(zodShape)) + out[k] = zodOutputToConvexInternal(v_); + return out as { [k in keyof Z]: GenericValidator }; +}