UNPKG

convex-helpers

Version:

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

668 lines (651 loc) 20.6 kB
/** * This file contains helpers for defining custom functions that modify the * context and arguments of a Convex function. Allows you to: * * - Run authentication logic before the request starts. * - Look up commonly used data and add it to the ctx argument. * - Replace a ctx or argument field with a different value, such as a version * of `db` that runs custom functions on data access. * - Consume arguments from the client that are not passed to the query, such * as taking in an authentication parameter like an API key or session ID. * These arguments must be sent up by the client along with each request. */ import type { GenericValidator, ObjectType, PropertyValidators, Validator, } from "convex/values"; import type { ActionBuilder, ArgsArrayForOptionalValidator, ArgsArrayToObject, DefaultArgsForOptionalValidator, DefaultFunctionArgs, FunctionVisibility, GenericActionCtx, GenericDataModel, GenericMutationCtx, GenericQueryCtx, MutationBuilder, QueryBuilder, RegisteredAction, RegisteredMutation, RegisteredQuery, ReturnValueForOptionalValidator, } from "convex/server"; import { omit, pick } from "../index.js"; import { addFieldsToValidator } from "../validators.js"; /** * A customization of a query, mutation, or action. * * It can specify common arguments that all defined functions take in, * as well as modify the ctx and args arguments to each function. * * Generally it's defined inline with customQuery, customMutation, etc. * But you can define the type explicitly if you want to reuse it. * * e.g. * ```ts * const myCustomization: Customization< * QueryCtx, * { sessionId: VId<"sessions"> }, * { db: DatabaseReader, user: User, session: Session }, * {}, * > = { * 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: {} }; * }, * }; * * const myQueryBuilder = customQuery(query, myCustomization); * ``` * * If the required args are not returned, they will not be provided for the * modified function. All returned ctx and args will show up in the type * signature for the modified function. To remove something from `ctx`, you * can return it as `undefined`. * The `input` function can also return an `onSuccess` callback that will be * called after the function executes successfully. The `onSuccess` callback * has access to resources created during input processing via closure. */ export type Customization< // The ctx object from the original function. Ctx extends Record<string, any>, // The validators for the args the customization function consumes. CustomArgsValidator extends PropertyValidators, // The ctx object produced: a patch applied to the original ctx. CustomCtx extends Record<string, any>, // The args produced by the customization function. CustomMadeArgs extends Record<string, any>, // Extra args that are passed to the input function. ExtraArgs extends Record<string, any> = Record<string, any>, > = { args: CustomArgsValidator; input: ( ctx: Ctx, args: ObjectType<CustomArgsValidator>, extra: ExtraArgs, ) => | Promise<{ ctx: CustomCtx; args: CustomMadeArgs; onSuccess?: (obj: { ctx: Ctx; args: Record<string, unknown>; result: unknown; }) => void | Promise<void>; }> | { ctx: CustomCtx; args: CustomMadeArgs; onSuccess?: (obj: { ctx: Ctx; args: Record<string, unknown>; result: unknown; }) => void | Promise<void>; }; }; /** * A helper for defining a custom function that modifies the ctx and args, to * be used with customQuery, customMutation, etc. * * This is helpful to avoid specifying the Customization type explicitly. * * e.g. * ```ts * const myCustomization = customCtxAndArgs({ * 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: {} }; * }, * }); * * const myQueryBuilder = customQuery(query, myCustomization); * ``` * If the required args are not returned, they will not be provided for the * modified function. All returned ctx and args will show up in the type * signature for the modified function. To remove something from `ctx`, you * can return it as `undefined`. */ export function customCtxAndArgs< Ctx extends Record<string, any>, CustomArgsValidator extends PropertyValidators = PropertyValidators, CustomCtx extends Record<string, any> = Record<string, any>, CustomMadeArgs extends Record<string, any> = Record<string, any>, ExtraArgs extends Record<string, any> = Record<string, any>, >(objectWithArgsAndInput: { args: CustomArgsValidator; input: ( ctx: Ctx, args: ObjectType<CustomArgsValidator>, extra: ExtraArgs, ) => | Promise<{ ctx: CustomCtx; args: CustomMadeArgs }> | { ctx: CustomCtx; args: CustomMadeArgs }; }): Customization< Ctx, CustomArgsValidator, CustomCtx, CustomMadeArgs, ExtraArgs > { // This is already the right type. This function just helps you define it. return objectWithArgsAndInput; } /** * A helper for defining a Customization when your mod doesn't need to add or remove * anything from args. * @param modifyCtx A function that defines how to modify the ctx. * @returns A ctx delta to be applied to the original ctx. */ export function customCtx< InCtx extends Record<string, any>, OutCtx extends Record<string, any>, ExtraArgs extends Record<string, any> = Record<string, any>, >( modifyCtx: (original: InCtx, extra: ExtraArgs) => Promise<OutCtx> | OutCtx, ): Customization< InCtx, Record<string, never>, OutCtx, Record<string, never>, ExtraArgs > { return { args: {}, input: async (ctx, _, extra) => ({ ctx: await modifyCtx(ctx, extra), args: {}, }), }; } /** * A Customization that doesn't add or remove any context or args. */ export const NoOp = { args: {}, input() { return { args: {}, ctx: {} }; }, }; /** * customQuery helps define custom behavior on top of `query` or `internalQuery` * by passing a function that modifies the ctx and args. * * Example usage: * ```js * const myQueryBuilder = customQuery(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: {}, * onSuccess: ({ result }) => { * // Optional callback that runs after the function executes * // Has access to resources created during input processing * console.log(`Query for ${user.name} returned:`, result); * } * }; * }, * }); * * // Using the custom builder * export const getSomeData = myQueryBuilder({ * args: { someArg: v.string() }, * handler: async (ctx, args) => { * const { db, user, session, scheduler } = ctx; * const { someArg } = args; * // ... * } * }); * ``` * * Simple usage only modifying ctx: * ```js * const myInternalQuery = customQuery( * 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: {}, * handler: async (ctx, args) => { * return ctx.user; * }, * }); * * @param query The query to be modified. Usually `query` or `internalQuery` * from `_generated/server`. * @param customization The modifier to be applied to the query, changing ctx and args. * @returns A new query builder to define queries with modified ctx and args. */ export function customQuery< CustomArgsValidator extends PropertyValidators, CustomCtx extends Record<string, any>, CustomMadeArgs extends Record<string, any>, Visibility extends FunctionVisibility, DataModel extends GenericDataModel, ExtraArgs extends Record<string, any> = object, >( query: QueryBuilder<DataModel, Visibility>, customization: Customization< GenericQueryCtx<DataModel>, CustomArgsValidator, CustomCtx, CustomMadeArgs, ExtraArgs >, ) { return customFnBuilder(query, customization) as CustomBuilder< "query", CustomArgsValidator, CustomCtx, CustomMadeArgs, GenericQueryCtx<DataModel>, Visibility, ExtraArgs >; } /** * customMutation helps define custom behavior on top of `mutation` * or `internalMutation` by passing a function that modifies the ctx and args. * * Example usage: * ```js * const myMutationBuilder = customMutation(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: {}, * onSuccess: ({ result }) => { * // Optional callback that runs after the function executes * // Has access to resources created during input processing * console.log(`User ${user.name} returned:`, result); * } * }; * }, * }); * * // Using the custom builder * export const setSomeData = myMutationBuilder({ * args: { someArg: v.string() }, * handler: async (ctx, args) => { * const { db, user, session, scheduler } = ctx; * const { someArg } = args; * // ... * } * }); * ``` * * Simple usage only modifying ctx: * ```js * const myUserMutation = customMutation( * mutation, * customCtx(async (ctx) => { * return { * // Throws an exception if the user isn't logged in * user: await getUserByTokenIdentifier(ctx), * }; * }) * ); * * // Using it * export const setMyName = myUserMutation({ * args: { name: v.string() }, * handler: async (ctx, args) => { * await ctx.db.patch(ctx.user._id, { name: args.name }); * }, * }); * * @param mutation The mutation to be modified. Usually `mutation` or `internalMutation` * from `_generated/server`. * @param customization The modifier to be applied to the mutation, changing ctx and args. * @returns A new mutation builder to define queries with modified ctx and args. */ export function customMutation< CustomArgsValidator extends PropertyValidators, CustomCtx extends Record<string, any>, CustomMadeArgs extends Record<string, any>, Visibility extends FunctionVisibility, DataModel extends GenericDataModel, ExtraArgs extends Record<string, any> = object, >( mutation: MutationBuilder<DataModel, Visibility>, customization: Customization< GenericMutationCtx<DataModel>, CustomArgsValidator, CustomCtx, CustomMadeArgs, ExtraArgs >, ) { return customFnBuilder(mutation, customization) as CustomBuilder< "mutation", CustomArgsValidator, CustomCtx, CustomMadeArgs, GenericMutationCtx<DataModel>, Visibility, ExtraArgs >; } /** * customAction helps define custom behavior on top of `action` * or `internalAction` by passing a function that modifies the ctx and args. * * Example usage: * ```js * const myActionBuilder = customAction(action, { * args: { secretKey: v.string() }, * input: async (ctx, args) => { * // Very basic authorization, e.g. from trusted backends. * if (args.secretKey !== process.env.SECRET_KEY) { * throw new Error("Invalid secret key"); * } * const user = await ctx.runQuery(internal.users.getUser, {}); * // Create resources that can be used in the onSuccess callback * const logger = createLogger(); * return { * ctx: { user }, * args: {}, * onSuccess: ({ result }) => { * // Optional callback that runs after the function executes * // Has access to resources created during input processing * logger.info(`Action for user ${user.name} returned:`, result); * } * }; * }, * }); * * // Using the custom builder * export const runSomeAction = myActionBuilder({ * args: { someArg: v.string() }, * handler: async (ctx, args) => { * const { user, scheduler } = ctx; * const { someArg } = args; * // ... * } * }); * ``` * * Simple usage only modifying ctx: * ```js * const myUserAction = customAction( * internalAction, * customCtx(async (ctx) => { * return { * // Throws an exception if the user isn't logged in * user: await ctx.runQuery(internal.users.getUser, {}); * }; * }) * ); * * // Using it * export const sendUserEmail = myUserAction({ * args: { subject: v.string(), body: v.string() }, * handler: async (ctx, args) => { * await sendEmail(ctx.user.email, args.subject, args.body); * }, * }); * * @param action The action to be modified. Usually `action` or `internalAction` * from `_generated/server`. * @param customization The modifier to be applied to the action, changing ctx and args. * @returns A new action builder to define queries with modified ctx and args. */ export function customAction< CustomArgsValidator extends PropertyValidators, CustomCtx extends Record<string, any>, CustomMadeArgs extends Record<string, any>, Visibility extends FunctionVisibility, DataModel extends GenericDataModel, ExtraArgs extends Record<string, any> = object, >( action: ActionBuilder<DataModel, Visibility>, customization: Customization< GenericActionCtx<DataModel>, CustomArgsValidator, CustomCtx, CustomMadeArgs, ExtraArgs >, ): CustomBuilder< "action", CustomArgsValidator, CustomCtx, CustomMadeArgs, GenericActionCtx<DataModel>, Visibility, ExtraArgs > { return customFnBuilder(action, customization) as CustomBuilder< "action", CustomArgsValidator, CustomCtx, CustomMadeArgs, GenericActionCtx<DataModel>, Visibility, ExtraArgs >; } function customFnBuilder( builder: (args: any) => any, customization: Customization<any, any, any, any, any>, ) { // Looking forward to when input / args / ... are optional const customInput: Customization<any, any, any, any, any>["input"] = customization.input ?? NoOp.input; const inputArgs = customization.args ?? NoOp.args; return function customBuilder(fn: any): any { // N.B.: This is fine if it's a function const { args, handler = fn, returns, ...extra } = fn; if (args) { return builder({ args: addFieldsToValidator(args, inputArgs), returns, handler: async (ctx: any, allArgs: any) => { const added = await customInput( ctx, pick(allArgs, Object.keys(inputArgs)) as any, extra, ); const args = omit(allArgs, Object.keys(inputArgs)); const finalCtx = { ...ctx, ...added.ctx }; const finalArgs = { ...args, ...added.args }; const result = await handler(finalCtx, finalArgs); if (added.onSuccess) { await added.onSuccess({ ctx, args, result }); } return result; }, }); } if (Object.keys(inputArgs).length > 0) { 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({ returns: fn.returns, handler: async (ctx: any, args: any) => { const added = await customInput(ctx, args, extra); const finalCtx = { ...ctx, ...added.ctx }; const finalArgs = { ...args, ...added.args }; const result = await handler(finalCtx, finalArgs); if (added.onSuccess) { await added.onSuccess({ ctx, args, result }); } return result; }, }); }; } /** * A Convex function (query, mutation, or action) to be registered for the API. * Convenience to specify the registration type based on function type. */ export type Registration< FuncType extends "query" | "mutation" | "action", Visibility extends FunctionVisibility, Args extends DefaultFunctionArgs, Output, > = { query: RegisteredQuery<Visibility, Args, Output>; mutation: RegisteredMutation<Visibility, Args, Output>; action: RegisteredAction<Visibility, Args, Output>; }[FuncType]; /* * Hack! This type causes TypeScript to simplify how it renders object types. * * It is functionally the identity for object types, but in practice it can * simplify expressions like `A & B`. */ type Expand<ObjectType extends Record<any, any>> = ObjectType extends Record<any, any> ? { [Key in keyof ObjectType]: ObjectType[Key]; } : never; type ArgsForHandlerType< OneOrZeroArgs extends [] | [Record<string, any>], CustomMadeArgs extends Record<string, any>, > = CustomMadeArgs extends Record<string, never> ? OneOrZeroArgs : OneOrZeroArgs extends [infer A] ? [Expand<A & CustomMadeArgs>] : [CustomMadeArgs]; /** * 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<string, any>, CustomMadeArgs extends Record<string, any>, InputCtx, Visibility extends FunctionVisibility, ExtraArgs extends Record<string, any>, > = { < ArgsValidator extends PropertyValidators | void | Validator<any, any, any>, ReturnsValidator extends PropertyValidators | GenericValidator | void, ReturnValue extends ReturnValueForOptionalValidator<ReturnsValidator> = any, OneOrZeroArgs extends ArgsArrayForOptionalValidator<ArgsValidator> = DefaultArgsForOptionalValidator<ArgsValidator>, >( func: | ({ args?: ArgsValidator; returns?: ReturnsValidator; handler: ( ctx: Overwrite<InputCtx, CustomCtx>, ...args: ArgsForHandlerType<OneOrZeroArgs, CustomMadeArgs> ) => ReturnValue; } & { [key in keyof ExtraArgs as key extends "args" | "returns" | "handler" ? never : key]: ExtraArgs[key]; }) | { ( ctx: Overwrite<InputCtx, CustomCtx>, ...args: ArgsForHandlerType<OneOrZeroArgs, CustomMadeArgs> ): ReturnValue; }, ): Registration< FuncType, Visibility, ArgsArrayToObject< CustomArgsValidator extends Record<string, never> ? OneOrZeroArgs : OneOrZeroArgs extends [infer A] ? [Expand<A & ObjectType<CustomArgsValidator>>] : [ObjectType<CustomArgsValidator>] >, ReturnValue >; }; export type CustomCtx<Builder> = Builder extends CustomBuilder< any, any, infer CustomCtx, any, infer InputCtx, any, any > ? Overwrite<InputCtx, CustomCtx> : never; type Overwrite<T, U> = keyof U extends never ? T : Omit<T, keyof U> & U; /** * @deprecated This type has been renamed to `Customization`. * A modifier for a query, mutation, or action. * * This defines what arguments are required for the modifier, and how to modify * the ctx and args. If the required args are not returned, they will not be * provided for the modified function. All returned ctx and args will show up * in the type signature for the modified function. * To remove something from `ctx`, you can return it as `undefined`. */ export type Mod< Ctx extends Record<string, any>, ModArgsValidator extends PropertyValidators, ModCtx extends Record<string, any>, ModMadeArgs extends Record<string, any>, ExtraArgs extends Record<string, any> = Record<string, any>, > = { args: ModArgsValidator; input: ( ctx: Ctx, args: ObjectType<ModArgsValidator>, extra: ExtraArgs, ) => | Promise<{ ctx: ModCtx; args: ModMadeArgs }> | { ctx: ModCtx; args: ModMadeArgs }; };