From 14d2d40194cb2679b6dcf278c7acff82d13bd69b Mon Sep 17 00:00:00 2001 From: Karan Singhal Date: Tue, 14 Oct 2025 18:26:34 -0400 Subject: [PATCH 1/3] initial effort --- packages/convex-helpers/README.md | 252 ++++++++ packages/convex-helpers/package.json | 6 +- packages/convex-helpers/server/zod4.test.ts | 15 + packages/convex-helpers/server/zod4.ts | 642 ++++++++++++++++++++ 4 files changed, 914 insertions(+), 1 deletion(-) create mode 100644 packages/convex-helpers/server/zod4.test.ts create mode 100644 packages/convex-helpers/server/zod4.ts 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..40cf9408 --- /dev/null +++ b/packages/convex-helpers/server/zod4.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "vitest"; +import { z } from "zod"; +import { v } from "convex/values"; +import { zodToConvexFields, convexToZod } from "./zod4.js"; + +// 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"); +}); + diff --git a/packages/convex-helpers/server/zod4.ts b/packages/convex-helpers/server/zod4.ts new file mode 100644 index 00000000..22d5ee34 --- /dev/null +++ b/packages/convex-helpers/server/zod4.ts @@ -0,0 +1,642 @@ +import { z } from "zod"; +import type { + GenericId, + PropertyValidators, + GenericValidator, + Value, +} from "convex/values"; +import { ConvexError, v } from "convex/values"; +import type { + FunctionVisibility, + GenericDataModel, + GenericActionCtx, + GenericQueryCtx, + MutationBuilder, + QueryBuilder, + GenericMutationCtx, + ActionBuilder, + TableNamesInDataModel, + DefaultFunctionArgs, +} from "convex/server"; +import type { Customization } from "./customFunctions.js"; +import { NoOp } from "./customFunctions.js"; +import { pick } from "../index.js"; +import { addFieldsToValidator } from "../validators.js"; + +export type ZodValidator = Record; + +// Simple registry for zid metadata +const _meta = new WeakMap(); +const registryHelpers = { + getMetadata: (schema: z.ZodTypeAny) => _meta.get(schema), + setMetadata: (schema: z.ZodTypeAny, meta: { isConvexId?: boolean; tableName?: string }) => + _meta.set(schema, meta), +}; + +export function zid< + DataModel extends GenericDataModel, + TableName extends TableNamesInDataModel = TableNamesInDataModel, +>(tableName: TableName) { + const base = z + .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 const withSystemFields = < + Table extends string, + T extends { [key: string]: z.ZodTypeAny }, +>(tableName: Table, zObject: T) => { + return { ...zObject, _id: zid(tableName as any), _creationTime: z.number() } as const; +}; + +function isZid(schema: z.ZodTypeAny): boolean { + const m = registryHelpers.getMetadata(schema); + return !!(m && m.isConvexId && typeof m.tableName === "string"); +} + +function makeUnion(members: GenericValidator[]) { + const arr = members.filter((m): m is GenericValidator => !!m); + if (arr.length === 0) return v.any(); + if (arr.length === 1) return arr[0]; + const [a, b, ...rest] = arr as [GenericValidator, GenericValidator, ...GenericValidator[]]; + return v.union(a, b, ...rest); +} + +// Narrow helpers for Convex validators +function asValidator(x: unknown): any { + return x as unknown as any; +} + +// Zod v4 -> Convex (input) +function zodToConvexInternal( + zodValidator: z.ZodTypeAny, + visited: Set = new Set(), +): GenericValidator { + if (!zodValidator) return v.any(); + if (visited.has(zodValidator)) return v.any(); + visited.add(zodValidator); + + if (isZid(zodValidator)) { + const meta = registryHelpers.getMetadata(zodValidator); + return v.id((meta?.tableName as string) ?? "unknown"); + } + + if (zodValidator instanceof z.ZodDefault) { + const inner = zodValidator.unwrap() as unknown as z.ZodTypeAny; + const innerV = zodToConvexInternal(inner, visited); + return v.optional(asValidator(innerV)); + } + if (zodValidator instanceof z.ZodOptional) { + const inner = zodValidator.unwrap() as unknown as z.ZodTypeAny; + const innerV = zodToConvexInternal(inner, visited); + return v.optional(asValidator(innerV)); + } + if (zodValidator instanceof z.ZodNullable) { + const inner = zodValidator.unwrap() as unknown as z.ZodTypeAny; + const innerV = zodToConvexInternal(inner, visited); + return v.union(asValidator(innerV), v.null()); + } + + if (zodValidator instanceof z.ZodString) return v.string(); + if (zodValidator instanceof z.ZodNumber) return v.float64(); + if (zodValidator instanceof z.ZodBigInt) return v.int64(); + if (zodValidator instanceof z.ZodBoolean) return v.boolean(); + if (zodValidator instanceof z.ZodNull) return v.null(); + if (zodValidator instanceof z.ZodAny) return v.any(); + if (zodValidator instanceof z.ZodUnknown) return v.any(); + if (zodValidator instanceof z.ZodDate) return v.float64(); + + if (zodValidator instanceof z.ZodArray) { + const el = zodToConvexInternal(zodValidator.element as unknown as z.ZodTypeAny, visited); + return v.array(asValidator(el)); + } + + if (zodValidator instanceof z.ZodObject) { + const shape = zodValidator.shape as unknown as Record; + const out: Record = {}; + for (const [k, val] of Object.entries(shape)) { + out[k] = zodToConvexInternal(val as z.ZodTypeAny, visited); + } + return v.object(out); + } + + if (zodValidator instanceof z.ZodUnion) { + const options = (zodValidator as any).options as z.ZodTypeAny[]; + if (Array.isArray(options) && options.length > 0) { + const members = options.map((opt) => zodToConvexInternal(opt, visited)); + return (makeUnion(members as [GenericValidator, GenericValidator, ...GenericValidator[]]) as unknown) as GenericValidator; + } + return v.any(); + } + if (zodValidator instanceof z.ZodDiscriminatedUnion) { + const options: Iterable = + ((zodValidator as any).def?.options as z.ZodTypeAny[]) || + ((zodValidator as any).def?.optionsMap?.values?.() as Iterable | undefined) || + []; + const arr = Array.isArray(options) ? options : Array.from(options); + if (arr.length >= 1) { + const vals = arr.map((opt) => zodToConvexInternal(opt, visited)); + return (makeUnion(vals as [GenericValidator, ...GenericValidator[]]) as unknown) as GenericValidator; + } + return v.any(); + } + if (zodValidator instanceof z.ZodTuple) { + const items = ((zodValidator as any).def?.items as z.ZodTypeAny[] | undefined) ?? []; + if (items.length > 0) { + const members = items.map((it) => zodToConvexInternal(it, visited)); + const unionized = makeUnion(members); + return v.array(unionized as unknown as any); + } + return v.array(v.any()); + } + if (zodValidator instanceof z.ZodLazy) { + try { + const getter = (zodValidator as any).def?.getter as (() => unknown) | undefined; + if (getter) return zodToConvexInternal(getter() as unknown as z.ZodTypeAny, visited); + } catch { + // ignore resolution errors + } + return v.any(); + } + if (zodValidator instanceof z.ZodLiteral) { + const val = (zodValidator as any).value as string | number | boolean | bigint | null; + return v.literal(val as any); + } + if (zodValidator instanceof z.ZodEnum) { + const options = (zodValidator as any).options as unknown[]; + const literals = options.map((o) => v.literal(o as any)) as unknown as GenericValidator[]; + if (literals.length === 0) return v.any(); + return makeUnion(literals) as unknown as GenericValidator; + } + if (zodValidator instanceof z.ZodRecord) { + const valueType = (zodValidator as any).valueType as z.ZodTypeAny | undefined; + const vVal = valueType ? zodToConvexInternal(valueType, visited) : v.any(); + return v.record(v.string(), asValidator(vVal)); + } + if (zodValidator instanceof z.ZodReadonly) { + return zodToConvexInternal((zodValidator as any).innerType as z.ZodTypeAny, visited); + } + if (zodValidator instanceof z.ZodTransform) { + const inner = (zodValidator as any).def?.schema as z.ZodTypeAny | undefined; + return inner ? zodToConvexInternal(inner, visited) : v.any(); + } + return v.any(); +} + +export function zodToConvex(zodSchema: Z) { + if (zodSchema instanceof z.ZodType) { + return zodToConvexInternal(zodSchema); + } + const out: Record = {}; + for (const [k, v_] of Object.entries(zodSchema as Record)) { + out[k] = zodToConvexInternal(v_); + } + return out as any; +} + +export function zodToConvexFields(zodShape: Z) { + const out: Record = {}; + for (const [k, v_] of Object.entries(zodShape)) out[k] = zodToConvexInternal(v_); + return out as { [k in keyof Z]: GenericValidator }; +} + +// Output mapping (post-transform) +function zodOutputToConvexInternal( + zodValidator: z.ZodTypeAny, + visited: Set = new Set(), +): GenericValidator { + if (!zodValidator) return v.any(); + if (visited.has(zodValidator)) return v.any(); + visited.add(zodValidator); + + if (zodValidator instanceof z.ZodDefault) { + const inner = zodValidator.unwrap() as unknown as z.ZodTypeAny; + 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.ZodTypeAny, visited); + } + if (zodValidator instanceof z.ZodOptional) { + const inner = zodValidator.unwrap() as unknown as z.ZodTypeAny; + return v.optional(asValidator(zodOutputToConvexInternal(inner, visited))); + } + if (zodValidator instanceof z.ZodNullable) { + const inner = zodValidator.unwrap() as unknown as z.ZodTypeAny; + return v.union(asValidator(zodOutputToConvexInternal(inner, visited)), v.null()); + } + return zodToConvexInternal(zodValidator, visited); +} + +export function zodOutputToConvex(zodSchema: Z) { + if (zodSchema instanceof z.ZodType) return zodOutputToConvexInternal(zodSchema); + const out: Record = {}; + for (const [k, v_] of Object.entries(zodSchema as Record)) { + out[k] = zodOutputToConvexInternal(v_); + } + return out as any; +} + +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 }; +} + +// Convex -> Zod minimal mapping (for tests) +export function convexToZod(validator: GenericValidator): z.ZodTypeAny { + const isOptional = (validator as any).isOptional === "optional"; + const base: any = isOptional ? (validator as any).value : validator; + let zodValidator: z.ZodTypeAny; + switch (base.kind) { + case "id": + zodValidator = zid((base as any).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": + zodValidator = z.array(convexToZod((base as any).element)); + break; + case "object": { + const fields = (base as any).fields as Record; + const out: Record = {}; + for (const [k, v_] of Object.entries(fields)) out[k] = convexToZod(v_); + zodValidator = z.object(out); + break; + } + case "union": { + const members = (base as any).members as GenericValidator[]; + const zs = members.map((m) => convexToZod(m)); + if (zs.length === 0) { + zodValidator = z.any(); + } else if (zs.length === 1) { + zodValidator = zs[0] as z.ZodTypeAny; + } else { + const first = zs[0]!; + const second = zs[1]!; + const rest = zs.slice(2) as [z.ZodTypeAny?, ...z.ZodTypeAny[]]; + zodValidator = z.union([first, second, ...rest.filter(Boolean) as z.ZodTypeAny[]]); + } + break; + } + case "literal": + zodValidator = z.literal((base as any).value); + break; + case "record": + // Restrict keys to string schema for compatibility + zodValidator = z.record(z.string(), convexToZod((base as any).value)); + break; + default: + throw new Error(`Unknown convex validator: ${base.kind}`); + } + return isOptional ? z.optional(zodValidator) : zodValidator; +} + +export function convexToZodFields(fields: PropertyValidators) { + const out: Record = {}; + for (const [k, v_] of Object.entries(fields)) out[k] = convexToZod(v_ as GenericValidator); + return out as { [k in keyof typeof fields]: z.ZodTypeAny }; +} + +// Builders +type OneArgArray = [ArgsObject]; +type NullToUndefinedOrNull = T extends null ? T | undefined | void : T; +type Returns = Promise> | NullToUndefinedOrNull; + +type ReturnValueInput = [ + ReturnsValidator, +] extends [z.ZodTypeAny] + ? Returns> + : [ReturnsValidator] extends [ZodValidator] + ? Returns>> + : any; + +// (unused) type kept for reference in v3 version +// type ReturnValueOutput<...> omitted in zod4 to reduce type depth + +// (unused) ArgsInput omitted to reduce type noise + +type ArgsOutput | void> = [ + ArgsValidator, +] extends [z.ZodObject] + ? [z.output] + : [ArgsValidator] extends [ZodValidator] + ? [z.output>] + : OneArgArray; + +type Overwrite = Omit & U; +type Expand> = { [K in keyof T]: T[K] }; +type ArgsForHandlerType< + OneOrZeroArgs extends [] | [Record], + CustomMadeArgs extends Record, +> = CustomMadeArgs extends Record + ? OneOrZeroArgs + : OneOrZeroArgs extends [infer A] + ? [Expand] + : [CustomMadeArgs]; + +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.ZodTypeAny | ZodValidator | void = void, + ReturnValue extends ReturnValueInput = any, + >( + func: + | ({ + args?: ArgsValidator; + handler: ( + ctx: Overwrite, + ...args: ArgsForHandlerType, 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, CustomMadeArgs> + ): ReturnValue; + }, + ): import("convex/server").RegisteredQuery & + import("convex/server").RegisteredMutation & + import("convex/server").RegisteredAction; +}; + +export function toConvexJS(schema?: z.ZodTypeAny, value?: unknown): unknown { + if (!schema) return value; + if (value === undefined || value === null) return value; + if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable || schema instanceof z.ZodDefault) { + return toConvexJS((schema.unwrap() as unknown) as z.ZodTypeAny, value); + } + if (schema instanceof z.ZodDate && value instanceof Date) return value.getTime(); + if (schema instanceof z.ZodArray && Array.isArray(value)) return value.map((v_) => toConvexJS((schema.element as unknown) as z.ZodTypeAny, v_)); + if (schema instanceof z.ZodObject && typeof value === "object" && value) { + const result: Record = {}; + for (const [k, v_] of Object.entries(value as Record)) { + if (v_ !== undefined) { + const child = (schema.shape as Record)[k]; + result[k] = child ? toConvexJS((child as unknown) as z.ZodTypeAny, v_) : v_; + } + } + return result; + } + return value; +} + +export function fromConvexJS(value: unknown, schema: z.ZodTypeAny): unknown { + if (value === undefined || value === null) return value; + if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable || schema instanceof z.ZodDefault) { + return fromConvexJS(value, (schema.unwrap() as unknown) as z.ZodTypeAny); + } + if (schema instanceof z.ZodDate && typeof value === "number") return new Date(value); + if (schema instanceof z.ZodArray && Array.isArray(value)) return value.map((v_) => fromConvexJS(v_, (schema.element as unknown) as z.ZodTypeAny)); + if (schema instanceof z.ZodObject && typeof value === "object" && value) { + const result: Record = {}; + for (const [k, v_] of Object.entries(value as Record)) { + const child = (schema.shape as Record)[k]; + result[k] = child ? fromConvexJS(v_, (child as unknown) as z.ZodTypeAny) : v_; + } + return result; + } + return value; +} + +export type ConvexCodec = { + validator: any; + encode: (value: T) => any; + decode: (value: any) => T; + pick: (keys: K[] | Record) => ConvexCodec>; +}; + +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) as T, + pick: (keys: K[] | Record) => { + if (!(schema instanceof z.ZodObject)) { + throw new Error("pick() can only be called on object schemas"); + } + const pickObj = Array.isArray(keys) + ? (keys as K[]).reduce((acc, k) => ({ ...acc, [k]: true }), {} as Record) + : (keys as Record); + const pickedSchema = schema.pick(pickObj as any); + return convexCodec(pickedSchema) as ConvexCodec>; + }, + }; +} + +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; +} + +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) ? z.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: z.ZodObject; + if (argsValidator instanceof z.ZodType) { + if (argsValidator instanceof z.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 = z.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 z.ZodTypeAny).parse(ret); + } catch (e) { + handleZodValidationError(e, "returns"); + } + if (added?.onSuccess) await added.onSuccess({ ctx, args: parsed.data, result: validated }); + return toConvexJS(returns as z.ZodTypeAny, 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 z.ZodTypeAny).parse(ret); + } catch (e) { + handleZodValidationError(e, "returns"); + } + if (added?.onSuccess) await added.onSuccess({ ctx, args: allArgs, result: validated }); + return toConvexJS(returns as z.ZodTypeAny, validated); + } + if (added?.onSuccess) await added.onSuccess({ ctx, args: allArgs, result: ret }); + return ret; + }, + }); + }; +} + +export type ZCustomCtx = Builder extends CustomBuilder< + any, + any, + infer CustomCtx, + any, + infer InputCtx, + any, + any +> + ? Overwrite + : never; + +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 any; +} + +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 any; +} + +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 any; +} + +export function zBrand( + validator: T, + brand?: B, +) { + return validator.brand(brand); +} + + From 33976e1e6b060b9f45ffdda0a59c9ba866d75c58 Mon Sep 17 00:00:00 2001 From: Nate Dunn Date: Tue, 28 Oct 2025 12:08:40 -0600 Subject: [PATCH 2/3] First pass a rewrite --- packages/convex-helpers/server/zod4.test.ts | 88 +- packages/convex-helpers/server/zod4.ts | 662 +------------ .../convex-helpers/server/zod4/builder.ts | 531 ++++++++++ packages/convex-helpers/server/zod4/codec.ts | 271 ++++++ .../convex-helpers/server/zod4/convexToZod.ts | 183 ++++ .../convex-helpers/server/zod4/helpers.ts | 46 + packages/convex-helpers/server/zod4/id.ts | 87 ++ packages/convex-helpers/server/zod4/types.ts | 11 + .../convex-helpers/server/zod4/zodToConvex.ts | 909 ++++++++++++++++++ 9 files changed, 2144 insertions(+), 644 deletions(-) create mode 100644 packages/convex-helpers/server/zod4/builder.ts create mode 100644 packages/convex-helpers/server/zod4/codec.ts create mode 100644 packages/convex-helpers/server/zod4/convexToZod.ts create mode 100644 packages/convex-helpers/server/zod4/helpers.ts create mode 100644 packages/convex-helpers/server/zod4/id.ts create mode 100644 packages/convex-helpers/server/zod4/types.ts create mode 100644 packages/convex-helpers/server/zod4/zodToConvex.ts diff --git a/packages/convex-helpers/server/zod4.test.ts b/packages/convex-helpers/server/zod4.test.ts index 40cf9408..4dc9c9fb 100644 --- a/packages/convex-helpers/server/zod4.test.ts +++ b/packages/convex-helpers/server/zod4.test.ts @@ -1,7 +1,42 @@ -import { expect, test } from "vitest"; +import { expect, expectTypeOf, test } from "vitest"; import { z } from "zod"; import { v } from "convex/values"; -import { zodToConvexFields, convexToZod } from "./zod4.js"; +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", () => { @@ -13,3 +48,52 @@ test("zod4 basic roundtrip", () => { 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 index 22d5ee34..888d4eaf 100644 --- a/packages/convex-helpers/server/zod4.ts +++ b/packages/convex-helpers/server/zod4.ts @@ -1,642 +1,20 @@ -import { z } from "zod"; -import type { - GenericId, - PropertyValidators, - GenericValidator, - Value, -} from "convex/values"; -import { ConvexError, v } from "convex/values"; -import type { - FunctionVisibility, - GenericDataModel, - GenericActionCtx, - GenericQueryCtx, - MutationBuilder, - QueryBuilder, - GenericMutationCtx, - ActionBuilder, - TableNamesInDataModel, - DefaultFunctionArgs, -} from "convex/server"; -import type { Customization } from "./customFunctions.js"; -import { NoOp } from "./customFunctions.js"; -import { pick } from "../index.js"; -import { addFieldsToValidator } from "../validators.js"; - -export type ZodValidator = Record; - -// Simple registry for zid metadata -const _meta = new WeakMap(); -const registryHelpers = { - getMetadata: (schema: z.ZodTypeAny) => _meta.get(schema), - setMetadata: (schema: z.ZodTypeAny, meta: { isConvexId?: boolean; tableName?: string }) => - _meta.set(schema, meta), -}; - -export function zid< - DataModel extends GenericDataModel, - TableName extends TableNamesInDataModel = TableNamesInDataModel, ->(tableName: TableName) { - const base = z - .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 const withSystemFields = < - Table extends string, - T extends { [key: string]: z.ZodTypeAny }, ->(tableName: Table, zObject: T) => { - return { ...zObject, _id: zid(tableName as any), _creationTime: z.number() } as const; -}; - -function isZid(schema: z.ZodTypeAny): boolean { - const m = registryHelpers.getMetadata(schema); - return !!(m && m.isConvexId && typeof m.tableName === "string"); -} - -function makeUnion(members: GenericValidator[]) { - const arr = members.filter((m): m is GenericValidator => !!m); - if (arr.length === 0) return v.any(); - if (arr.length === 1) return arr[0]; - const [a, b, ...rest] = arr as [GenericValidator, GenericValidator, ...GenericValidator[]]; - return v.union(a, b, ...rest); -} - -// Narrow helpers for Convex validators -function asValidator(x: unknown): any { - return x as unknown as any; -} - -// Zod v4 -> Convex (input) -function zodToConvexInternal( - zodValidator: z.ZodTypeAny, - visited: Set = new Set(), -): GenericValidator { - if (!zodValidator) return v.any(); - if (visited.has(zodValidator)) return v.any(); - visited.add(zodValidator); - - if (isZid(zodValidator)) { - const meta = registryHelpers.getMetadata(zodValidator); - return v.id((meta?.tableName as string) ?? "unknown"); - } - - if (zodValidator instanceof z.ZodDefault) { - const inner = zodValidator.unwrap() as unknown as z.ZodTypeAny; - const innerV = zodToConvexInternal(inner, visited); - return v.optional(asValidator(innerV)); - } - if (zodValidator instanceof z.ZodOptional) { - const inner = zodValidator.unwrap() as unknown as z.ZodTypeAny; - const innerV = zodToConvexInternal(inner, visited); - return v.optional(asValidator(innerV)); - } - if (zodValidator instanceof z.ZodNullable) { - const inner = zodValidator.unwrap() as unknown as z.ZodTypeAny; - const innerV = zodToConvexInternal(inner, visited); - return v.union(asValidator(innerV), v.null()); - } - - if (zodValidator instanceof z.ZodString) return v.string(); - if (zodValidator instanceof z.ZodNumber) return v.float64(); - if (zodValidator instanceof z.ZodBigInt) return v.int64(); - if (zodValidator instanceof z.ZodBoolean) return v.boolean(); - if (zodValidator instanceof z.ZodNull) return v.null(); - if (zodValidator instanceof z.ZodAny) return v.any(); - if (zodValidator instanceof z.ZodUnknown) return v.any(); - if (zodValidator instanceof z.ZodDate) return v.float64(); - - if (zodValidator instanceof z.ZodArray) { - const el = zodToConvexInternal(zodValidator.element as unknown as z.ZodTypeAny, visited); - return v.array(asValidator(el)); - } - - if (zodValidator instanceof z.ZodObject) { - const shape = zodValidator.shape as unknown as Record; - const out: Record = {}; - for (const [k, val] of Object.entries(shape)) { - out[k] = zodToConvexInternal(val as z.ZodTypeAny, visited); - } - return v.object(out); - } - - if (zodValidator instanceof z.ZodUnion) { - const options = (zodValidator as any).options as z.ZodTypeAny[]; - if (Array.isArray(options) && options.length > 0) { - const members = options.map((opt) => zodToConvexInternal(opt, visited)); - return (makeUnion(members as [GenericValidator, GenericValidator, ...GenericValidator[]]) as unknown) as GenericValidator; - } - return v.any(); - } - if (zodValidator instanceof z.ZodDiscriminatedUnion) { - const options: Iterable = - ((zodValidator as any).def?.options as z.ZodTypeAny[]) || - ((zodValidator as any).def?.optionsMap?.values?.() as Iterable | undefined) || - []; - const arr = Array.isArray(options) ? options : Array.from(options); - if (arr.length >= 1) { - const vals = arr.map((opt) => zodToConvexInternal(opt, visited)); - return (makeUnion(vals as [GenericValidator, ...GenericValidator[]]) as unknown) as GenericValidator; - } - return v.any(); - } - if (zodValidator instanceof z.ZodTuple) { - const items = ((zodValidator as any).def?.items as z.ZodTypeAny[] | undefined) ?? []; - if (items.length > 0) { - const members = items.map((it) => zodToConvexInternal(it, visited)); - const unionized = makeUnion(members); - return v.array(unionized as unknown as any); - } - return v.array(v.any()); - } - if (zodValidator instanceof z.ZodLazy) { - try { - const getter = (zodValidator as any).def?.getter as (() => unknown) | undefined; - if (getter) return zodToConvexInternal(getter() as unknown as z.ZodTypeAny, visited); - } catch { - // ignore resolution errors - } - return v.any(); - } - if (zodValidator instanceof z.ZodLiteral) { - const val = (zodValidator as any).value as string | number | boolean | bigint | null; - return v.literal(val as any); - } - if (zodValidator instanceof z.ZodEnum) { - const options = (zodValidator as any).options as unknown[]; - const literals = options.map((o) => v.literal(o as any)) as unknown as GenericValidator[]; - if (literals.length === 0) return v.any(); - return makeUnion(literals) as unknown as GenericValidator; - } - if (zodValidator instanceof z.ZodRecord) { - const valueType = (zodValidator as any).valueType as z.ZodTypeAny | undefined; - const vVal = valueType ? zodToConvexInternal(valueType, visited) : v.any(); - return v.record(v.string(), asValidator(vVal)); - } - if (zodValidator instanceof z.ZodReadonly) { - return zodToConvexInternal((zodValidator as any).innerType as z.ZodTypeAny, visited); - } - if (zodValidator instanceof z.ZodTransform) { - const inner = (zodValidator as any).def?.schema as z.ZodTypeAny | undefined; - return inner ? zodToConvexInternal(inner, visited) : v.any(); - } - return v.any(); -} - -export function zodToConvex(zodSchema: Z) { - if (zodSchema instanceof z.ZodType) { - return zodToConvexInternal(zodSchema); - } - const out: Record = {}; - for (const [k, v_] of Object.entries(zodSchema as Record)) { - out[k] = zodToConvexInternal(v_); - } - return out as any; -} - -export function zodToConvexFields(zodShape: Z) { - const out: Record = {}; - for (const [k, v_] of Object.entries(zodShape)) out[k] = zodToConvexInternal(v_); - return out as { [k in keyof Z]: GenericValidator }; -} - -// Output mapping (post-transform) -function zodOutputToConvexInternal( - zodValidator: z.ZodTypeAny, - visited: Set = new Set(), -): GenericValidator { - if (!zodValidator) return v.any(); - if (visited.has(zodValidator)) return v.any(); - visited.add(zodValidator); - - if (zodValidator instanceof z.ZodDefault) { - const inner = zodValidator.unwrap() as unknown as z.ZodTypeAny; - 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.ZodTypeAny, visited); - } - if (zodValidator instanceof z.ZodOptional) { - const inner = zodValidator.unwrap() as unknown as z.ZodTypeAny; - return v.optional(asValidator(zodOutputToConvexInternal(inner, visited))); - } - if (zodValidator instanceof z.ZodNullable) { - const inner = zodValidator.unwrap() as unknown as z.ZodTypeAny; - return v.union(asValidator(zodOutputToConvexInternal(inner, visited)), v.null()); - } - return zodToConvexInternal(zodValidator, visited); -} - -export function zodOutputToConvex(zodSchema: Z) { - if (zodSchema instanceof z.ZodType) return zodOutputToConvexInternal(zodSchema); - const out: Record = {}; - for (const [k, v_] of Object.entries(zodSchema as Record)) { - out[k] = zodOutputToConvexInternal(v_); - } - return out as any; -} - -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 }; -} - -// Convex -> Zod minimal mapping (for tests) -export function convexToZod(validator: GenericValidator): z.ZodTypeAny { - const isOptional = (validator as any).isOptional === "optional"; - const base: any = isOptional ? (validator as any).value : validator; - let zodValidator: z.ZodTypeAny; - switch (base.kind) { - case "id": - zodValidator = zid((base as any).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": - zodValidator = z.array(convexToZod((base as any).element)); - break; - case "object": { - const fields = (base as any).fields as Record; - const out: Record = {}; - for (const [k, v_] of Object.entries(fields)) out[k] = convexToZod(v_); - zodValidator = z.object(out); - break; - } - case "union": { - const members = (base as any).members as GenericValidator[]; - const zs = members.map((m) => convexToZod(m)); - if (zs.length === 0) { - zodValidator = z.any(); - } else if (zs.length === 1) { - zodValidator = zs[0] as z.ZodTypeAny; - } else { - const first = zs[0]!; - const second = zs[1]!; - const rest = zs.slice(2) as [z.ZodTypeAny?, ...z.ZodTypeAny[]]; - zodValidator = z.union([first, second, ...rest.filter(Boolean) as z.ZodTypeAny[]]); - } - break; - } - case "literal": - zodValidator = z.literal((base as any).value); - break; - case "record": - // Restrict keys to string schema for compatibility - zodValidator = z.record(z.string(), convexToZod((base as any).value)); - break; - default: - throw new Error(`Unknown convex validator: ${base.kind}`); - } - return isOptional ? z.optional(zodValidator) : zodValidator; -} - -export function convexToZodFields(fields: PropertyValidators) { - const out: Record = {}; - for (const [k, v_] of Object.entries(fields)) out[k] = convexToZod(v_ as GenericValidator); - return out as { [k in keyof typeof fields]: z.ZodTypeAny }; -} - -// Builders -type OneArgArray = [ArgsObject]; -type NullToUndefinedOrNull = T extends null ? T | undefined | void : T; -type Returns = Promise> | NullToUndefinedOrNull; - -type ReturnValueInput = [ - ReturnsValidator, -] extends [z.ZodTypeAny] - ? Returns> - : [ReturnsValidator] extends [ZodValidator] - ? Returns>> - : any; - -// (unused) type kept for reference in v3 version -// type ReturnValueOutput<...> omitted in zod4 to reduce type depth - -// (unused) ArgsInput omitted to reduce type noise - -type ArgsOutput | void> = [ - ArgsValidator, -] extends [z.ZodObject] - ? [z.output] - : [ArgsValidator] extends [ZodValidator] - ? [z.output>] - : OneArgArray; - -type Overwrite = Omit & U; -type Expand> = { [K in keyof T]: T[K] }; -type ArgsForHandlerType< - OneOrZeroArgs extends [] | [Record], - CustomMadeArgs extends Record, -> = CustomMadeArgs extends Record - ? OneOrZeroArgs - : OneOrZeroArgs extends [infer A] - ? [Expand] - : [CustomMadeArgs]; - -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.ZodTypeAny | ZodValidator | void = void, - ReturnValue extends ReturnValueInput = any, - >( - func: - | ({ - args?: ArgsValidator; - handler: ( - ctx: Overwrite, - ...args: ArgsForHandlerType, 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, CustomMadeArgs> - ): ReturnValue; - }, - ): import("convex/server").RegisteredQuery & - import("convex/server").RegisteredMutation & - import("convex/server").RegisteredAction; -}; - -export function toConvexJS(schema?: z.ZodTypeAny, value?: unknown): unknown { - if (!schema) return value; - if (value === undefined || value === null) return value; - if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable || schema instanceof z.ZodDefault) { - return toConvexJS((schema.unwrap() as unknown) as z.ZodTypeAny, value); - } - if (schema instanceof z.ZodDate && value instanceof Date) return value.getTime(); - if (schema instanceof z.ZodArray && Array.isArray(value)) return value.map((v_) => toConvexJS((schema.element as unknown) as z.ZodTypeAny, v_)); - if (schema instanceof z.ZodObject && typeof value === "object" && value) { - const result: Record = {}; - for (const [k, v_] of Object.entries(value as Record)) { - if (v_ !== undefined) { - const child = (schema.shape as Record)[k]; - result[k] = child ? toConvexJS((child as unknown) as z.ZodTypeAny, v_) : v_; - } - } - return result; - } - return value; -} - -export function fromConvexJS(value: unknown, schema: z.ZodTypeAny): unknown { - if (value === undefined || value === null) return value; - if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable || schema instanceof z.ZodDefault) { - return fromConvexJS(value, (schema.unwrap() as unknown) as z.ZodTypeAny); - } - if (schema instanceof z.ZodDate && typeof value === "number") return new Date(value); - if (schema instanceof z.ZodArray && Array.isArray(value)) return value.map((v_) => fromConvexJS(v_, (schema.element as unknown) as z.ZodTypeAny)); - if (schema instanceof z.ZodObject && typeof value === "object" && value) { - const result: Record = {}; - for (const [k, v_] of Object.entries(value as Record)) { - const child = (schema.shape as Record)[k]; - result[k] = child ? fromConvexJS(v_, (child as unknown) as z.ZodTypeAny) : v_; - } - return result; - } - return value; -} - -export type ConvexCodec = { - validator: any; - encode: (value: T) => any; - decode: (value: any) => T; - pick: (keys: K[] | Record) => ConvexCodec>; -}; - -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) as T, - pick: (keys: K[] | Record) => { - if (!(schema instanceof z.ZodObject)) { - throw new Error("pick() can only be called on object schemas"); - } - const pickObj = Array.isArray(keys) - ? (keys as K[]).reduce((acc, k) => ({ ...acc, [k]: true }), {} as Record) - : (keys as Record); - const pickedSchema = schema.pick(pickObj as any); - return convexCodec(pickedSchema) as ConvexCodec>; - }, - }; -} - -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; -} - -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) ? z.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: z.ZodObject; - if (argsValidator instanceof z.ZodType) { - if (argsValidator instanceof z.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 = z.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 z.ZodTypeAny).parse(ret); - } catch (e) { - handleZodValidationError(e, "returns"); - } - if (added?.onSuccess) await added.onSuccess({ ctx, args: parsed.data, result: validated }); - return toConvexJS(returns as z.ZodTypeAny, 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 z.ZodTypeAny).parse(ret); - } catch (e) { - handleZodValidationError(e, "returns"); - } - if (added?.onSuccess) await added.onSuccess({ ctx, args: allArgs, result: validated }); - return toConvexJS(returns as z.ZodTypeAny, validated); - } - if (added?.onSuccess) await added.onSuccess({ ctx, args: allArgs, result: ret }); - return ret; - }, - }); - }; -} - -export type ZCustomCtx = Builder extends CustomBuilder< - any, - any, - infer CustomCtx, - any, - infer InputCtx, - any, - any -> - ? Overwrite - : never; - -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 any; -} - -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 any; -} - -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 any; -} - -export function zBrand( - validator: T, - brand?: B, -) { - return validator.brand(brand); -} - - +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 }; +} From e822e380e3d4bb5f8f56419a1f57be6a646500c2 Mon Sep 17 00:00:00 2001 From: Nate Dunn Date: Mon, 3 Nov 2025 16:23:23 -0600 Subject: [PATCH 3/3] Fix builder return type --- package-lock.json | 46 ++-- .../convex-helpers/server/zod4/builder.ts | 205 +++++++++--------- 2 files changed, 112 insertions(+), 139 deletions(-) diff --git a/package-lock.json b/package-lock.json index 324ba474..23cce724 100644 --- a/package-lock.json +++ b/package-lock.json @@ -225,7 +225,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -632,7 +631,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -656,7 +654,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -677,7 +674,6 @@ "integrity": "sha512-NKBGBSIKUG584qrS1tyxVpX/AKJKQw5HgjYEnPLC0QsTw79JrGn+qUr8CXFb955Iy7GUdiiUv1rJ6JBGvaKb6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@edge-runtime/primitives": "6.0.0" }, @@ -1836,7 +1832,6 @@ "integrity": "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -2050,7 +2045,6 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2854,8 +2848,7 @@ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", "devOptional": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@testing-library/dom": { "version": "10.4.0", @@ -2911,7 +2904,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3077,7 +3071,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3088,7 +3081,6 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3178,7 +3170,6 @@ "integrity": "sha512-jCNyAuXx8dr5KJMkecGmZ8KI61KBUhkCob+SD+C+I5+Y1FWI2Y3QmY4/cxMCC5WAsZqoEtEETVhUiUMIGCf6Bw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.40.0", "@typescript-eslint/types": "8.40.0", @@ -3570,7 +3561,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3604,7 +3594,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3713,6 +3702,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -4007,7 +3997,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -4643,7 +4632,6 @@ "resolved": "https://registry.npmjs.org/convex/-/convex-1.27.3.tgz", "integrity": "sha512-Ebr9lPgXkL7JO5IFr3bG+gYvHskyJjc96Fx0BBNkJUDXrR/bd9/uI4q8QszbglK75XfDu068vR0d/HK2T7tB9Q==", "license": "Apache-2.0", - "peer": true, "dependencies": { "esbuild": "0.25.4", "jwt-decode": "^4.0.0", @@ -4963,6 +4951,7 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -4995,7 +4984,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dompurify": { "version": "3.2.6", @@ -5349,7 +5339,6 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6424,7 +6413,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.9.12.tgz", "integrity": "sha512-SrTC0YxqPwnN7yKa8gg/giLyQ2pILCKoideIHbYbFQlWZjYt68D2A4Ae1hehO/aDQ6RmTcpqOV/O2yBtMzx/VQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -7392,7 +7380,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -7674,6 +7661,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7729,7 +7717,6 @@ "integrity": "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -7890,7 +7877,6 @@ "integrity": "sha512-aChaVU/DO5aRPmk1GX8L+whocagUUpBQqoPtJk+cm7UOXUk87J4PeWCh6nNmTTIfEhiR9DI/+FnA8dln/hTK7g==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" @@ -8813,6 +8799,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -8828,6 +8815,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -8838,6 +8826,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -9004,7 +8993,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9014,7 +9002,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9027,7 +9014,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.17.0", @@ -10136,7 +10124,6 @@ "integrity": "sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@emotion/is-prop-valid": "1.2.2", "@emotion/unitless": "0.8.1", @@ -10379,7 +10366,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10620,7 +10606,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10839,7 +10824,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -10951,7 +10935,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10993,7 +10976,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -11386,7 +11368,6 @@ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "devOptional": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -11448,7 +11429,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/packages/convex-helpers/server/zod4/builder.ts b/packages/convex-helpers/server/zod4/builder.ts index 2f9142ff..8aa95c0d 100644 --- a/packages/convex-helpers/server/zod4/builder.ts +++ b/packages/convex-helpers/server/zod4/builder.ts @@ -9,11 +9,13 @@ import type { QueryBuilder, } from "convex/server"; import type { Value } from "convex/values"; +import type { Registration } from '../customFunctions.js' +import type { ArgsArrayToObject} from 'convex/server'; import { pick } from "convex-helpers"; import { NoOp } from "convex-helpers/server/customFunctions"; import { addFieldsToValidator } from "convex-helpers/validators"; -import { ConvexError } from "convex/values"; +import { ConvexError, type ObjectType } from "convex/values"; import { fromConvexJS, toConvexJS } from "./codec.js"; import { zodOutputToConvex, zodToConvexFields } from "./zodToConvex.js"; @@ -37,6 +39,24 @@ type ReturnValueInput< ? Returns>> : any; +// The return value after it's been validated: returned to the client +type ReturnValueOutput< + ReturnsValidator extends z.$ZodType | ZodValidator | void, +> = [ReturnsValidator] extends [z.$ZodType] + ? Returns> + : [ReturnsValidator] extends [ZodValidator] + ? Returns>> + : any; + +// The args before they've been validated: passed from the client +type ArgsInput | void> = [ + ArgsValidator, +] extends [z.$ZodObject] + ? [z.input] + : [ArgsValidator] extends [ZodValidator] + ? [z.input>] + : OneArgArray; + // The args after they've been validated: passed to the handler type ArgsOutput | void> = [ArgsValidator] extends [z.$ZodObject] @@ -55,6 +75,7 @@ type ArgsForHandlerType< ? [Expand] : [CustomMadeArgs]; + /** * Useful to get the input context type for a custom function using zod. */ @@ -77,8 +98,8 @@ export type ZCustomCtx = * builder will require argument validation too. */ export type CustomBuilder< - _FuncType extends "query" | "mutation" | "action", - _CustomArgsValidator extends PropertyValidators, + FuncType extends "query" | "mutation" | "action", + CustomArgsValidator extends PropertyValidators, CustomCtx extends Record, CustomMadeArgs extends Record, InputCtx, @@ -89,9 +110,16 @@ export type CustomBuilder< ArgsValidator extends ZodValidator | z.$ZodObject | void, ReturnsZodValidator extends z.$ZodType | ZodValidator | void = void, ReturnValue extends ReturnValueInput = any, + // Note: this differs from customFunctions.ts b/c we don't need to track + // the exact args to match the standard builder types. For Zod we don't + // try to ever pass a custom function as a builder to another custom + // function, so we can be looser here. >( func: | ({ + /** + * Specify the arguments to the function as a Zod validator. + */ args?: ArgsValidator; handler: ( ctx: Overwrite, @@ -100,7 +128,16 @@ export type CustomBuilder< CustomMadeArgs > ) => ReturnValue; + /** + * Validates the value returned by the function. + * Note: you can't pass an object directly without wrapping it + * in `z.object()`. + */ returns?: ReturnsZodValidator; + /** + * If true, the function will not be validated by Convex, + * in case you're seeing performance issues with validating twice. + */ skipConvexValidation?: boolean; } & { [key in keyof ExtraArgs as key extends @@ -120,9 +157,20 @@ export type CustomBuilder< > ): ReturnValue; }, - ): import("convex/server").RegisteredQuery & - import("convex/server").RegisteredMutation & - import("convex/server").RegisteredAction; + ): Registration< + FuncType, + Visibility, + ArgsArrayToObject< + CustomArgsValidator extends Record + ? ArgsInput + : ArgsInput extends [infer A] + ? [Expand>] + : [ObjectType] + >, + ReturnsZodValidator extends void + ? ReturnValue + : ReturnValueOutput + >; }; function handleZodValidationError( @@ -143,44 +191,35 @@ export function customFnBuilder( builder: (args: any) => any, customization: Customization, ) { + // Looking forward to when input / args / ... are optional 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; + : null; 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; + let argsValidator = args; + if (argsValidator instanceof z.$ZodType) { + if (argsValidator instanceof z.$ZodObject) { + argsValidator = argsValidator._zod.def.shape; } else { throw new Error( - "Unsupported non-object Zod schema for args; " + - "please provide an args schema using z.object({...})", + "Unsupported zod type as args validator: " + + argsValidator.constructor.name, ); } - } else { - argsSchema = zValidate.object(argsValidator); } - - const convexValidator = zodToConvexFields( - argsValidator as Record, - ); - + const convexValidator = zodToConvexFields(argsValidator); return builder({ args: addFieldsToValidator(convexValidator, inputArgs), ...returnValidator, @@ -190,95 +229,49 @@ export function customFnBuilder( 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 rawArgs = pick(allArgs, Object.keys(argsValidator)); + const parsed = zValidate.object(argsValidator).safeParse(rawArgs); + if (!parsed.success) { + throw new ConvexError({ + ZodError: JSON.parse( + JSON.stringify(parsed.error, null, 2), + ) as Value[], + }); + } + const args = parsed.data; + const finalCtx = { ...ctx, ...added.ctx }; + const finalArgs = { ...args, ...added.args }; 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); + // We don't catch the error here. It's a developer error and we + // don't want to risk exposing the unexpected value to the client. + const result = returns ? returns.parse(ret) : ret; + if (added.onSuccess) { + await added.onSuccess({ ctx, args, result }); } - - if (added?.onSuccess) - await added.onSuccess({ - ctx, - args: parsed.data, - result: ret, - }); - - return ret; + return result; }, }); } - + if (Object.keys(inputArgs).length > 0 && !fn.skipConvexValidation) { + throw new Error( + "If you're using a custom function with arguments for the input " + + "customization, you must declare the arguments for the function too.", + ); + } 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 }; + handler: async (ctx: any, args: any) => { + const added = await customInput(ctx, args, extra); + const finalCtx = { ...ctx, ...added.ctx }; + const finalArgs = { ...args, ...added.args }; 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); + // We don't catch the error here. It's a developer error and we + // don't want to risk exposing the unexpected value to the client. + const result = returns ? returns.parse(ret) : ret; + if (added.onSuccess) { + await added.onSuccess({ ctx, args, result }); } - - if (added?.onSuccess) - await added.onSuccess({ - ctx, - args: allArgs, - result: ret, - }); - - return ret; + return result; }, }); };