Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down
109 changes: 109 additions & 0 deletions convex/returnsValidation.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
142 changes: 142 additions & 0 deletions convex/returnsValidation.ts
Original file line number Diff line number Diff line change
@@ -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<any> => {
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 };
},
});
30 changes: 27 additions & 3 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -2369,27 +2371,31 @@ 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,
byTypeWithPath.runQuery,
byTypeWithPath.runMutation,
byTypeWithPath.runAction,
);
validateReturnValue(func, result, "action", functionPath);
return result;
},
runQuery: async (
functionReference: FunctionReference<any, any, any, any>,
Expand Down Expand Up @@ -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}`;
}
Expand Down
Loading