|
| 1 | +# @trigger.dev/convex |
| 2 | + |
| 3 | +Run Trigger.dev tasks from Convex mutations and read their status and results through Convex reactive queries. |
| 4 | + |
| 5 | +Use Convex for application state and Trigger.dev for work that needs longer execution, more memory, ffmpeg, Playwright, Python, or other system dependencies. Tasks are still written and deployed normally with Trigger.dev. |
| 6 | + |
| 7 | +**Experimental MVP.** No HTTP routes, webhooks, Trigger lifecycle hooks, or changes to your Trigger server are required. |
| 8 | + |
| 9 | +## Install |
| 10 | + |
| 11 | +```sh |
| 12 | +npm install @trigger.dev/convex convex |
| 13 | +npx convex env set TRIGGER_SECRET_KEY tr_dev_... |
| 14 | +``` |
| 15 | + |
| 16 | +Choose the Trigger environment's secret key. Never expose it to browser code. |
| 17 | + |
| 18 | +```ts |
| 19 | +// convex/convex.config.ts |
| 20 | +import { defineApp } from "convex/server"; |
| 21 | +import trigger from "@trigger.dev/convex/convex.config.js"; |
| 22 | + |
| 23 | +const app = defineApp(); |
| 24 | +app.use(trigger); |
| 25 | +export default app; |
| 26 | +``` |
| 27 | + |
| 28 | +Run `npx convex dev` to generate your application's component references. |
| 29 | + |
| 30 | +```ts |
| 31 | +// convex/trigger.ts |
| 32 | +import { Trigger } from "@trigger.dev/convex"; |
| 33 | +import { components, internal } from "./_generated/api"; |
| 34 | + |
| 35 | +export const trigger: Trigger = new Trigger(components.trigger, { |
| 36 | + onComplete: internal.jobs.onComplete, // Optional internal mutation |
| 37 | +}); |
| 38 | +``` |
| 39 | + |
| 40 | +The explicit `Trigger` annotation avoids circular type inference through Convex's generated API when configuring a completion callback. |
| 41 | + |
| 42 | +## Example: request work and read the result |
| 43 | + |
| 44 | +The example assumes a Trigger task named `render-video` exists. Type-only task imports do not bundle task code into Convex. |
| 45 | + |
| 46 | +```ts |
| 47 | +// convex/schema.ts |
| 48 | +import { defineSchema, defineTable } from "convex/server"; |
| 49 | +import { v } from "convex/values"; |
| 50 | + |
| 51 | +export default defineSchema({ |
| 52 | + jobs: defineTable({ |
| 53 | + owner: v.string(), |
| 54 | + runId: v.optional(v.string()), |
| 55 | + status: v.string(), |
| 56 | + output: v.optional(v.any()), |
| 57 | + }).index("by_runId", ["runId"]), |
| 58 | +}); |
| 59 | +``` |
| 60 | + |
| 61 | +```ts |
| 62 | +// convex/jobs.ts |
| 63 | +import { vRun } from "@trigger.dev/convex"; |
| 64 | +import { v } from "convex/values"; |
| 65 | +import { mutation, query, internalMutation } from "./_generated/server"; |
| 66 | +import { trigger } from "./trigger"; |
| 67 | +import type { renderVideo } from "../trigger/renderVideo"; |
| 68 | + |
| 69 | +export const request = mutation({ |
| 70 | + args: { videoUrl: v.string() }, |
| 71 | + handler: async (ctx, { videoUrl }) => { |
| 72 | + const user = await ctx.auth.getUserIdentity(); |
| 73 | + if (!user) throw new Error("Unauthorized"); |
| 74 | + |
| 75 | + const jobId = await ctx.db.insert("jobs", { |
| 76 | + owner: user.subject, |
| 77 | + status: "PENDING", |
| 78 | + }); |
| 79 | + const runId = await trigger.trigger<typeof renderVideo>( |
| 80 | + ctx, |
| 81 | + "render-video", |
| 82 | + { videoUrl }, |
| 83 | + { tags: [`job:${jobId}`] }, |
| 84 | + ); |
| 85 | + await ctx.db.patch(jobId, { runId }); |
| 86 | + return jobId; |
| 87 | + }, |
| 88 | +}); |
| 89 | + |
| 90 | +export const status = query({ |
| 91 | + args: { jobId: v.id("jobs") }, |
| 92 | + handler: async (ctx, { jobId }) => { |
| 93 | + const user = await ctx.auth.getUserIdentity(); |
| 94 | + const job = await ctx.db.get(jobId); |
| 95 | + if (!user || !job || job.owner !== user.subject) return null; |
| 96 | + return job.runId ? trigger.getRun(ctx, job.runId) : null; |
| 97 | + }, |
| 98 | +}); |
| 99 | + |
| 100 | +export const onComplete = internalMutation({ |
| 101 | + args: { run: vRun }, |
| 102 | + handler: async (ctx, { run }) => { |
| 103 | + const job = await ctx.db.query("jobs") |
| 104 | + .withIndex("by_runId", q => q.eq("runId", run.id)) |
| 105 | + .unique(); |
| 106 | + if (job) await ctx.db.patch(job._id, { |
| 107 | + status: run.status, |
| 108 | + output: run.output, |
| 109 | + }); |
| 110 | + }, |
| 111 | +}); |
| 112 | +``` |
| 113 | + |
| 114 | +```tsx |
| 115 | +// React |
| 116 | +const run = useQuery(api.jobs.status, { jobId }); |
| 117 | +// run.status, run.output, run.error, run.metadata, run.syncStatus |
| 118 | +``` |
| 119 | + |
| 120 | +The ID returned by `trigger.trigger` identifies the **component record**, not the Trigger.dev run. The latter becomes available as `run.runId` after submission. |
| 121 | + |
| 122 | +## API |
| 123 | + |
| 124 | +- `trigger(ctx, taskId, payload, options?)`: enqueue work. Calling from a mutation makes the request atomic with your application's writes. |
| 125 | +- `getRun(ctx, id)`: read one record reactively. |
| 126 | +- `listRuns(ctx, paginationOpts)`: paginate records, newest first, up to 100 per page and a bounded read budget. |
| 127 | +- `resume(ctx, id)`: resume paused synchronization using the current credentials. |
| 128 | + |
| 129 | +Trigger options include tags, delay, queue name, concurrency key, machine, metadata, max attempts, max duration, TTL, and a component-level idempotency key. |
| 130 | + |
| 131 | +An optional `idempotencyKey` deduplicates requests across this component instance, regardless of task. Reusing a key returns the original record, even if its payload or task differs. Keys remain reserved while records exist. |
| 132 | + |
| 133 | +The constructor accepts `secretKey` (defaults to `TRIGGER_SECRET_KEY`), `baseUrl` (defaults to `https://api.trigger.dev`), and `onComplete`. HTTP base URLs are permitted only for localhost development. |
| 134 | + |
| 135 | +**Authorization belongs in your application's functions.** The component has no tenant or user model. Do not expose arbitrary record IDs or `listRuns` to clients without enforcing ownership. Queries never return stored credentials or request payloads. |
| 136 | + |
| 137 | +## Execution and reliability |
| 138 | + |
| 139 | +1. The calling mutation writes a component record and schedules submission. If the transaction rolls back, neither survives. |
| 140 | +2. A short action submits to the existing Trigger API with a stable key derived from the record ID. |
| 141 | +3. Scheduled actions retrieve status with backoff from approximately 15 to 60 seconds between checks. Nothing runs in Convex between checks. |
| 142 | +4. A terminal state, including cancellation, is persisted. The optional completion mutation runs in the same transaction, then stored request data and credentials are deleted. |
| 143 | + |
| 144 | +A scheduled watchdog is committed before each network action. An interrupted action is retried on the next check; obsolete responses cannot overwrite newer observations. |
| 145 | + |
| 146 | +Remote submission uses a 24-hour idempotency TTL. Automatic submission retries stop within one hour of creating the record, well before that TTL. This is **not an unlimited exactly-once guarantee**. |
| 147 | + |
| 148 | +Permanent API errors pause synchronization immediately. Eight consecutive transient or callback failures also pause it. Check `syncStatus === "paused"` and `syncError`. Fix the cause, then call `resume` from an authorized mutation. If submission has not been confirmed, it can only resume within the original one-hour window. After that, reconcile with the Trigger dashboard before creating new work. |
| 149 | + |
| 150 | +Keep the same Trigger environment when resuming. Changing credentials does not migrate remote runs between environments. Existing requests retain their original credentials until resumed. |
| 151 | + |
| 152 | +Completion callbacks should be short mutations. If a callback throws, completion and its application writes roll back together and are retried. A broken callback can therefore delay the component's completion state; `syncError` reports synchronization trouble. No callback is needed to use reactive results. |
| 153 | + |
| 154 | +## Limits |
| 155 | + |
| 156 | +- Polling costs Convex executions and Trigger API requests. This MVP is for validating the integration, not high-volume fan-out or subsecond progress. |
| 157 | +- Payload plus trigger options must fit within 256 KiB. Pass storage references for larger inputs. |
| 158 | +- Output, error, and metadata each have a 256 KiB inline budget. Oversized output is omitted with `outputOmitted: true`. Oversized error or metadata is omitted. |
| 159 | +- Trigger's `outputPresignedUrl`, when supplied, is preserved rather than downloaded. These URLs expire. Retrieve the run again through the Trigger API when you need a fresh URL. |
| 160 | +- Run records are retained. Paused requests retain their payload and credentials for recovery. Automatic retention cleanup is not included. |
| 161 | +- No batch API, cancellation command, waitpoint completion, schedules, or streaming yet. Trigger tasks themselves can use those features normally. |
| 162 | + |
| 163 | +## Development |
| 164 | + |
| 165 | +From the repository root: |
| 166 | + |
| 167 | +```sh |
| 168 | +pnpm --filter @trigger.dev/convex build |
| 169 | +pnpm --filter @trigger.dev/convex test |
| 170 | +``` |
| 171 | + |
| 172 | +Tests use Convex's in-memory test runtime and real HTTP requests to a local server. |
0 commit comments