diff --git a/convex/inlineFunctions.test.ts b/convex/inlineFunctions.test.ts new file mode 100644 index 0000000..2f249c8 --- /dev/null +++ b/convex/inlineFunctions.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from "vitest"; +import { convexTest } from "../index"; +import schema from "./schema"; + +test("inline query", async () => { + const t = convexTest(schema); + await t.run(async (ctx) => { + await ctx.db.insert("messages", { author: "sarah", body: "hello1" }); + await ctx.db.insert("messages", { author: "sarah", body: "hello2" }); + }); + const messages = await t.query(async (ctx) => { + return await ctx.db.query("messages").collect(); + }); + expect(messages).toMatchObject([ + { author: "sarah", body: "hello1" }, + { author: "sarah", body: "hello2" }, + ]); +}); + +test("inline mutation insert", async () => { + const t = convexTest(schema); + const id = await t.mutation(async (ctx) => { + return await ctx.db.insert("messages", { author: "sarah", body: "hello" }); + }); + expect(id).toBeDefined(); + const messages = await t.query(async (ctx) => { + return await ctx.db.query("messages").collect(); + }); + expect(messages).toMatchObject([{ author: "sarah", body: "hello" }]); +}); + +test("inline mutation patch", async () => { + const t = convexTest(schema); + const id = await t.mutation(async (ctx) => { + return await ctx.db.insert("messages", { author: "sarah", body: "hello" }); + }); + await t.mutation(async (ctx) => { + await ctx.db.patch(id, { body: "updated" }); + }); + const message = await t.query(async (ctx) => { + return await ctx.db.get(id); + }); + expect(message).toMatchObject({ author: "sarah", body: "updated" }); +}); + +test("inline mutation delete", async () => { + const t = convexTest(schema); + const id = await t.mutation(async (ctx) => { + return await ctx.db.insert("messages", { author: "sarah", body: "hello" }); + }); + await t.mutation(async (ctx) => { + await ctx.db.delete(id); + }); + const message = await t.query(async (ctx) => { + return await ctx.db.get(id); + }); + expect(message).toBeNull(); +}); + +test("inline query is read-only (no db.insert)", async () => { + const t = convexTest(schema); + await expect(async () => { + await t.query(async (ctx) => { + // @ts-expect-error - queries should not have insert + await ctx.db.insert("messages", { author: "sarah", body: "hello" }); + }); + }).rejects.toThrow(); +}); + +test("inline mutation transaction rollback", async () => { + const t = convexTest(schema); + await expect(async () => { + await t.mutation(async (ctx) => { + await ctx.db.insert("messages", { author: "sarah", body: "hello" }); + throw new Error("rollback"); + }); + }).rejects.toThrowError("rollback"); + + const messages = await t.query(async (ctx) => { + return await ctx.db.query("messages").collect(); + }); + expect(messages).toMatchObject([]); +}); + +test("inline functions with identity", async () => { + const t = convexTest(schema); + const identity = await t + .withIdentity({ name: "Test User" }) + .query(async (ctx) => { + return await ctx.auth.getUserIdentity(); + }); + expect(identity).toMatchObject({ name: "Test User" }); +}); + +test("inline query returns value", async () => { + const t = convexTest(schema); + await t.run(async (ctx) => { + await ctx.db.insert("messages", { author: "sarah", body: "hello" }); + }); + const count = await t.query(async (ctx) => { + const messages = await ctx.db.query("messages").collect(); + return messages.length + 41; + }); + expect(count).toBe(42); +}); + +test("inline mutation returns inserted id", async () => { + const t = convexTest(schema); + const id = await t.mutation(async (ctx) => { + const insertedId = await ctx.db.insert("messages", { + author: "sarah", + body: "hello", + }); + return insertedId; + }); + expect(typeof id).toBe("string"); + expect(id).toContain("messages"); +}); diff --git a/index.ts b/index.ts index 2248116..3062708 100644 --- a/index.ts +++ b/index.ts @@ -10,6 +10,7 @@ import { GenericDataModel, GenericDocument, GenericMutationCtx, + GenericQueryCtx, GenericSchema, HttpRouter, OptionalRestArgs, @@ -1158,7 +1159,10 @@ function asyncSyscallImpl() { const { name, args: queryArgs } = args; return JSON.stringify( convexToJson( - await withAuth().query(makeFunctionReference(name), queryArgs), + (await withAuth().query( + makeFunctionReference(name), + queryArgs, + )) as Value, ), ); } @@ -1177,7 +1181,10 @@ function asyncSyscallImpl() { const { name, args: actionArgs } = args; return JSON.stringify( convexToJson( - await withAuth().action(makeFunctionReference(name), actionArgs), + (await withAuth().action( + makeFunctionReference(name), + actionArgs, + )) as Value, ), ); } @@ -1478,43 +1485,61 @@ export type TestConvex> = export type TestConvexForDataModel = { /** - * Call a public or internal query. + * Call a public or internal query, or run an inline query function. * - * @param query A {@link FunctionReference} for the query. + * @param query A {@link FunctionReference} for the query, or an inline + * async function that receives a query context. * @param args An arguments object for the query. If this is omitted, - * the arguments will be `{}`. + * the arguments will be `{}`. Not used for inline functions. * @returns A `Promise` of the query's result. */ - query: >( - query: Query, - ...args: OptionalRestArgs - ) => Promise>; + query: { + >( + query: Query, + ...args: OptionalRestArgs + ): Promise>; + ( + func: (ctx: GenericQueryCtx) => Promise, + ): Promise; + }; /** - * Call a public or internal mutation. + * Call a public or internal mutation, or run an inline mutation function. * - * @param mutation A {@link FunctionReference} for the mutation. + * @param mutation A {@link FunctionReference} for the mutation, or an inline + * async function that receives a mutation context. * @param args An arguments object for the mutation. If this is omitted, - * the arguments will be `{}`. + * the arguments will be `{}`. Not used for inline functions. * @returns A `Promise` of the mutation's result. */ - mutation: >( - mutation: Mutation, - ...args: OptionalRestArgs - ) => Promise>; + mutation: { + >( + mutation: Mutation, + ...args: OptionalRestArgs + ): Promise>; + ( + func: (ctx: GenericMutationCtx) => Promise, + ): Promise; + }; /** - * Call a public or internal action. + * Call a public or internal action, or run an inline action function. * - * @param action A {@link FunctionReference} for the action. + * @param action A {@link FunctionReference} for the action, or an inline + * async function that receives an action context. * @param args An arguments object for the action. If this is omitted, - * the arguments will be `{}`. + * the arguments will be `{}`. Not used for inline functions. * @returns A `Promise` of the action's result. */ - action: >( - action: Action, - ...args: OptionalRestArgs - ) => Promise>; + action: { + >( + action: Action, + ...args: OptionalRestArgs + ): Promise>; + ( + func: (ctx: GenericActionCtx) => Promise, + ): Promise; + }; /** * Read from and write to the mock backend. @@ -1919,13 +1944,28 @@ function withAuth(auth: AuthFake = new AuthFake()) { const func = await getFunctionFromPath(functionPath, "action"); validateValidator(JSON.parse((func as any).exportArgs()), args ?? {}); + // Reference-only ctx.runQuery/runMutation/runAction that call byTypeWithPath directly + // These do NOT support inline functions, matching real Convex behavior + const ctxRunQuery = async (functionReference: any, queryArgs: any) => { + const refPath = await getFunctionPathFromReference(functionReference); + return await byTypeWithPath.queryFromPath(refPath, false, queryArgs); + }; + const ctxRunMutation = async (functionReference: any, mutArgs: any) => { + const refPath = await getFunctionPathFromReference(functionReference); + return await byTypeWithPath.mutationFromPath(refPath, false, mutArgs); + }; + const ctxRunAction = async (functionReference: any, actArgs: any) => { + const refPath = await getFunctionPathFromReference(functionReference); + return await byTypeWithPath.actionFromPath(refPath, actArgs); + }; + const a = actionGeneric({ handler: (ctx: any, a: any) => { const testCtx = { ...ctx, - runQuery: byType.query, - runMutation: byType.mutation, - runAction: byType.action, + runQuery: ctxRunQuery, + runMutation: ctxRunMutation, + runAction: ctxRunAction, auth, }; return getHandler(func)(testCtx, a); @@ -1947,10 +1987,107 @@ function withAuth(auth: AuthFake = new AuthFake()) { }, }; + // Helper to run a query with a given handler (shared by queryFromPath and inline queries) + const runQueryWithHandler = async ( + handler: (ctx: any) => T, + functionPath: FunctionPath, + args: any, + isNested: boolean, + ): Promise => { + const q = queryGeneric({ + handler: (ctx: any) => { + const testCtx = { ...ctx, auth }; + return handler(testCtx); + }, + }); + const transactionManager = getTransactionManager(); + await transactionManager.begin(functionPath, isNested); + try { + const rawResult = await ( + q as unknown as { invokeQuery: (args: string) => Promise } + ).invokeQuery(JSON.stringify(convexToJson([parseArgs(args)]))); + return jsonToConvex(JSON.parse(rawResult)) as T; + } finally { + transactionManager.rollback(isNested); + } + }; + + // Helper to run an action with a given handler (shared by actionFromPath and inline actions) + const runActionWithHandler = async ( + handler: (ctx: any) => T, + functionPath: FunctionPath, + args: any, + ctxRunQuery: any, + ctxRunMutation: any, + ctxRunAction: any, + ): Promise => { + const a = actionGeneric({ + handler: (ctx: any) => { + const testCtx = { + ...ctx, + runQuery: ctxRunQuery, + runMutation: ctxRunMutation, + runAction: ctxRunAction, + auth, + }; + return handler(testCtx); + }, + }); + getTransactionManager().beginAction(functionPath); + const requestId = "" + Math.random(); + const rawResult = await ( + a as unknown as { + invokeAction: (requestId: string, args: string) => Promise; + } + ).invokeAction(requestId, JSON.stringify(convexToJson([parseArgs(args)]))); + getTransactionManager().finishAction(); + return jsonToConvex(JSON.parse(rawResult)) as T; + }; + + // Reference-only versions for ctx.runQuery/runMutation/runAction inside actions + // These do NOT support inline functions, matching real Convex behavior + const refOnlyQuery = async (functionReference: any, args: any) => { + const functionPath = await getFunctionPathFromReference(functionReference); + return await byTypeWithPath.queryFromPath(functionPath, false, args); + }; + + const refOnlyMutation = async (functionReference: any, args: any) => { + const functionPath = await getFunctionPathFromReference(functionReference); + return await byTypeWithPath.mutationFromPath(functionPath, false, args); + }; + + const refOnlyAction = async (functionReference: any, args: any) => { + const functionPath = await getFunctionPathFromReference(functionReference); + return await byTypeWithPath.actionFromPath(functionPath, args); + }; + + const runInlineMutation = async ( + componentPath: string, + handler: (ctx: any) => T, + ): Promise => { + return await runTransaction( + handler, + {}, + {}, + { componentPath, udfPath: "inline" }, + false, + ); + }; + const byType = { - query: async (functionReference: any, args: any) => { - const functionPath = - await getFunctionPathFromReference(functionReference); + query: async (functionReferenceOrHandler: any, args?: any) => { + if (typeof functionReferenceOrHandler === "function") { + // Inline query handler + return await runQueryWithHandler( + functionReferenceOrHandler, + { componentPath: getCurrentComponentPath(), udfPath: "inline" }, + {}, + false, + ); + } + const functionPath = await getFunctionPathFromReference( + functionReferenceOrHandler, + ); return await byTypeWithPath.queryFromPath( functionPath, /* isNested */ false, @@ -1958,9 +2095,19 @@ function withAuth(auth: AuthFake = new AuthFake()) { ); }, - mutation: async (functionReference: any, args: any): Promise => { - const functionPath = - await getFunctionPathFromReference(functionReference); + mutation: async ( + functionReferenceOrHandler: any, + args?: any, + ): Promise => { + if (typeof functionReferenceOrHandler === "function") { + return await runInlineMutation( + getCurrentComponentPath(), + functionReferenceOrHandler, + ); + } + const functionPath = await getFunctionPathFromReference( + functionReferenceOrHandler, + ); return await byTypeWithPath.mutationFromPath( functionPath, /* isNested */ false, @@ -1968,9 +2115,22 @@ function withAuth(auth: AuthFake = new AuthFake()) { ); }, - action: async (functionReference: any, args: any) => { - const functionPath = - await getFunctionPathFromReference(functionReference); + action: async (functionReferenceOrHandler: any, args?: any) => { + if (typeof functionReferenceOrHandler === "function") { + // Inline action handler - use refOnly* for ctx.runQuery/runMutation/runAction + // to match real Convex behavior (no inline support inside actions) + return await runActionWithHandler( + functionReferenceOrHandler, + { componentPath: getCurrentComponentPath(), udfPath: "inline" }, + {}, + refOnlyQuery, + refOnlyMutation, + refOnlyAction, + ); + } + const functionPath = await getFunctionPathFromReference( + functionReferenceOrHandler, + ); return await byTypeWithPath.actionFromPath(functionPath, args); }, };