From d1821a4a84a837054971058fbe0eae7233512a0f Mon Sep 17 00:00:00 2001 From: Ian Macartney <366683+ianmacartney@users.noreply.github.com> Date: Mon, 4 May 2026 18:02:47 -0700 Subject: [PATCH 1/2] add tests for round-tripping context --- example/convex/_generated/api.d.ts | 2 + example/convex/test/contextRoundtrip.test.ts | 157 +++++++++++++++++++ example/convex/test/contextRoundtrip.ts | 35 +++++ 3 files changed, 194 insertions(+) create mode 100644 example/convex/test/contextRoundtrip.test.ts create mode 100644 example/convex/test/contextRoundtrip.ts diff --git a/example/convex/_generated/api.d.ts b/example/convex/_generated/api.d.ts index 3c3163a..b97fe43 100644 --- a/example/convex/_generated/api.d.ts +++ b/example/convex/_generated/api.d.ts @@ -16,6 +16,7 @@ import type * as inlineTest from "../inlineTest.js"; import type * as nestedWorkflow from "../nestedWorkflow.js"; import type * as oversized from "../oversized.js"; import type * as passingSignals from "../passingSignals.js"; +import type * as test_contextRoundtrip from "../test/contextRoundtrip.js"; import type * as test_oldSyntax from "../test/oldSyntax.js"; import type * as transcription from "../transcription.js"; import type * as userConfirmation from "../userConfirmation.js"; @@ -35,6 +36,7 @@ declare const fullApi: ApiFromModules<{ nestedWorkflow: typeof nestedWorkflow; oversized: typeof oversized; passingSignals: typeof passingSignals; + "test/contextRoundtrip": typeof test_contextRoundtrip; "test/oldSyntax": typeof test_oldSyntax; transcription: typeof transcription; userConfirmation: typeof userConfirmation; diff --git a/example/convex/test/contextRoundtrip.test.ts b/example/convex/test/contextRoundtrip.test.ts new file mode 100644 index 0000000..176b6ec --- /dev/null +++ b/example/convex/test/contextRoundtrip.test.ts @@ -0,0 +1,157 @@ +/// + +import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; +import { createFunctionHandle } from "convex/server"; +import { assert } from "convex-helpers"; +import { workflow } from "../example"; +import { internal } from "../_generated/api"; +import { initConvexTest } from "../setup.test"; + +describe("context round-trips through failure paths", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + test("[1] direct call + handler throws", async () => { + const t = initConvexTest(); + const ctxValue = { case: "directThrow", marker: 12345 }; + const workflowId = await t.mutation(async (ctx) => { + const onCompleteHandle = await createFunctionHandle( + internal.test.contextRoundtrip.captureOnComplete, + ); + const wfId = await ctx.runMutation( + internal.test.contextRoundtrip.throwingWorkflow, + { + args: {}, + onComplete: onCompleteHandle, + context: ctxValue, + startAsync: true, + }, + ); + await ctx.db.insert("flows", { + workflowId: wfId, + in: "directThrow", + out: null, + }); + return wfId; + }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + + const flow = await t.query(async (ctx) => + ctx.db + .query("flows") + .withIndex("workflowId", (q) => q.eq("workflowId", workflowId)) + .first(), + ); + assert(flow); + expect(flow.out?.result?.kind).toBe("failed"); + expect(flow.out?.capturedContext).toEqual(ctxValue); + }); + + test("[2] start() + handler throws", async () => { + const t = initConvexTest(); + const ctxValue = { case: "startThrow", marker: 23456 }; + const workflowId = await t.mutation(async (ctx) => { + const wfId = await workflow.start( + ctx, + internal.test.contextRoundtrip.throwingWorkflow, + {}, + { + onComplete: internal.test.contextRoundtrip.captureOnComplete, + context: ctxValue, + startAsync: true, + }, + ); + await ctx.db.insert("flows", { + workflowId: wfId, + in: "startThrow", + out: null, + }); + return wfId; + }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + + const flow = await t.query(async (ctx) => + ctx.db + .query("flows") + .withIndex("workflowId", (q) => q.eq("workflowId", workflowId)) + .first(), + ); + assert(flow); + expect(flow.out?.result?.kind).toBe("failed"); + expect(flow.out?.capturedContext).toEqual(ctxValue); + }); + + test("[3] direct call + oversized return", async () => { + const t = initConvexTest(); + const ctxValue = { case: "directOversized", marker: 34567 }; + const workflowId = await t.mutation(async (ctx) => { + const onCompleteHandle = await createFunctionHandle( + internal.test.contextRoundtrip.captureOnComplete, + ); + const wfId = await ctx.runMutation( + internal.oversized.largeReturnWorkflow, + { + args: {}, + onComplete: onCompleteHandle, + context: ctxValue, + startAsync: true, + }, + ); + await ctx.db.insert("flows", { + workflowId: wfId, + in: "directOversized", + out: null, + }); + return wfId; + }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + + const flow = await t.query(async (ctx) => + ctx.db + .query("flows") + .withIndex("workflowId", (q) => q.eq("workflowId", workflowId)) + .first(), + ); + assert(flow); + expect(flow.out?.result?.kind).toBe("failed"); + expect(flow.out?.capturedContext).toEqual(ctxValue); + }); + + test("[4] start() + oversized return", async () => { + const t = initConvexTest(); + const ctxValue = { case: "startOversized", marker: 45678 }; + const workflowId = await t.mutation(async (ctx) => { + const wfId = await workflow.start( + ctx, + internal.oversized.largeReturnWorkflow, + {}, + { + onComplete: internal.test.contextRoundtrip.captureOnComplete, + context: ctxValue, + startAsync: true, + }, + ); + await ctx.db.insert("flows", { + workflowId: wfId, + in: "startOversized", + out: null, + }); + return wfId; + }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + + const flow = await t.query(async (ctx) => + ctx.db + .query("flows") + .withIndex("workflowId", (q) => q.eq("workflowId", workflowId)) + .first(), + ); + assert(flow); + expect(flow.out?.result?.kind).toBe("failed"); + expect(flow.out?.capturedContext).toEqual(ctxValue); + }); +}); diff --git a/example/convex/test/contextRoundtrip.ts b/example/convex/test/contextRoundtrip.ts new file mode 100644 index 0000000..45816de --- /dev/null +++ b/example/convex/test/contextRoundtrip.ts @@ -0,0 +1,35 @@ +import { v } from "convex/values"; +import { vWorkflowId } from "@convex-dev/workflow"; +import { vResultValidator } from "@convex-dev/workpool"; +import { internalMutation } from "../_generated/server.js"; +import { workflow } from "../example.js"; + +export const throwingWorkflow = workflow + .define({ + args: {}, + }) + .handler(async () => { + throw new Error("intentional failure"); + }); + +// onComplete that captures both the result and the received context, so tests +// can verify the context round-trips through every failure path. +export const captureOnComplete = internalMutation({ + args: { + workflowId: vWorkflowId, + result: vResultValidator, + context: v.any(), + }, + returns: v.null(), + handler: async (ctx, args) => { + const flow = await ctx.db + .query("flows") + .withIndex("workflowId", (q) => q.eq("workflowId", args.workflowId)) + .first(); + if (!flow) return null; + await ctx.db.patch("flows", flow._id, { + out: { result: args.result, capturedContext: args.context }, + }); + return null; + }, +}); From 2de18bcdcb5e562a2ff81d95441a64ff0839b301 Mon Sep 17 00:00:00 2001 From: Ian Macartney <366683+ianmacartney@users.noreply.github.com> Date: Mon, 4 May 2026 17:50:09 -0700 Subject: [PATCH 2/2] make onComplete & context type-safe --- src/client/index.ts | 67 +++++++++++++++++++--------------- src/client/workflowMutation.ts | 27 +++++++++----- 2 files changed, 54 insertions(+), 40 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 3873d35..95e9fbf 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -44,36 +44,43 @@ export type { RunOptions, WorkflowCtx } from "./workflowContext.js"; export type { WorkflowArgs } from "./workflowMutation.js"; export { vResultValidator } from "@convex-dev/workpool"; -export type CallbackOptions = { - /** - * A mutation to run after the function succeeds, fails, or is canceled. - * The context type is for your use, feel free to provide a validator for it. - * e.g. - * ```ts - * export const completion = internalMutation({ - * args: { - * workflowId: vWorkflowId, - * result: vResultValidator, - * context: v.any(), - * }, - * handler: async (ctx, args) => { - * console.log(args.result, "Got Context back -> ", args.context, Date.now() - args.context); - * }, - * }); - * ``` - */ - onComplete?: FunctionReference< - "mutation", - FunctionVisibility, - OnCompleteArgs - > | null; - - /** - * A context object to pass to the `onComplete` mutation. - * Useful for passing data from the enqueue site to the onComplete site. - */ - context?: Context; -}; +export type CallbackOptions = + | { + /** + * A mutation to run after the workflow succeeds, fails, or is canceled. + * The context type is for your use, feel free to provide a validator for it. + * + * If you don't need `context`, you can set the validator to optional + * with `v.optional(v.any())` and pass `context: undefined`. + * + * ```ts + * export const completion = internalMutation({ + * args: { + * workflowId: vWorkflowId, + * result: vResultValidator, + * context: v.optional(v.any()), + * }, + * handler: async (ctx, args) => { + * console.log(args.result, "Got Context back -> ", args.context); + * }, + * }); + * ``` + */ + onComplete: FunctionReference< + "mutation", + FunctionVisibility, + OnCompleteArgs + >; + /** + * A context object to pass to the `onComplete` mutation. + * Useful for passing data from the enqueue site to the onComplete site. + */ + context: Context; + } + | { + onComplete?: undefined; + context?: undefined; + }; export type WorkflowDefinition< ArgsValidator extends PropertyValidators, diff --git a/src/client/workflowMutation.ts b/src/client/workflowMutation.ts index e8ec29f..44b09c8 100644 --- a/src/client/workflowMutation.ts +++ b/src/client/workflowMutation.ts @@ -36,16 +36,23 @@ export type WorkflowArgs = { * current transaction. */ startAsync?: boolean; - /** - * A function handle (created with createFunctionHandle) that will be called - * when the Workflow completes. - */ - onComplete?: FunctionHandle<"mutation", OnCompleteArgs>; - /** - * Any extra context to pass to the Workflow. - */ - context?: Context; -}; +} & ( + | { + /** + * A function handle (created with createFunctionHandle) that will be + * called when the Workflow completes. + */ + onComplete: FunctionHandle<"mutation", OnCompleteArgs>; + /** + * Context forwarded to the `onComplete` mutation. + */ + context: Context; + } + | { + onComplete?: undefined; + context?: undefined; + } +); const vWorkflowArgs = v.union( v.object({ workflowId: vWorkflowId,