From b07c59ed7cb00249dce52af510dbb58e528bfa00 Mon Sep 17 00:00:00 2001 From: Lee Danilek Date: Thu, 2 Jan 2025 18:29:41 -0500 Subject: [PATCH 1/4] don't call convex functions directly --- index.ts | 59 ++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/index.ts b/index.ts index 4078b8f..b479c03 100644 --- a/index.ts +++ b/index.ts @@ -1,33 +1,39 @@ /// -import { getFunctionAddress } from "convex/server"; import { + actionGeneric, DataModelFromSchemaDefinition, + DefaultFunctionArgs, DocumentByName, FunctionReference, FunctionReturnType, + GenericActionCtx, GenericDataModel, GenericDocument, GenericMutationCtx, GenericSchema, + getFunctionAddress, HttpRouter, + httpActionGeneric, + makeFunctionReference, + mutationGeneric, OptionalRestArgs, + PublicHttpAction, + queryGeneric, + RegisteredAction, + RegisteredMutation, + RegisteredQuery, SchemaDefinition, StorageActionWriter, SystemDataModel, UserIdentity, - actionGeneric, - httpActionGeneric, - makeFunctionReference, - mutationGeneric, - queryGeneric, } from "convex/server"; import { + convexToJson, GenericId, + jsonToConvex, JSONValue, Value, - convexToJson, - jsonToConvex, } from "convex/values"; import { createHash } from "crypto"; import { compareValues } from "./compare.js"; @@ -1733,11 +1739,11 @@ function withAuth(auth: AuthFake = new AuthFake()) { const byTypeWithPath = { queryFromPath: async (functionPath: FunctionPath, isNested: boolean, args: any) => { const func = await getFunctionFromPath(functionPath, "query"); - validateValidator(JSON.parse(func.exportArgs()), args ?? {}); + validateValidator(JSON.parse((func as any).exportArgs()), args ?? {}); const q = queryGeneric({ handler: (ctx: any, a: any) => { const testCtx = { ...ctx, auth }; - return func(testCtx, a); + return getHandler(func)(testCtx, a); }, }); const transactionManager = getTransactionManager(); @@ -1758,14 +1764,14 @@ function withAuth(auth: AuthFake = new AuthFake()) { args: any, ): Promise => { const func = await getFunctionFromPath(functionPath, "mutation"); - validateValidator(JSON.parse(func.exportArgs()), args ?? {}); + validateValidator(JSON.parse((func as any).exportArgs()), args ?? {}); - return await runTransaction(func, args, {}, functionPath, isNested); + return await runTransaction(getHandler(func), args, {}, functionPath, isNested); }, actionFromPath: async (functionPath: FunctionPath, args: any) => { const func = await getFunctionFromPath(functionPath, "action"); - validateValidator(JSON.parse(func.exportArgs()), args ?? {}); + validateValidator(JSON.parse((func as any).exportArgs()), args ?? {}); const a = actionGeneric({ handler: (ctx: any, a: any) => { @@ -1776,7 +1782,7 @@ function withAuth(auth: AuthFake = new AuthFake()) { runAction: byType.action, auth, }; - return func(testCtx, a); + return getHandler(func)(testCtx, a); }, }); getTransactionManager().beginAction(functionPath); @@ -1880,8 +1886,7 @@ function withAuth(auth: AuthFake = new AuthFake()) { runAction: byType.action, auth, }; - // TODO: Remove `any`, it's needed because of a bug in Convex types - return func(testCtx, a) as any; + return getHandler(func)(testCtx, a); }); const response = await ( a as unknown as { @@ -1992,6 +1997,26 @@ async function getFunctionPathFromReference( return await getFunctionPathFromAddress(functionAddress); } +function getHandler( + func: RegisteredAction +): (ctx: GenericActionCtx, args: Args) => Promise; +function getHandler( + func: RegisteredMutation +): (ctx: GenericActionCtx, args: Args) => Promise; +function getHandler( + func: RegisteredQuery +): (ctx: GenericActionCtx, args: Args) => Promise; +function getHandler( + func: PublicHttpAction +): (ctx: GenericActionCtx, args: Request) => Promise; +function getHandler(func: any): (ctx: any, args: any) => any { + return '_handler' in func ? func["_handler"] : func; +} + +async function getFunctionFromPath(functionPath: FunctionPath, type: "query"): Promise>; +async function getFunctionFromPath(functionPath: FunctionPath, type: "mutation"): Promise>; +async function getFunctionFromPath(functionPath: FunctionPath, type: "action"): Promise>; +async function getFunctionFromPath(functionPath: FunctionPath, type: "any"): Promise; async function getFunctionFromPath( functionPath: FunctionPath, type: "query" | "mutation" | "action" | "any", @@ -2011,7 +2036,7 @@ async function getFunctionFromPath( `Expected a Convex function exported from module "${modulePath}" as \`${exportName}\`, but there is no such export.`, ); } - if (typeof func !== "function") { + if (typeof getHandler(func) !== "function") { throw new Error( `Expected a Convex function exported from module "${modulePath}" as \`${exportName}\`, but got: ${func}`, ); From 754286f363860beb5c0a76cdf6a37f16895f0ce4 Mon Sep 17 00:00:00 2001 From: Lee Danilek Date: Thu, 2 Jan 2025 18:31:28 -0500 Subject: [PATCH 2/4] . --- index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/index.ts b/index.ts index ed3d0f4..99c6a22 100644 --- a/index.ts +++ b/index.ts @@ -1,7 +1,6 @@ /// import { - actionGeneric, DataModelFromSchemaDefinition, DefaultFunctionArgs, DocumentByName, @@ -12,14 +11,9 @@ import { GenericDocument, GenericMutationCtx, GenericSchema, - getFunctionAddress, HttpRouter, - httpActionGeneric, - makeFunctionReference, - mutationGeneric, OptionalRestArgs, PublicHttpAction, - queryGeneric, RegisteredAction, RegisteredMutation, RegisteredQuery, @@ -27,6 +21,12 @@ import { StorageActionWriter, SystemDataModel, UserIdentity, + actionGeneric, + getFunctionAddress, + httpActionGeneric, + makeFunctionReference, + mutationGeneric, + queryGeneric, } from "convex/server"; import { convexToJson, From 0ba6e722d65defa742c0cf896a7e817d0dcd4525 Mon Sep 17 00:00:00 2001 From: Lee Danilek Date: Thu, 2 Jan 2025 18:35:55 -0500 Subject: [PATCH 3/4] . --- index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.ts b/index.ts index 99c6a22..a705876 100644 --- a/index.ts +++ b/index.ts @@ -29,11 +29,11 @@ import { queryGeneric, } from "convex/server"; import { - convexToJson, GenericId, - jsonToConvex, JSONValue, Value, + convexToJson, + jsonToConvex, } from "convex/values"; import { createHash } from "crypto"; import { compareValues } from "./compare.js"; From d2c370989a07c1663ae3de118ae85785c84c6ee5 Mon Sep 17 00:00:00 2001 From: Lee Danilek Date: Fri, 3 Jan 2025 12:08:24 -0500 Subject: [PATCH 4/4] use mapped types (#34) Co-authored-by: Sarah Shader --- index.ts | 195 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 122 insertions(+), 73 deletions(-) diff --git a/index.ts b/index.ts index a705876..27bb69f 100644 --- a/index.ts +++ b/index.ts @@ -130,9 +130,7 @@ class DatabaseFake { // - When the top-level mutation commits, the writes are applied to the // database. // - When a mutation is rolled back, last level of writes is discarded. - private _writes: Array< - Record - > = []; + private _writes: Array> = []; private _schema: { schemaValidation: boolean; @@ -212,10 +210,7 @@ class DatabaseFake { return this._storage[storageId]; } - private _addWrite( - id: DocumentId, - newValue: StoredDocument | null, - ) { + private _addWrite(id: DocumentId, newValue: StoredDocument | null) { if (this._writes.length === 0) { throw new Error(`Write outside of transaction ${id}`); } @@ -620,16 +615,20 @@ function evaluateFilter( filter: any, ): Value | undefined { if (filter.$eq !== undefined) { - return compareValues( - evaluateFilter(document, filter.$eq[0]), - evaluateFilter(document, filter.$eq[1]) - ) === 0; + return ( + compareValues( + evaluateFilter(document, filter.$eq[0]), + evaluateFilter(document, filter.$eq[1]), + ) === 0 + ); } if (filter.$neq !== undefined) { - return compareValues( - evaluateFilter(document, filter.$neq[0]), - evaluateFilter(document, filter.$neq[1]) - ) !== 0; + return ( + compareValues( + evaluateFilter(document, filter.$neq[0]), + evaluateFilter(document, filter.$neq[1]), + ) !== 0 + ); } if (filter.$and !== undefined) { return filter.$and.every((child: any) => evaluateFilter(document, child)); @@ -1109,13 +1108,23 @@ function asyncSyscallImpl() { }); if (udfType === "query") { return JSON.stringify( - convexToJson(await withAuth().queryFromPath(functionPath, /* isNested */ true, udfArgs)), + convexToJson( + await withAuth().queryFromPath( + functionPath, + /* isNested */ true, + udfArgs, + ), + ), ); } if (udfType === "mutation") { return JSON.stringify( convexToJson( - await withAuth().mutationFromPath(functionPath, /* isNested */ true, udfArgs), + await withAuth().mutationFromPath( + functionPath, + /* isNested */ true, + udfArgs, + ), ), ); } @@ -1124,11 +1133,7 @@ function asyncSyscallImpl() { ); } case "1.0/createFunctionHandle": { - const { - name, - reference, - functionHandle, - } = args; + const { name, reference, functionHandle } = args; const functionPath = await getFunctionPathFromAddress({ name, reference, @@ -1165,19 +1170,22 @@ function asyncSyscallImpl() { const componentPath = getCurrentComponentPath(); setTimeout( (async () => { - const canceled = await withAuth().runInComponent(componentPath, async () => { - const job = db.get(jobId) as ScheduledFunction; - if (job.state.kind === "canceled") { - return true; - } - if (job.state.kind !== "pending") { - throw new Error( - `\`convexTest\` invariant error: Unexpected scheduled function state when starting it: ${job.state.kind}`, - ); - } - db.patch(jobId, { state: { kind: "inProgress" } }); - return false; - }); + const canceled = await withAuth().runInComponent( + componentPath, + async () => { + const job = db.get(jobId) as ScheduledFunction; + if (job.state.kind === "canceled") { + return true; + } + if (job.state.kind !== "pending") { + throw new Error( + `\`convexTest\` invariant error: Unexpected scheduled function state when starting it: ${job.state.kind}`, + ); + } + db.patch(jobId, { state: { kind: "inProgress" } }); + return false; + }, + ); if (canceled) { return; } @@ -1321,11 +1329,14 @@ async function blobSha(blob: Blob) { async function waitForInProgressScheduledFunctions(): Promise { let hadScheduledFunctions = false; for (const componentPath of Object.keys(getConvexGlobal().components)) { - const inProgressJobs = (await withAuth().runInComponent(componentPath, async (ctx) => { - return (await ctx.db.system.query("_scheduled_functions").collect()).filter( - (job: ScheduledFunction) => job.state.kind === "inProgress", - ); - })) as ScheduledFunction[]; + const inProgressJobs = (await withAuth().runInComponent( + componentPath, + async (ctx) => { + return ( + await ctx.db.system.query("_scheduled_functions").collect() + ).filter((job: ScheduledFunction) => job.state.kind === "inProgress"); + }, + )) as ScheduledFunction[]; let numRemaining = inProgressJobs.length; if (numRemaining === 0) { continue; @@ -1461,7 +1472,9 @@ export type TestConvexForDataModelAndIdentity< function getComponentInfo(componentPath: string): ComponentInfo { const convex = getConvexGlobal(); if (convex.components[componentPath] === undefined) { - throw new Error(`Component "${componentPath}" is not registered. Call "t.registerComponent".`); + throw new Error( + `Component "${componentPath}" is not registered. Call "t.registerComponent".`, + ); } return convex.components[componentPath]; } @@ -1480,8 +1493,7 @@ function getTransactionManager() { function getCurrentComponentPath() { const functionStack = getTransactionManager().functionStack; - const currentFunctionPath = - functionStack[functionStack.length - 1]; + const currentFunctionPath = functionStack[functionStack.length - 1]; return currentFunctionPath?.componentPath ?? ROOT_COMPONENT_PATH; } @@ -1584,10 +1596,7 @@ class TransactionManager { private _markTransactionDone: (() => void) | null = null; public functionStack: FunctionPath[] = []; - async begin( - functionPath: FunctionPath, - isNested: boolean, - ) { + async begin(functionPath: FunctionPath, isNested: boolean) { // Take a lock only for the top-level of each transaction. // Nested transactions are not isolated so if you `Promise.all` on multiple // `ctx.runMutation` or `ctx.runQuery` calls, they won't be serialized. @@ -1738,7 +1747,11 @@ function withAuth(auth: AuthFake = new AuthFake()) { }; const byTypeWithPath = { - queryFromPath: async (functionPath: FunctionPath, isNested: boolean, args: any) => { + queryFromPath: async ( + functionPath: FunctionPath, + isNested: boolean, + args: any, + ) => { const func = await getFunctionFromPath(functionPath, "query"); validateValidator(JSON.parse((func as any).exportArgs()), args ?? {}); const q = queryGeneric({ @@ -1767,7 +1780,13 @@ function withAuth(auth: AuthFake = new AuthFake()) { const func = await getFunctionFromPath(functionPath, "mutation"); validateValidator(JSON.parse((func as any).exportArgs()), args ?? {}); - return await runTransaction(getHandler(func), args, {}, functionPath, isNested); + return await runTransaction( + getHandler(func), + args, + {}, + functionPath, + isNested, + ); }, actionFromPath: async (functionPath: FunctionPath, args: any) => { @@ -1804,21 +1823,35 @@ function withAuth(auth: AuthFake = new AuthFake()) { const byType = { query: async (functionReference: any, args: any) => { - const functionPath = await getFunctionPathFromReference(functionReference); - return await byTypeWithPath.queryFromPath(functionPath, /* isNested */ false, args); + const functionPath = + await getFunctionPathFromReference(functionReference); + return await byTypeWithPath.queryFromPath( + functionPath, + /* isNested */ false, + args, + ); }, mutation: async (functionReference: any, args: any): Promise => { - const functionPath = await getFunctionPathFromReference(functionReference); - return await byTypeWithPath.mutationFromPath(functionPath, /* isNested */ false, args); + const functionPath = + await getFunctionPathFromReference(functionReference); + return await byTypeWithPath.mutationFromPath( + functionPath, + /* isNested */ false, + args, + ); }, action: async (functionReference: any, args: any) => { - const functionPath = await getFunctionPathFromReference(functionReference); + const functionPath = + await getFunctionPathFromReference(functionReference); return await byTypeWithPath.actionFromPath(functionPath, args); }, }; - const run = async (componentPath: string, handler: (ctx: any) => T): Promise => { + const run = async ( + componentPath: string, + handler: (ctx: any) => T, + ): Promise => { // Grab StorageActionWriter from action ctx const a = actionGeneric({ handler: async ({ storage }: any) => { @@ -1856,10 +1889,18 @@ function withAuth(auth: AuthFake = new AuthFake()) { fun: async (functionPath: FunctionPath, args: any) => { const func = await getFunctionFromPath(functionPath, "any"); if (func.isQuery) { - return await byTypeWithPath.queryFromPath(functionPath, /* isNested */ false, args); + return await byTypeWithPath.queryFromPath( + functionPath, + /* isNested */ false, + args, + ); } if (func.isMutation) { - return await byTypeWithPath.mutationFromPath(functionPath, /* isNested */ false, args); + return await byTypeWithPath.mutationFromPath( + functionPath, + /* isNested */ false, + args, + ); } if (func.isAction) { return await byTypeWithPath.actionFromPath(functionPath, args); @@ -1913,14 +1954,17 @@ function withAuth(auth: AuthFake = new AuthFake()) { // Stop after a fixed number of iterations to avoid infinite loops. for (let i = 0; i < maxIterations; i++) { advanceTimers(); - const hadScheduledFunctions = await waitForInProgressScheduledFunctions(); + const hadScheduledFunctions = + await waitForInProgressScheduledFunctions(); if (!hadScheduledFunctions) { return; } } - throw new Error("finishAllScheduledFunctions: too many iterations. " - + "Check for infinitely recursive scheduled functions, " - + "or increase maxIterations."); + throw new Error( + "finishAllScheduledFunctions: too many iterations. " + + "Check for infinitely recursive scheduled functions, " + + "or increase maxIterations.", + ); }, }; } @@ -1998,30 +2042,35 @@ async function getFunctionPathFromReference( return await getFunctionPathFromAddress(functionAddress); } +type RegisteredFunctions = { + query: RegisteredQuery; + mutation: RegisteredMutation; + action: RegisteredAction; + any: any; +}; + +type RegisteredFunctionKind = keyof RegisteredFunctions; + function getHandler( - func: RegisteredAction + func: RegisteredAction, ): (ctx: GenericActionCtx, args: Args) => Promise; function getHandler( - func: RegisteredMutation + func: RegisteredMutation, ): (ctx: GenericActionCtx, args: Args) => Promise; function getHandler( - func: RegisteredQuery + func: RegisteredQuery, ): (ctx: GenericActionCtx, args: Args) => Promise; function getHandler( - func: PublicHttpAction + func: PublicHttpAction, ): (ctx: GenericActionCtx, args: Request) => Promise; function getHandler(func: any): (ctx: any, args: any) => any { - return '_handler' in func ? func["_handler"] : func; + return "_handler" in func ? func["_handler"] : func; } -async function getFunctionFromPath(functionPath: FunctionPath, type: "query"): Promise>; -async function getFunctionFromPath(functionPath: FunctionPath, type: "mutation"): Promise>; -async function getFunctionFromPath(functionPath: FunctionPath, type: "action"): Promise>; -async function getFunctionFromPath(functionPath: FunctionPath, type: "any"): Promise; -async function getFunctionFromPath( +async function getFunctionFromPath( functionPath: FunctionPath, - type: "query" | "mutation" | "action" | "any", -) { + type: T, +): Promise { // "queries/messages:list" -> ["queries/messages", "list"] const [modulePath, maybeExportName] = functionPath.udfPath.split(":"); const exportName =