diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 0b6416a..d182901 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -19,6 +19,7 @@ import type * as messages from "../messages.js"; import type * as mutations from "../mutations.js"; import type * as pagination from "../pagination.js"; import type * as queries from "../queries.js"; +import type * as returnsValidation from "../returnsValidation.js"; import type * as scheduler from "../scheduler.js"; import type * as storage from "../storage.js"; import type * as textSearch from "../textSearch.js"; @@ -42,6 +43,7 @@ declare const fullApi: ApiFromModules<{ mutations: typeof mutations; pagination: typeof pagination; queries: typeof queries; + returnsValidation: typeof returnsValidation; scheduler: typeof scheduler; storage: typeof storage; textSearch: typeof textSearch; diff --git a/convex/returnsValidation.test.ts b/convex/returnsValidation.test.ts new file mode 100644 index 0000000..b237dc5 --- /dev/null +++ b/convex/returnsValidation.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from "vitest"; +import { convexTest } from "../index"; +import { api } from "./_generated/api"; +import schema from "./schema"; + +test("query with incomplete return validator should fail", async () => { + const t = convexTest(schema); + + await t.run(async (ctx) => { + await ctx.db.insert("messages", { author: "Alice", body: "Hello" }); + }); + + await expect( + t.query(api.returnsValidation.queryWithIncompleteReturnValidator), + ).rejects.toThrowError(/Return value validation failed/); +}); + +test("query with correct return validator should pass", async () => { + const t = convexTest(schema); + + await t.run(async (ctx) => { + await ctx.db.insert("messages", { author: "Bob", body: "World" }); + }); + + const result = await t.query( + api.returnsValidation.queryWithCorrectReturnValidator, + ); + expect(result).not.toBeNull(); + expect(result?.author).toBe("Bob"); + expect(result?._creationTime).toBeDefined(); +}); + +test("query returning correct primitive type should pass", async () => { + const t = convexTest(schema); + const result = await t.query(api.returnsValidation.queryReturningNumber); + expect(result).toBe(42); +}); + +test("query returning wrong primitive type should fail", async () => { + const t = convexTest(schema); + await expect( + t.query(api.returnsValidation.queryReturningWrongType), + ).rejects.toThrowError(/Return value validation failed/); +}); + +test("mutation with incomplete return validator should fail", async () => { + const t = convexTest(schema); + + await expect( + t.mutation(api.returnsValidation.mutationWithIncompleteReturnValidator, { + author: "Charlie", + body: "Test", + }), + ).rejects.toThrowError(/Return value validation failed/); +}); + +test("mutation with correct return validator should pass", async () => { + const t = convexTest(schema); + + const result = await t.mutation( + api.returnsValidation.mutationWithCorrectReturnValidator, + { author: "Diana", body: "Test" }, + ); + expect(result).not.toBeNull(); + expect(result.author).toBe("Diana"); + expect(result._creationTime).toBeDefined(); +}); + +test("query without return validator should pass", async () => { + const t = convexTest(schema); + + await t.run(async (ctx) => { + await ctx.db.insert("messages", { author: "Eve", body: "No validator" }); + }); + + const result = await t.query( + api.returnsValidation.queryWithoutReturnValidator, + ); + expect(result).not.toBeNull(); +}); + +test("query returning array with incomplete item validator should fail", async () => { + const t = convexTest(schema); + + await t.run(async (ctx) => { + await ctx.db.insert("messages", { author: "Frank", body: "Array test" }); + }); + + await expect( + t.query(api.returnsValidation.queryReturningArrayWithIncompleteValidator), + ).rejects.toThrowError(/Return value validation failed/); +}); + +test("action with incomplete return validator should fail", async () => { + const t = convexTest(schema); + + await expect( + t.action(api.returnsValidation.actionWithIncompleteReturnValidator), + ).rejects.toThrowError(/Return value validation failed/); +}); + +test("action with correct return validator should pass", async () => { + const t = convexTest(schema); + + const result = await t.action( + api.returnsValidation.actionWithCorrectReturnValidator, + ); + expect(result).toEqual({ name: "test", value: 42 }); +}); diff --git a/convex/returnsValidation.ts b/convex/returnsValidation.ts new file mode 100644 index 0000000..c44234d --- /dev/null +++ b/convex/returnsValidation.ts @@ -0,0 +1,142 @@ +import { v } from "convex/values"; +import { action, mutation, query } from "./_generated/server"; + +// Query that returns a document but the return validator is INCOMPLETE +// (missing _creationTime which Convex auto-adds to all documents) +export const queryWithIncompleteReturnValidator = query({ + args: {}, + returns: v.union( + v.null(), + v.object({ + _id: v.id("messages"), + author: v.string(), + body: v.string(), + embedding: v.optional(v.array(v.number())), + score: v.optional(v.number()), + // _creationTime: v.number(), <-- INTENTIONALLY MISSING + }), + ), + handler: async (ctx) => { + return await ctx.db.query("messages").first(); + }, +}); + +// Query with correct return validator (includes _creationTime) +export const queryWithCorrectReturnValidator = query({ + args: {}, + returns: v.union( + v.null(), + v.object({ + _id: v.id("messages"), + _creationTime: v.number(), + author: v.string(), + body: v.string(), + embedding: v.optional(v.array(v.number())), + score: v.optional(v.number()), + }), + ), + handler: async (ctx) => { + return await ctx.db.query("messages").first(); + }, +}); + +// Query returning primitive +export const queryReturningNumber = query({ + args: {}, + returns: v.number(), + handler: async () => { + return 42; + }, +}); + +// Query with wrong primitive type +export const queryReturningWrongType = query({ + args: {}, + returns: v.string(), + handler: async (): Promise => { + return 42; + }, +}); + +// Mutation with incomplete return validator +export const mutationWithIncompleteReturnValidator = mutation({ + args: { author: v.string(), body: v.string() }, + returns: v.object({ + _id: v.id("messages"), + author: v.string(), + body: v.string(), + embedding: v.optional(v.array(v.number())), + score: v.optional(v.number()), + // _creationTime: v.number(), <-- INTENTIONALLY MISSING + }), + handler: async (ctx, args) => { + const id = await ctx.db.insert("messages", args); + return (await ctx.db.get(id))!; + }, +}); + +// Mutation with correct return validator +export const mutationWithCorrectReturnValidator = mutation({ + args: { author: v.string(), body: v.string() }, + returns: v.object({ + _id: v.id("messages"), + _creationTime: v.number(), + author: v.string(), + body: v.string(), + embedding: v.optional(v.array(v.number())), + score: v.optional(v.number()), + }), + handler: async (ctx, args) => { + const id = await ctx.db.insert("messages", args); + return (await ctx.db.get(id))!; + }, +}); + +// Query without return validator (should pass - no validation) +export const queryWithoutReturnValidator = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query("messages").first(); + }, +}); + +// Query returning array with incomplete item validator +export const queryReturningArrayWithIncompleteValidator = query({ + args: {}, + returns: v.array( + v.object({ + _id: v.id("messages"), + author: v.string(), + body: v.string(), + embedding: v.optional(v.array(v.number())), + score: v.optional(v.number()), + // Missing _creationTime + }), + ), + handler: async (ctx) => { + return await ctx.db.query("messages").collect(); + }, +}); + +// Action with incomplete return validator +export const actionWithIncompleteReturnValidator = action({ + args: {}, + returns: v.object({ + name: v.string(), + }), + handler: async () => { + return { name: "test", extraField: "unexpected" } as { name: string }; + }, +}); + +// Action with correct return validator +export const actionWithCorrectReturnValidator = action({ + args: {}, + returns: v.object({ + name: v.string(), + value: v.number(), + }), + handler: async () => { + return { name: "test", value: 42 }; + }, +}); diff --git a/index.ts b/index.ts index aa0e05f..2580bf0 100644 --- a/index.ts +++ b/index.ts @@ -2353,12 +2353,14 @@ function withAuth(auth: AuthFake = new AuthFake()) { const func = await getFunctionFromPath(functionPath, "query"); validateValidator(JSON.parse((func as any).exportArgs()), args ?? {}); - return await runQueryWithHandler( + const result = await runQueryWithHandler( (ctx, a) => getHandler(func)(ctx, a), args, functionPath, isNested, ); + validateReturnValue(func, result, "query", functionPath); + return result; }, mutationFromPath: async ( @@ -2369,20 +2371,22 @@ function withAuth(auth: AuthFake = new AuthFake()) { const func = await getFunctionFromPath(functionPath, "mutation"); validateValidator(JSON.parse((func as any).exportArgs()), args ?? {}); - return await runMutationWithHandler( + const result = await runMutationWithHandler( getHandler(func), args, {}, functionPath, isNested, ); + validateReturnValue(func, result, "mutation", functionPath); + return result; }, actionFromPath: async (functionPath: FunctionPath, args: any) => { const func = await getFunctionFromPath(functionPath, "action"); validateValidator(JSON.parse((func as any).exportArgs()), args ?? {}); - return await runActionWithHandler( + const result = await runActionWithHandler( getHandler(func), args, functionPath, @@ -2390,6 +2394,8 @@ function withAuth(auth: AuthFake = new AuthFake()) { byTypeWithPath.runMutation, byTypeWithPath.runAction, ); + validateReturnValue(func, result, "action", functionPath); + return result; }, runQuery: async ( functionReference: FunctionReference, @@ -2616,6 +2622,24 @@ function parseArgs( return args; } +function validateReturnValue( + func: any, + result: any, + functionType: "query" | "mutation" | "action", + functionPath: FunctionPath, +) { + const returnsValidator = JSON.parse(func.exportReturns()); + if (returnsValidator !== null) { + try { + validateValidator(returnsValidator, result); + } catch (e) { + throw new Error( + `Return value validation failed for ${functionType} "${functionPath.udfPath}":\n${(e as Error).message}`, + ); + } + } +} + function createFunctionHandle(functionPath: FunctionPath) { return `function://${functionPath.componentPath};${functionPath.udfPath}`; }