UNPKG

@convex-dev/workflow

Version:

Convex component for durably executing workflows.

200 lines (193 loc) 6.02 kB
// Get event status import { v } from "convex/values"; import { mutation, type MutationCtx } from "./_generated/server.js"; import { vResultValidator } from "@convex-dev/workpool"; import type { Doc, Id } from "./_generated/dataModel.js"; import { assert } from "convex-helpers"; import { enqueueWorkflow, getWorkpool, workpoolOptions } from "./pool.js"; import { checkForOversizedResult } from "./oversizedValues.js"; export async function awaitEvent( ctx: MutationCtx, entry: Doc<"steps">, args: { eventId?: Id<"events">; name: string }, ) { const event = await getOrCreateEvent(ctx, entry.workflowId, args, [ "sent", "created", ]); switch (event.state.kind) { case "consumed": { throw new Error( `Event already consumed: ${event._id} (${entry.step.name}) in workflow ${entry.workflowId} step ${entry.stepNumber} (${entry._id})`, ); } case "waiting": { throw new Error( `Event already waiting: ${event._id} (${entry.step.name}) in workflow ${entry.workflowId} step ${entry.stepNumber} (${entry._id})`, ); } } switch (event.state.kind) { case "sent": { await ctx.db.patch("events", event._id, { state: { kind: "consumed", sentAt: event.state.sentAt, waitingAt: Date.now(), consumedAt: Date.now(), stepId: entry._id, }, }); entry.step.runResult = checkForOversizedResult(event.state.result); entry.step.inProgress = false; entry.step.completedAt = Date.now(); break; } case "created": { await ctx.db.patch("events", event._id, { state: { kind: "waiting", waitingAt: Date.now(), stepId: entry._id, }, }); break; } } assert(entry.step.kind === "event", "Step is not an event"); entry.step.eventId = event._id; // if there's a name, see if there's one to consume. // if it's there, mark it consumed and swap in the result. return entry; } async function getOrCreateEvent( ctx: MutationCtx, workflowId: Id<"workflows"> | undefined, args: { eventId?: Id<"events">; name?: string }, statuses: Doc<"events">["state"]["kind"][], ): Promise<Doc<"events">> { if (args.eventId) { const event = await ctx.db.get("events", args.eventId); if (!event) { throw new Error( `Event not found: ${args.eventId} (${args.name}) in workflow ${workflowId}`, ); } return event; } assert(args.name, "Name is required if eventId is not specified"); assert(workflowId, "workflowId is required if eventId is not specified"); for (const status of statuses) { const event = await ctx.db .query("events") .withIndex("workflowId_state", (q) => q.eq("workflowId", workflowId).eq("state.kind", status), ) .filter((q) => q.eq(q.field("name"), args.name)) .first(); if (event) return event; } const eventId = await ctx.db.insert("events", { workflowId, name: args.name, state: { kind: "created", }, }); return (await ctx.db.get("events", eventId))!; } export const send = mutation({ args: { workflowId: v.optional(v.id("workflows")), eventId: v.optional(v.id("events")), name: v.optional(v.string()), result: vResultValidator, workpoolOptions: v.optional(workpoolOptions), }, returns: v.id("events"), handler: async (ctx, args) => { const event = await getOrCreateEvent( ctx, args.workflowId, { eventId: args.eventId, name: args.name, }, ["waiting", "created"], ); const { workflowId } = event; const name = args.name ?? event.name; switch (event.state.kind) { case "sent": { throw new Error( `Event already sent: ${event._id} (${name}) in workflow ${workflowId}`, ); } case "consumed": { throw new Error( `Event already consumed: ${event._id} (${name}) in workflow ${workflowId}`, ); } case "created": { await ctx.db.patch("events", event._id, { state: { kind: "sent", result: args.result, sentAt: Date.now() }, }); break; } case "waiting": { const step = await ctx.db.get("steps", event.state.stepId); assert( step, `Entry ${event.state.stepId} not found when sending event ${event._id} (${name}) in workflow ${workflowId}`, ); assert(step.step.kind === "event", "Step is not an event"); step.step.eventId = event._id; step.step.runResult = checkForOversizedResult(args.result); step.step.inProgress = false; step.step.completedAt = Date.now(); await ctx.db.replace("steps", step._id, step); await ctx.db.patch("events", event._id, { state: { kind: "consumed", stepId: step._id, waitingAt: event.state.waitingAt, sentAt: Date.now(), consumedAt: Date.now(), }, }); const anyMoreEvents = await ctx.db .query("events") .withIndex("workflowId_state", (q) => q.eq("workflowId", workflowId).eq("state.kind", "waiting"), ) .order("desc") .first(); if (!anyMoreEvents) { const workflow = await ctx.db.get("workflows", workflowId); assert(workflow, `Workflow ${workflowId} not found`); const workpool = await getWorkpool(ctx, args.workpoolOptions); await enqueueWorkflow(ctx, workflow, workpool); } break; } } return event._id; }, }); export const create = mutation({ args: { name: v.string(), workflowId: v.id("workflows"), }, returns: v.id("events"), handler: async (ctx, args) => { const eventId = await ctx.db.insert("events", { workflowId: args.workflowId, name: args.name, state: { kind: "created", }, }); return eventId; }, });