diff --git a/convex/testingFunctions.ts b/convex/testingFunctions.ts index 3142e837..7b30ff02 100644 --- a/convex/testingFunctions.ts +++ b/convex/testingFunctions.ts @@ -21,12 +21,15 @@ export const testingQuery = customQuery(query, { export const testingMutation = customMutation(mutation, { args: {}, - input: async (_ctx, _args) => { + input: async (_ctx, _args, { devOnly }: { devOnly: boolean }) => { if (process.env.IS_TEST === undefined) { throw new Error( "Calling a test only function in an unexpected environment", ); } + if (devOnly && process.env.IS_PROD) { + throw new Error("This function is only available in development"); + } return { ctx: {}, args: {} }; }, }); @@ -43,13 +46,16 @@ export const testingAction = customAction(action, { }, }); -export const clearAll = testingMutation(async ({ db, scheduler, storage }) => { - for (const table of Object.keys(schema.tables)) { - const docs = await db.query(table as any).collect(); - await Promise.all(docs.map((doc) => db.delete(doc._id))); - } - const scheduled = await db.system.query("_scheduled_functions").collect(); - await Promise.all(scheduled.map((s) => scheduler.cancel(s._id))); - const storedFiles = await db.system.query("_storage").collect(); - await Promise.all(storedFiles.map((s) => storage.delete(s._id))); +export const clearAll = testingMutation({ + devOnly: true, + handler: async ({ db, scheduler, storage }) => { + for (const table of Object.keys(schema.tables)) { + const docs = await db.query(table as any).collect(); + await Promise.all(docs.map((doc) => db.delete(doc._id))); + } + const scheduled = await db.system.query("_scheduled_functions").collect(); + await Promise.all(scheduled.map((s) => scheduler.cancel(s._id))); + const storedFiles = await db.system.query("_storage").collect(); + await Promise.all(storedFiles.map((s) => storage.delete(s._id))); + }, }); diff --git a/packages/convex-helpers/README.md b/packages/convex-helpers/README.md index 4af9e699..c46d010e 100644 --- a/packages/convex-helpers/README.md +++ b/packages/convex-helpers/README.md @@ -52,7 +52,7 @@ See the associated [Stack Post](https://stack.convex.dev/custom-functions) For example: -```js +```ts import { customQuery } from "convex-helpers/server/customFunctions.js"; const myQueryBuilder = customQuery(query, { @@ -75,6 +75,34 @@ export const getSomeData = myQueryBuilder({ }); ``` +### Taking in extra arguments + +You can take in extra arguments to a custom function by specifying the type of a third `input` arg. + +```ts +const myQueryBuilder = customQuery(query, { + args: {}, + input: async (ctx, args, { role }: { role: "admin" | "user" }) => { + const user = await getUser(ctx); + if (role === "admin" && user.role !== "admin") { + throw new Error("You are not an admin"); + } + if (role === "user" && !user) { + throw new Error("You must be logged in to access this query"); + } + return { ctx: { user }, args: {} }; + }, +}); + +const myAdminQuery = myQueryBuilder({ + role: "admin", + args: {}, + handler: async (ctx, args) => { + // ... + }, +}); +``` + ## Relationship helpers Traverse database relationships without all the query boilerplate. diff --git a/packages/convex-helpers/server/customFunctions.test.ts b/packages/convex-helpers/server/customFunctions.test.ts index 7a5e615e..98066790 100644 --- a/packages/convex-helpers/server/customFunctions.test.ts +++ b/packages/convex-helpers/server/customFunctions.test.ts @@ -147,6 +147,30 @@ customAction( customCtx((ctx) => ({})), ) satisfies typeof action; +customQuery({} as any, { + args: {}, + input: async () => ({ + ctx: {}, + args: {}, + }), +}) satisfies typeof query; + +customMutation(mutation, { + args: {}, + input: async () => ({ + ctx: {}, + args: {}, + }), +}) satisfies typeof mutation; + +customAction(action, { + args: {}, + input: async () => ({ + ctx: {}, + args: {}, + }), +}) satisfies typeof action; + /** * Testing custom function modifications. */ @@ -359,6 +383,25 @@ export const outerRemoves = outerRemover({ }, }); +/** + * Adding extra args to `input` + */ +const extraArgQueryBuilder = customQuery(query, { + args: { a: v.string() }, + input: async (_ctx, args, { extraArg }: { extraArg: string }) => ({ + ctx: { extraArg }, + args, + }), +}); +export const extraArgQuery = extraArgQueryBuilder({ + args: {}, + extraArg: "foo", + handler: async (ctx, args) => { + return { ctxA: ctx.extraArg }; + }, +}); +queryMatches(extraArgQuery, {}, { ctxA: "foo" }); + /** * Test helpers */ @@ -388,6 +431,7 @@ const testApi: ApiFromModules<{ create: typeof create; outerAdds: typeof outerAdds; outerRemoves: typeof outerRemoves; + extraArgQuery: typeof extraArgQuery; }; }>["fns"] = anyApi["customFunctions.test"] as any; @@ -569,3 +613,12 @@ describe("nested custom functions", () => { ).rejects.toThrow("Validator error: Expected `string`"); }); }); + +describe("extra args", () => { + test("add extra args", async () => { + const t = convexTest(schema, modules); + expect(await t.query(testApi.extraArgQuery, { a: "foo" })).toMatchObject({ + ctxA: "foo", + }); + }); +}); diff --git a/packages/convex-helpers/server/customFunctions.ts b/packages/convex-helpers/server/customFunctions.ts index f538cd3a..793b0795 100644 --- a/packages/convex-helpers/server/customFunctions.ts +++ b/packages/convex-helpers/server/customFunctions.ts @@ -51,11 +51,13 @@ export type Mod< ModArgsValidator extends PropertyValidators, ModCtx extends Record, ModMadeArgs extends Record, + ExtraArgs extends Record = {}, > = { args: ModArgsValidator; input: ( ctx: Ctx, args: ObjectType, + extra: ExtraArgs, ) => | Promise<{ ctx: ModCtx; args: ModMadeArgs }> | { ctx: ModCtx; args: ModMadeArgs }; @@ -70,12 +72,13 @@ export type Mod< export function customCtx< InCtx extends Record, OutCtx extends Record, + ExtraArgs extends Record = {}, >( - mod: (original: InCtx) => Promise | OutCtx, -): Mod { + mod: (original: InCtx, extra: ExtraArgs) => Promise | OutCtx, +): Mod { return { args: {}, - input: async (ctx) => ({ ctx: await mod(ctx), args: {} }), + input: async (ctx, _, extra) => ({ ctx: await mod(ctx, extra), args: {} }), }; } @@ -147,9 +150,16 @@ export function customQuery< ModMadeArgs extends Record, Visibility extends FunctionVisibility, DataModel extends GenericDataModel, + ExtraArgs extends Record = {}, >( query: QueryBuilder, - mod: Mod, ModArgsValidator, ModCtx, ModMadeArgs>, + mod: Mod< + GenericQueryCtx, + ModArgsValidator, + ModCtx, + ModMadeArgs, + ExtraArgs + >, ) { return customFnBuilder(query, mod) as CustomBuilder< "query", @@ -157,7 +167,8 @@ export function customQuery< ModCtx, ModMadeArgs, GenericQueryCtx, - Visibility + Visibility, + ExtraArgs >; } @@ -219,13 +230,15 @@ export function customMutation< ModMadeArgs extends Record, Visibility extends FunctionVisibility, DataModel extends GenericDataModel, + ExtraArgs extends Record = {}, >( mutation: MutationBuilder, mod: Mod< GenericMutationCtx, ModArgsValidator, ModCtx, - ModMadeArgs + ModMadeArgs, + ExtraArgs >, ) { return customFnBuilder(mutation, mod) as CustomBuilder< @@ -234,7 +247,8 @@ export function customMutation< ModCtx, ModMadeArgs, GenericMutationCtx, - Visibility + Visibility, + ExtraArgs >; } @@ -298,16 +312,24 @@ export function customAction< ModMadeArgs extends Record, Visibility extends FunctionVisibility, DataModel extends GenericDataModel, + ExtraArgs extends Record = {}, >( action: ActionBuilder, - mod: Mod, ModArgsValidator, ModCtx, ModMadeArgs>, + mod: Mod< + GenericActionCtx, + ModArgsValidator, + ModCtx, + ModMadeArgs, + ExtraArgs + >, ): CustomBuilder< "action", ModArgsValidator, ModCtx, ModMadeArgs, GenericActionCtx, - Visibility + Visibility, + ExtraArgs > { return customFnBuilder(action, mod) as CustomBuilder< "action", @@ -315,27 +337,30 @@ export function customAction< ModCtx, ModMadeArgs, GenericActionCtx, - Visibility + Visibility, + ExtraArgs >; } function customFnBuilder( builder: (args: any) => any, - mod: Mod, + mod: Mod, ) { // Looking forward to when input / args / ... are optional const inputMod = mod.input ?? NoOp.input; const inputArgs = mod.args ?? NoOp.args; return function customBuilder(fn: any): any { - const handler = fn.handler ?? fn; - if ("args" in fn) { + // N.B.: This is fine if it's a function + const { args, handler = fn, returns, ...extra } = fn; + if (args) { return builder({ - args: addArgs(fn.args, inputArgs), - returns: fn.returns, + args: addArgs(args, inputArgs), + returns, handler: async (ctx: any, allArgs: any) => { const added = await inputMod( ctx, pick(allArgs, Object.keys(inputArgs)) as any, + extra, ); const args = omit(allArgs, Object.keys(inputArgs)); return handler({ ...ctx, ...added.ctx }, { ...args, ...added.args }); @@ -351,7 +376,7 @@ function customFnBuilder( return builder({ returns: fn.returns, handler: async (ctx: any, args: any) => { - const added = await inputMod(ctx, args); + const added = await inputMod(ctx, args, extra); return handler({ ...ctx, ...added.ctx }, { ...args, ...added.args }); }, }); @@ -430,6 +455,7 @@ export type CustomBuilder< ModMadeArgs extends Record, InputCtx, Visibility extends FunctionVisibility, + ExtraArgs extends Record, > = { < ArgsValidator extends PropertyValidators | void | Validator, @@ -439,14 +465,18 @@ export type CustomBuilder< ArgsArrayForOptionalValidator = DefaultArgsForOptionalValidator, >( func: - | { + | ({ args?: ArgsValidator; returns?: ReturnsValidator; handler: ( ctx: Overwrite, ...args: ArgsForHandlerType ) => ReturnValue; - } + } & { + [key in keyof ExtraArgs as key extends "args" | "returns" | "handler" + ? never + : key]: ExtraArgs[key]; + }) | { ( ctx: Overwrite, @@ -474,9 +504,10 @@ export type CustomCtx = infer ModCtx, any, infer InputCtx, + any, any > ? Overwrite : never; -type Overwrite = Omit & U; +type Overwrite = keyof U extends never ? T : Omit & U; diff --git a/packages/convex-helpers/server/zod.ts b/packages/convex-helpers/server/zod.ts index daaf2d6a..982eb5cc 100644 --- a/packages/convex-helpers/server/zod.ts +++ b/packages/convex-helpers/server/zod.ts @@ -309,17 +309,20 @@ function customFnBuilder( const inputMod = mod.input ?? NoOp.input; const inputArgs = mod.args ?? NoOp.args; return function customBuilder(fn: any): any { - let returns = fn.returns ?? fn.output; - if (returns && !(returns instanceof z.ZodType)) { - returns = z.object(returns); - } + const { args, handler = fn, returns: maybeObject, ...extra } = fn; + + const returns = + maybeObject && !(maybeObject instanceof z.ZodType) + ? z.object(maybeObject) + : maybeObject; const returnValidator = - fn.returns && !fn.skipConvexValidation + returns && !fn.skipConvexValidation ? { returns: zodOutputToConvex(returns) } : null; - if ("args" in fn && !fn.skipConvexValidation) { - let argsValidator = fn.args; + + if (args && !fn.skipConvexValidation) { + let argsValidator = args; if (argsValidator instanceof z.ZodType) { if (argsValidator instanceof z.ZodObject) { argsValidator = argsValidator._def.shape(); @@ -341,6 +344,7 @@ function customFnBuilder( const added = await inputMod( ctx, pick(allArgs, Object.keys(inputArgs)) as any, + extra, ); const rawArgs = pick(allArgs, Object.keys(argsValidator)); const parsed = z.object(argsValidator).safeParse(rawArgs); @@ -370,11 +374,10 @@ function customFnBuilder( "modifier, you must declare the arguments for the function too.", ); } - const handler = fn.handler ?? fn; return builder({ ...returnValidator, handler: async (ctx: any, args: any) => { - const added = await inputMod(ctx, args); + const added = await inputMod(ctx, args, extra); if (returns) { // 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.