@convex-dev/workflow
Version:
Convex component for durably executing workflows.
283 lines (266 loc) • 8.38 kB
text/typescript
import type { RetryOption, RunResult } from "@convex-dev/workpool";
import { BaseChannel } from "async-channel";
import { parse } from "convex-helpers/validators";
import type {
ArgsAndOptions,
FunctionArgs,
FunctionReference,
FunctionReturnType,
FunctionType,
FunctionVisibility,
} from "convex/server";
import type { Validator } from "convex/values";
import type { EventId, SchedulerOptions, WorkflowId } from "../types.js";
import { safeFunctionName } from "./safeFunctionName.js";
import type { StepRequest } from "./step.js";
import type { TransactionLimits } from "./types.js";
export type RunOptions = {
/**
* The name of the function. By default, if you pass in api.foo.bar.baz,
* it will use "foo/bar:baz" as the name. If you pass in a function handle,
* it will use the function handle directly.
*/
name?: string;
/**
* If true, the journal will not validate that the arguments match on replay.
* This is useful when arguments are non-deterministic (e.g. derived from
* a stack trace caught in the workflow) and you want to allow the workflow to
* replay successfully despite argument changes.
*/
unstableArgs?: boolean;
} & SchedulerOptions;
type InlineArgs =
| {
inline: true;
runAt?: never;
runAfter?: never;
/**
* Per-transaction resource limits enforced on this inline step's
* transaction. Exceeding a limit throws a catchable error in the
* workflow handler.
*
* **Requires Convex >= 1.41.** Only supported for `inline` steps.
*/
transactionLimits?: TransactionLimits;
}
| {
inline?: false;
/** @deprecated `transactionLimits` is only supported when `inline` is true. */
transactionLimits?: TransactionLimits;
};
export type WorkflowCtx = {
/**
* The ID of the workflow currently running.
*/
workflowId: WorkflowId;
/**
* Run a query with the given name and arguments.
*
* @param query - The query to run, like `internal.index.exampleQuery`.
* @param args - The arguments to the query function.
* @param opts - Options for scheduling and naming the query.
*/
runQuery<Query extends FunctionReference<"query", FunctionVisibility>>(
query: Query,
...args: ArgsAndOptions<Query, RunOptions & InlineArgs>
): Promise<FunctionReturnType<Query>>;
/**
* Run a mutation with the given name and arguments.
*
* @param mutation - The mutation to run, like `internal.index.exampleMutation`.
* @param args - The arguments to the mutation function.
* @param opts - Options for scheduling and naming the mutation.
*/
runMutation<
Mutation extends FunctionReference<"mutation", FunctionVisibility>,
>(
mutation: Mutation,
...args: ArgsAndOptions<Mutation, RunOptions & InlineArgs>
): Promise<FunctionReturnType<Mutation>>;
/**
* Run an action with the given name and arguments.
*
* @param action - The action to run, like `internal.index.exampleAction`.
* @param args - The arguments to the action function.
* @param opts - Options for retrying, scheduling and naming the action.
*/
runAction<Action extends FunctionReference<"action", FunctionVisibility>>(
action: Action,
...args: ArgsAndOptions<Action, RunOptions & RetryOption>
): Promise<FunctionReturnType<Action>>;
/**
* Run a workflow with the given name and arguments.
*
* @param workflow - The workflow to run, like `internal.index.exampleWorkflow`.
* @param args - The arguments to the workflow function.
* @param opts - Options for retrying, scheduling and naming the workflow.
*/
runWorkflow<Workflow extends FunctionReference<"mutation", "internal">>(
workflow: Workflow,
args: FunctionArgs<Workflow>["args"],
opts?: RunOptions,
): Promise<FunctionReturnType<Workflow>>;
/**
* Blocks until a matching event is sent to this workflow.
*
* If an ID is specified, an event with that ID must already exist and must
* not already be "awaited" or "consumed".
*
* If a name is specified, the first available event is consumed that matches
* the name. If there is no available event, it will create one with that name
* with status "awaited".
* @param event
*/
awaitEvent<T = unknown, Name extends string = string>(
event: (
| { name: Name; id?: EventId<Name> }
| { name?: Name; id: EventId<Name> }
) & {
validator?: Validator<T, any, any>;
},
): Promise<T>;
/**
* Suspend execution for the given duration.
*
* @param duration - The number of milliseconds to sleep.
* @param opts - Optionally name the step. Default: "sleep"
*/
sleep(duration: number, opts?: { name?: string }): Promise<void>;
};
export function createWorkflowCtx(
workflowId: WorkflowId,
sender: BaseChannel<StepRequest>,
) {
return {
workflowId,
runQuery: async (query, args, opts?) => {
return runFunction(sender, "query", query, args, opts);
},
runMutation: async (mutation, args, opts?) => {
return runFunction(sender, "mutation", mutation, args, opts);
},
runAction: async (action, args, opts?) => {
return runFunction(sender, "action", action, args, opts);
},
runWorkflow: async (workflow, args, opts?) => {
const { name, unstableArgs, ...schedulerOptions } = opts ?? {};
return run(sender, {
name: name ?? safeFunctionName(workflow),
target: {
kind: "workflow",
function: workflow,
args,
},
retry: undefined,
inline: false,
unstableArgs: unstableArgs ?? false,
schedulerOptions,
transactionLimits: undefined,
});
},
sleep: async (duration, opts?) => {
await run(sender, {
name: opts?.name ?? "sleep",
target: {
kind: "sleep",
args: {},
},
retry: undefined,
inline: false,
unstableArgs: false,
schedulerOptions: { runAfter: duration },
transactionLimits: undefined,
});
},
awaitEvent: async (event) => {
const result = await run(sender, {
name: event.name ?? event.id ?? "Event",
target: {
kind: "event",
args: { eventId: event.id },
},
retry: undefined,
inline: false,
unstableArgs: false,
schedulerOptions: {},
transactionLimits: undefined,
});
if (event.validator) {
return parse(event.validator, result);
}
return result as any;
},
} satisfies WorkflowCtx;
}
async function runFunction<
F extends FunctionReference<FunctionType, FunctionVisibility>,
>(
sender: BaseChannel<StepRequest>,
functionType: FunctionType,
f: F,
args: Record<string, unknown> | undefined,
opts?: RunOptions & {
inline?: boolean;
transactionLimits?: TransactionLimits;
} & RetryOption,
): Promise<unknown> {
const {
name,
retry,
inline,
transactionLimits,
unstableArgs,
...schedulerOptions
} = opts ?? {};
if (
inline &&
schedulerOptions &&
(schedulerOptions.runAt || schedulerOptions.runAfter)
) {
throw new Error("Cannot combine `inline` with `runAt` or `runAfter`.");
}
if (inline && functionType === "action") {
throw new Error("Cannot run an action inline.");
}
if (!inline && transactionLimits) {
throw new Error("Cannot set transaction limits for non-inline functions.");
}
return run(sender, {
name: name ?? safeFunctionName(f),
target: {
kind: "function",
functionType,
function: f,
args: args ?? {},
},
retry,
inline: inline ?? false,
unstableArgs: unstableArgs ?? false,
transactionLimits,
schedulerOptions,
});
}
async function run(
sender: BaseChannel<StepRequest>,
request: Omit<StepRequest, "resolve">,
): Promise<unknown> {
let send: Promise<void>;
const p = new Promise<RunResult>((resolve) => {
send = sender.push({
...request,
resolve,
});
});
await send!;
const result = await p;
switch (result.kind) {
case "success":
return result.returnValue;
case "failed":
throw new Error(result.error);
case "canceled":
throw new Error("Canceled");
default:
throw new Error("Unknown result kind: " + (result as any).kind);
}
}