UNPKG

convex-helpers

Version:

A collection of useful code to complement the official convex package.

727 lines (710 loc) 23.7 kB
import { ZodFirstPartyTypeKind, ZodTypeDef, z } from "zod"; import { v, ConvexError, GenericId, ObjectType, PropertyValidators, Validator, Value, } from "convex/values"; import { FunctionVisibility, GenericDataModel, GenericActionCtx, GenericQueryCtx, MutationBuilder, QueryBuilder, GenericMutationCtx, ActionBuilder, } from "convex/server"; import { Mod, NoOp, Registration, UnvalidatedBuilder, splitArgs, } from "./customFunctions"; import { EmptyObject } from ".."; export type ZodValidator = Record<string, z.ZodTypeAny>; /** * Create a validator for a Convex `Id`. * * When used as a validator, it will check that it's for the right table. * When used as a parser, it will only check that the Id is a string. * * @param tableName - The table that the `Id` references. i.e.` Id<tableName>` * @returns - A Zod object representing a Convex `Id` */ export const zid = <TableName extends string>(tableName: TableName) => new Zid({ typeName: "ConvexId", tableName }); /** * 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 mod The modifier to be applied to the query, changing ctx and args. * @returns A new query builder using zod validation to define queries. */ export function zCustomQuery< ModArgsValidator extends PropertyValidators, ModCtx extends Record<string, any>, ModMadeArgs extends Record<string, any>, Visibility extends FunctionVisibility, DataModel extends GenericDataModel >( query: QueryBuilder<DataModel, Visibility>, mod: Mod<GenericQueryCtx<DataModel>, ModArgsValidator, ModCtx, ModMadeArgs> ) { // Looking forward to when input / args / ... are optional const inputMod = mod.input ?? NoOp.input; const inputArgs = mod.args ?? NoOp.args; function customQueryBuilder(fn: any): any { if ("args" in fn) { const convexValidator = zodToConvexFields(fn.args); return query({ args: { ...convexValidator, ...inputArgs, }, handler: async (ctx, allArgs: any) => { const { split, rest } = splitArgs(inputArgs, allArgs); const added = await inputMod(ctx, split); const parsed = z.object(fn.args).safeParse(rest); if (!parsed.success) { throw new ConvexError({ ZodError: JSON.parse( JSON.stringify(parsed.error.errors, null, 2) ) as Value[], }); } const result = await fn.handler( { ...ctx, ...added.ctx }, { ...parsed.data, ...added.args } ); if (fn.output) { // 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. return fn.output.parse(result); } return result; }, }); } if (Object.keys(inputArgs).length > 0) { throw new Error( "If you're using a custom function with arguments for the input " + "modifier, you must declare the arguments for the function too." ); } const handler = fn.handler ?? fn; return query({ handler: async (ctx, args: any) => { const { ctx: modCtx } = await inputMod(ctx, args); return await handler({ ...ctx, ...modCtx }, args); }, }); } return customQueryBuilder as CustomBuilder< "query", ModArgsValidator, ModCtx, ModMadeArgs, GenericQueryCtx<DataModel>, Visibility >; } /** * 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 mod The modifier to be applied to the mutation, changing ctx and args. * @returns A new mutation builder using zod validation to define queries. */ export function zCustomMutation< ModArgsValidator extends PropertyValidators, ModCtx extends Record<string, any>, ModMadeArgs extends Record<string, any>, Visibility extends FunctionVisibility, DataModel extends GenericDataModel >( mutation: MutationBuilder<DataModel, Visibility>, mod: Mod<GenericMutationCtx<DataModel>, ModArgsValidator, ModCtx, ModMadeArgs> ) { // Looking forward to when input / args / ... are optional const inputMod = mod.input ?? NoOp.input; const inputArgs = mod.args ?? NoOp.args; function customMutationBuilder(fn: any): any { if ("args" in fn) { const convexValidator = zodToConvexFields(fn.args); return mutation({ args: { ...convexValidator, ...inputArgs, }, handler: async (ctx, allArgs: any) => { const { split, rest } = splitArgs(inputArgs, allArgs); const added = await inputMod(ctx, split); const parsed = z.object(fn.args).safeParse(rest); if (!parsed.success) { throw new ConvexError({ ZodError: JSON.parse( JSON.stringify(parsed.error.errors, null, 2) ) as Value[], }); } const result = await fn.handler( { ...ctx, ...added.ctx }, { ...parsed.data, ...added.args } ); if (fn.output) { // 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. return fn.output.parse(result); } return result; }, }); } if (Object.keys(inputArgs).length > 0) { throw new Error( "If you're using a custom function with arguments for the input " + "modifier, you must declare the arguments for the function too." ); } const handler = fn.handler ?? fn; return mutation({ handler: async (ctx, args: any) => { const { ctx: modCtx } = await inputMod(ctx, args); return await handler({ ...ctx, ...modCtx }, args); }, }); } return customMutationBuilder as CustomBuilder< "mutation", ModArgsValidator, ModCtx, ModMadeArgs, GenericMutationCtx<DataModel>, Visibility >; } /** * 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 mod The modifier to be applied to the action, changing ctx and args. * @returns A new action builder using zod validation to define queries. */ export function zCustomAction< ModArgsValidator extends PropertyValidators, ModCtx extends Record<string, any>, ModMadeArgs extends Record<string, any>, Visibility extends FunctionVisibility, DataModel extends GenericDataModel >( action: ActionBuilder<DataModel, Visibility>, mod: Mod<GenericActionCtx<DataModel>, ModArgsValidator, ModCtx, ModMadeArgs> ) { // Looking forward to when input / args / ... are optional const inputMod = mod.input ?? NoOp.input; const inputArgs = mod.args ?? NoOp.args; function customActionBuilder(fn: any): any { if ("args" in fn) { const convexValidator = zodToConvexFields(fn.args); return action({ args: { ...convexValidator, ...inputArgs, }, handler: async (ctx, allArgs: any) => { const { split, rest } = splitArgs(inputArgs, allArgs); const added = await inputMod(ctx, split); const parsed = z.object(fn.args).safeParse(rest); if (!parsed.success) { throw new ConvexError({ ZodError: JSON.parse( JSON.stringify(parsed.error.errors, null, 2) ) as Value[], }); } const result = await fn.handler( { ...ctx, ...added.ctx }, { ...parsed.data, ...added.args } ); if (fn.output) { // 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. return fn.output.parse(result); } return result; }, }); } if (Object.keys(inputArgs).length > 0) { throw new Error( "If you're using a custom function with arguments for the input " + "modifier, you must declare the arguments for the function too." ); } const handler = fn.handler ?? fn; return action({ handler: async (ctx, args: any) => { const { ctx: modCtx } = await inputMod(ctx, args); return await handler({ ...ctx, ...modCtx }, args); }, }); } return customActionBuilder as CustomBuilder< "action", ModArgsValidator, ModCtx, ModMadeArgs, GenericActionCtx<DataModel>, Visibility >; } /** * A builder that customizes a Convex function using argument validation. * e.g. `query({ args: {}, handler: async (ctx, args) => {} })` */ type ValidatedBuilder< FuncType extends "query" | "mutation" | "action", ModArgsValidator extends PropertyValidators, ModCtx extends Record<string, any>, ModMadeArgs extends Record<string, any>, InputCtx, Visibility extends FunctionVisibility > = < ExistingArgsValidator extends ZodValidator, Output, ZodOutput extends z.ZodTypeAny | undefined = undefined >(fn: { args: ExistingArgsValidator; handler: ( ctx: InputCtx & ModCtx, args: z.output<z.ZodObject<ExistingArgsValidator>> & ModMadeArgs ) => ZodOutput extends z.ZodTypeAny ? z.input<ZodOutput> | Promise<z.input<ZodOutput>> : Output; output?: ZodOutput; }) => Registration< FuncType, Visibility, z.input<z.ZodObject<ExistingArgsValidator>> & ObjectType<ModArgsValidator>, ZodOutput extends z.ZodTypeAny ? Promise<z.output<ZodOutput>> : Output >; /** * 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. */ type CustomBuilder< FuncType extends "query" | "mutation" | "action", ModArgsValidator extends PropertyValidators, ModCtx extends Record<string, any>, ModMadeArgs extends Record<string, any>, InputCtx, Visibility extends FunctionVisibility > = ModArgsValidator extends EmptyObject ? ValidatedBuilder< FuncType, ModArgsValidator, ModCtx, ModMadeArgs, InputCtx, Visibility > & UnvalidatedBuilder<FuncType, ModCtx, ModMadeArgs, InputCtx, Visibility> : ValidatedBuilder< FuncType, ModArgsValidator, ModCtx, ModMadeArgs, InputCtx, Visibility >; type ConvexValidatorFromZod<Z extends z.ZodTypeAny> = // Keep this in sync with zodToConvex implementation Z extends Zid<infer TableName> ? Validator<GenericId<TableName>> : Z extends z.ZodString ? Validator<string> : Z extends z.ZodNumber ? Validator<number> : Z extends z.ZodNaN ? Validator<number> : Z extends z.ZodBigInt ? Validator<bigint> : Z extends z.ZodBoolean ? Validator<boolean> : Z extends z.ZodNull ? Validator<null> : Z extends z.ZodUnknown ? Validator<any, false, string> : Z extends z.ZodAny ? Validator<any, false, string> : Z extends z.ZodArray<infer Inner> ? Validator<ConvexValidatorFromZod<Inner>["type"][]> : Z extends z.ZodObject<infer ZodShape> ? ReturnType< typeof v.object<{ [key in keyof ZodShape]: ConvexValidatorFromZod<ZodShape[key]>; }> > : Z extends z.ZodUnion<infer T> ? Validator< ConvexValidatorFromZod<T[number]>["type"], false, ConvexValidatorFromZod<T[number]>["fieldPaths"] > : Z extends z.ZodDiscriminatedUnion<any, infer T> ? Validator< ConvexValidatorFromZod<T[number]>["type"], false, ConvexValidatorFromZod<T[number]>["fieldPaths"] > : Z extends z.ZodTuple<infer Inner> ? Validator<ConvexValidatorFromZod<Inner[number]>["type"][]> : Z extends z.ZodLazy<infer Inner> ? ConvexValidatorFromZod<Inner> : Z extends z.ZodLiteral<infer Literal> ? Validator<Literal> : Z extends z.ZodEnum<infer T> ? Validator<T[number]> : Z extends z.ZodEffects<infer Inner> ? ConvexValidatorFromZod<Inner> : Z extends z.ZodOptional<infer Inner> ? ConvexValidatorFromZod<Inner> extends Validator< infer InnerConvex, false, infer InnerFieldPaths > ? Validator<InnerConvex | undefined, true, InnerFieldPaths> : never : Z extends z.ZodNullable<infer Inner> ? ConvexValidatorFromZod<Inner> extends Validator< infer InnerConvex, infer InnerOptional, infer InnerFieldPaths > ? Validator<null | InnerConvex, InnerOptional, InnerFieldPaths> : never : Z extends z.ZodBranded<infer Inner, any> ? ConvexValidatorFromZod<Inner> : Z extends z.ZodDefault<infer Inner> // Treat like optional ? ConvexValidatorFromZod<Inner> extends Validator< infer InnerConvex, false, infer InnerFieldPaths > ? Validator<InnerConvex | undefined, true, InnerFieldPaths> : never : Z extends z.ZodReadonly<infer Inner> ? ConvexValidatorFromZod<Inner> : Z extends z.ZodPipeline<infer Inner, any> // Validate input type ? ConvexValidatorFromZod<Inner> : // Some that are a bit unknown // : Z extends z.ZodDate ? Validator<number> // : Z extends z.ZodSymbol ? Validator<symbol> // : Z extends z.ZodNever ? Validator<never> // : Z extends z.ZodIntersection<infer T, infer U> // ? Validator< // ConvexValidatorFromZodValidator<T>["type"] & // ConvexValidatorFromZodValidator<U>["type"], // false, // ConvexValidatorFromZodValidator<T>["fieldPaths"] | // ConvexValidatorFromZodValidator<U>["fieldPaths"] // > // Is arraybuffer a thing? // Z extends z.??? ? Validator<ArrayBuffer> : // If/when Convex supports Record: // Z extends z.ZodRecord<infer K, infer V> ? RecordValidator<ConvexValidatorFromZodValidator<K>["type"], ConvexValidatorFromZodValidator<V>["type"]> : // Note: we don't handle z.undefined() in union, nullable, etc. // ? Validator<any, false, string> // We avoid doing this catch-all to avoid over-promising on types // : Z extends z.ZodTypeAny never; /** * Turn a Zod validator into a Convex Validator. * @param zod Zod validator can be a Zod object, or a Zod type like `z.string()` * @returns Convex Validator (e.g. `v.string()` from "convex/values") */ export function zodToConvex<Z extends z.ZodTypeAny>( zod: Z ): ConvexValidatorFromZod<Z> { const typeName: ZodFirstPartyTypeKind | "ConvexId" = zod._def.typeName; switch (typeName) { case "ConvexId": return v.id(zod._def.tableName) as ConvexValidatorFromZod<Z>; case "ZodString": return v.string() as ConvexValidatorFromZod<Z>; case "ZodNumber": case "ZodNaN": return v.number() as ConvexValidatorFromZod<Z>; case "ZodBigInt": return v.int64() as ConvexValidatorFromZod<Z>; case "ZodBoolean": return v.boolean() as ConvexValidatorFromZod<Z>; case "ZodNull": return v.null() as ConvexValidatorFromZod<Z>; case "ZodAny": case "ZodUnknown": return v.any() as ConvexValidatorFromZod<Z>; case "ZodArray": const inner = zodToConvex(zod._def.type); if (inner.isOptional) { throw new Error("Arrays of optional values are not supported"); } return v.array(inner) as ConvexValidatorFromZod<Z>; case "ZodObject": return v.object( zodToConvexFields(zod._def.shape()) ) as ConvexValidatorFromZod<Z>; case "ZodUnion": case "ZodDiscriminatedUnion": return v.union( ...zod._def.options.map((v: z.ZodTypeAny) => zodToConvex(v)) ) as ConvexValidatorFromZod<Z>; case "ZodTuple": const allTypes = zod._def.items.map((v: z.ZodTypeAny) => zodToConvex(v)); if (zod._def.rest) { allTypes.push(zodToConvex(zod._def.rest)); } return v.array(v.union(...allTypes)) as ConvexValidatorFromZod<Z>; case "ZodLazy": return zodToConvex(zod._def.getter()) as ConvexValidatorFromZod<Z>; case "ZodLiteral": return v.literal(zod._def.value) as ConvexValidatorFromZod<Z>; case "ZodEnum": return v.union( ...zod._def.values.map((l: string | number | boolean | bigint) => v.literal(l) ) ) as ConvexValidatorFromZod<Z>; case "ZodEffects": return zodToConvex(zod._def.schema) as ConvexValidatorFromZod<Z>; case "ZodOptional": return v.optional( zodToConvex((zod as any).unwrap()) as any ) as ConvexValidatorFromZod<Z>; case "ZodNullable": const nullable = zodToConvex((zod as any).unwrap()); if (nullable.isOptional) { // Swap nullable(optional(Z)) for optional(nullable(Z)) // Casting to any to ignore the mismatch of optional return v.optional( v.union(v.null(), nullable as any) ) as ConvexValidatorFromZod<Z>; } return v.union(v.null(), nullable) as ConvexValidatorFromZod<Z>; case "ZodBranded": return zodToConvex((zod as any).unwrap()) as ConvexValidatorFromZod<Z>; case "ZodDefault": const withDefault = zodToConvex(zod._def.innerType); if (withDefault.isOptional) { return withDefault as ConvexValidatorFromZod<Z>; } return v.optional(withDefault) as ConvexValidatorFromZod<Z>; case "ZodReadonly": return zodToConvex(zod._def.innerType) as ConvexValidatorFromZod<Z>; case "ZodPipeline": return zodToConvex(zod._def.in) as ConvexValidatorFromZod<Z>; default: throw new Error(`Unknown zod type: ${typeName}`); // N/A or not supported // case "ZodDate": // case "ZodSymbol": // case "ZodUndefined": // case "ZodNever": // case "ZodVoid": // case "ZodIntersection": // case "ZodRecord": // case "ZodMap": // case "ZodSet": // case "ZodFunction": // case "ZodNativeEnum": // case "ZodCatch": // case "ZodPromise": } } /** * 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<Z extends ZodValidator>(zod: Z) { return Object.fromEntries( Object.entries(zod).map(([k, v]) => [k, zodToConvex(v)]) ) as { [k in keyof Z]: ConvexValidatorFromZod<Z[k]> }; } interface ZidDef<TableName extends string> extends ZodTypeDef { typeName: "ConvexId"; tableName: TableName; } export class Zid<TableName extends string> extends z.ZodType< GenericId<TableName>, ZidDef<TableName> > { _parse(input: z.ParseInput) { return z.string()._parse(input) as z.ParseReturnType<GenericId<TableName>>; } } /** * Zod helper for adding Convex system fields to a record to return. * * @param tableName - The table where records are from, i.e. Doc<tableName> * @param zObject - Validators for the user-defined fields on the document. * @returns - Zod shape for use with `z.object(shape)` that includes system fields. */ export const withSystemFields = < Table extends string, T extends { [key: string]: z.ZodTypeAny } >( tableName: Table, zObject: T ) => { return { ...zObject, _id: zid(tableName), _creationTime: z.number() }; };