@trigger.dev/convex 0.0.1 → 4.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,105 @@
1
+ import { v } from "convex/values";
2
+ import { internal } from "./api.js";
3
+ import { internalAction, internalMutation } from "./server.js";
4
+ import { pause } from "./runs.js";
5
+ import { ApiError, inlineValue, request } from "./apiClient.js";
6
+ import { IDEMPOTENCY_TTL, MAX_SYNC_FAILURES, REQUEST_TIMEOUT_MS, SUBMISSION_WINDOW_MS, WATCHDOG_MARGIN_MS, pollDelay, } from "./shared.js";
7
+ const jobArgs = { id: v.id("runs"), generation: v.number() };
8
+ export const tick = internalMutation({
9
+ args: jobArgs,
10
+ returns: v.null(),
11
+ handler: async (ctx, { id, generation }) => {
12
+ const run = await ctx.db.get("runs", id);
13
+ if (!run ||
14
+ run.generation !== generation ||
15
+ run.syncStatus === "complete" ||
16
+ run.syncStatus === "paused")
17
+ return null;
18
+ if (!run.runId && Date.now() - run._creationTime >= SUBMISSION_WINDOW_MS) {
19
+ await pause(ctx, run, "Submission retry window expired; check Trigger before triggering again");
20
+ return null;
21
+ }
22
+ const nextGeneration = generation + 1;
23
+ await ctx.db.patch("runs", id, { generation: nextGeneration, checks: run.checks + 1 });
24
+ // Schedule the watchdog transactionally before starting an action that can be interrupted.
25
+ await ctx.scheduler.runAfter(Math.max(pollDelay(run.checks), REQUEST_TIMEOUT_MS + WATCHDOG_MARGIN_MS), internal.worker.tick, { id, generation: nextGeneration });
26
+ await ctx.scheduler.runAfter(0, internal.worker.step, { id, generation: nextGeneration });
27
+ return null;
28
+ },
29
+ });
30
+ export const step = internalAction({
31
+ args: jobArgs,
32
+ returns: v.null(),
33
+ handler: async (ctx, { id, generation }) => {
34
+ const loaded = await ctx.runQuery(internal.runs.load, { id });
35
+ if (!loaded ||
36
+ loaded.run.generation !== generation ||
37
+ loaded.run.syncStatus === "complete" ||
38
+ loaded.run.syncStatus === "paused")
39
+ return null;
40
+ const { run, request: saved } = loaded;
41
+ try {
42
+ if (!run.runId) {
43
+ const result = await request(saved.config, `/api/v1/tasks/${encodeURIComponent(run.taskId)}/trigger`, {
44
+ payload: saved.payload,
45
+ options: {
46
+ ...saved.options,
47
+ idempotencyKey: `convex:${id}`,
48
+ idempotencyKeyTTL: IDEMPOTENCY_TTL,
49
+ },
50
+ });
51
+ if (typeof result.id !== "string")
52
+ throw new ApiError("Missing run ID in Trigger.dev response", true);
53
+ await ctx.runMutation(internal.runs.recordSubmission, { id, generation, runId: result.id });
54
+ }
55
+ else {
56
+ const result = await request(saved.config, `/api/v3/runs/${encodeURIComponent(run.runId)}`);
57
+ if (typeof result.status !== "string" || typeof result.isCompleted !== "boolean") {
58
+ throw new ApiError("Missing run status in Trigger.dev response", true);
59
+ }
60
+ const output = inlineValue(result.output);
61
+ await ctx.runMutation(internal.runs.recordStatus, {
62
+ id,
63
+ generation,
64
+ status: result.status,
65
+ completed: result.isCompleted || result.status === "EXPIRED" || result.status === "TIMED_OUT",
66
+ output: output.value,
67
+ outputOmitted: output.omitted,
68
+ outputPresignedUrl: typeof result.outputPresignedUrl === "string" ? result.outputPresignedUrl : undefined,
69
+ error: inlineValue(result.error).value,
70
+ metadata: inlineValue(result.metadata).value,
71
+ });
72
+ }
73
+ }
74
+ catch (error) {
75
+ await ctx.runMutation(internal.worker.recordError, {
76
+ id,
77
+ generation,
78
+ message: error instanceof ApiError
79
+ ? error.message
80
+ : "Run synchronization failed; inspect Convex function logs",
81
+ permanent: error instanceof ApiError && !error.retryable,
82
+ });
83
+ // Preserve diagnostic detail in server logs without including the request config.
84
+ console.error("Trigger.dev synchronization failed", {
85
+ id,
86
+ error: error instanceof Error ? error.name : "UnknownError",
87
+ });
88
+ }
89
+ return null;
90
+ },
91
+ });
92
+ export const recordError = internalMutation({
93
+ args: { ...jobArgs, message: v.string(), permanent: v.boolean() },
94
+ returns: v.null(),
95
+ handler: async (ctx, { id, generation, message, permanent }) => {
96
+ const run = await ctx.db.get("runs", id);
97
+ if (!run || run.generation !== generation || run.syncStatus === "complete")
98
+ return null;
99
+ const failures = run.failures + 1;
100
+ await ctx.db.patch("runs", id, { failures, syncError: message, updatedAt: Date.now() });
101
+ if (permanent || failures >= MAX_SYNC_FAILURES)
102
+ await pause(ctx, run, message);
103
+ return null;
104
+ },
105
+ });
package/package.json CHANGED
@@ -1,16 +1,49 @@
1
1
  {
2
2
  "name": "@trigger.dev/convex",
3
- "version": "0.0.1",
3
+ "version": "4.8.2",
4
4
  "description": "Run Trigger.dev tasks from Convex mutations and query their results reactively",
5
5
  "license": "Apache-2.0",
6
+ "type": "module",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
6
10
  "repository": {
7
11
  "type": "git",
8
12
  "url": "https://github.com/triggerdotdev/trigger.dev",
9
13
  "directory": "packages/convex"
10
14
  },
11
- "homepage": "https://trigger.dev",
12
- "publishConfig": {
13
- "access": "public"
15
+ "files": [
16
+ "dist",
17
+ "src/component",
18
+ "!src/component/**/*.test.ts"
19
+ ],
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/client/index.d.ts",
23
+ "default": "./dist/client/index.js"
24
+ },
25
+ "./convex.config.js": {
26
+ "types": "./dist/component/convex.config.d.ts",
27
+ "default": "./dist/component/convex.config.js"
28
+ },
29
+ "./_generated/component.js": {
30
+ "types": "./dist/component/_generated/component.d.ts"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "peerDependencies": {
35
+ "convex": "^1.48.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "24.13.3",
39
+ "convex": "^1.48.0",
40
+ "convex-test": "^0.0.65",
41
+ "typescript": "7.0.2",
42
+ "vitest": "4.1.11"
14
43
  },
15
- "files": ["README.md"]
16
- }
44
+ "scripts": {
45
+ "build": "rm -rf dist && tsc -p tsconfig.json",
46
+ "test": "vitest run",
47
+ "typecheck": "tsc -p tsconfig.json --noEmit"
48
+ }
49
+ }
@@ -0,0 +1,18 @@
1
+ import type { ApiFromModules, FilterApi, FunctionReference } from "convex/server";
2
+ import type * as runs from "../runs.js";
3
+
4
+ type PublicApi = FilterApi<ApiFromModules<{ runs: typeof runs }>, FunctionReference<any, "public">>;
5
+
6
+ // Derive the component boundary from its functions, so it cannot drift from their validators.
7
+ export type ComponentApi<Name extends string | undefined = string | undefined> = {
8
+ runs: {
9
+ [Key in keyof PublicApi["runs"]]: PublicApi["runs"][Key] extends FunctionReference<
10
+ infer Type,
11
+ any,
12
+ infer Args,
13
+ infer Result
14
+ >
15
+ ? FunctionReference<Type, "internal", Args, Result, Name>
16
+ : never;
17
+ };
18
+ };
@@ -0,0 +1,6 @@
1
+ import { anyApi, type ApiFromModules, type FilterApi, type FunctionReference } from "convex/server";
2
+ import type * as runs from "./runs.js";
3
+ import type * as worker from "./worker.js";
4
+
5
+ type Api = ApiFromModules<{ runs: typeof runs; worker: typeof worker }>;
6
+ export const internal = anyApi as unknown as FilterApi<Api, FunctionReference<any, "internal">>;
@@ -0,0 +1,57 @@
1
+ import { MAX_INLINE_BYTES, REQUEST_TIMEOUT_MS, type Config } from "./shared.js";
2
+
3
+ export class ApiError extends Error {
4
+ constructor(
5
+ message: string,
6
+ public retryable: boolean
7
+ ) {
8
+ super(message);
9
+ }
10
+ }
11
+
12
+ export async function request(
13
+ config: Config,
14
+ path: string,
15
+ body?: unknown
16
+ ): Promise<Record<string, unknown>> {
17
+ const controller = new AbortController();
18
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
19
+ try {
20
+ const response = await fetch(config.baseUrl + path, {
21
+ method: body === undefined ? "GET" : "POST",
22
+ headers: { Authorization: `Bearer ${config.secretKey}`, "Content-Type": "application/json" },
23
+ body: body === undefined ? undefined : JSON.stringify(body),
24
+ signal: controller.signal,
25
+ redirect: "error",
26
+ });
27
+ if (!response.ok) {
28
+ await response.body?.cancel();
29
+ const retryHeader = response.headers.get("x-should-retry");
30
+ // Never persist response bodies, which could contain credentials or payloads.
31
+ throw new ApiError(
32
+ `Trigger.dev API returned HTTP ${response.status}`,
33
+ retryHeader === "true" ||
34
+ (retryHeader !== "false" &&
35
+ (response.status === 408 ||
36
+ response.status === 409 ||
37
+ response.status === 429 ||
38
+ response.status >= 500))
39
+ );
40
+ }
41
+ const data: unknown = await response.json();
42
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
43
+ throw new ApiError("Invalid Trigger.dev response", true);
44
+ }
45
+ return data as Record<string, unknown>;
46
+ } finally {
47
+ clearTimeout(timeout);
48
+ }
49
+ }
50
+
51
+ export function inlineValue(value: unknown): { value?: any; omitted: boolean } {
52
+ if (value === undefined) return { omitted: false };
53
+ if (new TextEncoder().encode(JSON.stringify(value)).byteLength > MAX_INLINE_BYTES) {
54
+ return { omitted: true };
55
+ }
56
+ return { value, omitted: false };
57
+ }
@@ -0,0 +1,3 @@
1
+ import { defineComponent } from "convex/server";
2
+
3
+ export default defineComponent("trigger");
@@ -0,0 +1,354 @@
1
+ import { createServer, type Server } from "node:http";
2
+ import { once } from "node:events";
3
+ import { afterAll, afterEach, beforeAll, beforeEach, expect, test, vi } from "vitest";
4
+ import { defineTestApp, type ComponentRegistration } from "convex-test";
5
+ import { defineSchema, defineTable } from "convex/server";
6
+ import { v } from "convex/values";
7
+ import { Trigger, vRun, type ComponentApi } from "../client/index.js";
8
+ import schema from "./schema.js";
9
+ import { MAX_INLINE_BYTES } from "./shared.js";
10
+
11
+ const modules = {
12
+ "./_generated/component.ts": () => import("./_generated/component.js"),
13
+ "./runs.ts": () => import("./runs.js"),
14
+ "./worker.ts": () => import("./worker.js"),
15
+ };
16
+ const app = defineTestApp({
17
+ schema: defineSchema({
18
+ completions: defineTable({ run: vRun }),
19
+ controls: defineTable({ failCallback: v.boolean() }),
20
+ }),
21
+ components: {
22
+ trigger: {
23
+ register: ((t, name) =>
24
+ t.registerComponent(name, schema, modules)) as ComponentRegistration<ComponentApi>,
25
+ },
26
+ },
27
+ });
28
+ let baseUrl: string;
29
+ const { createTest, api, internal } = app.defineModules({
30
+ callbacks: {
31
+ complete: app.internalMutation({
32
+ args: { run: vRun },
33
+ handler: async (ctx, args) => {
34
+ if ((await ctx.db.query("controls").first())?.failCallback)
35
+ throw new Error("Callback unavailable");
36
+ await ctx.db.insert("completions", args);
37
+ },
38
+ }),
39
+ },
40
+ jobs: {
41
+ request: app.mutation({
42
+ args: {
43
+ rollback: v.optional(v.boolean()),
44
+ key: v.optional(v.string()),
45
+ payload: v.optional(v.any()),
46
+ },
47
+ handler: async (ctx, args): Promise<string> => {
48
+ const trigger = new Trigger(app.components.trigger, {
49
+ secretKey: "test-only-key",
50
+ baseUrl,
51
+ onComplete: internal.callbacks.complete,
52
+ });
53
+ const id = await trigger.trigger(
54
+ ctx,
55
+ "render-video",
56
+ args.payload ?? { videoId: "video-1" },
57
+ {
58
+ idempotencyKey: args.key,
59
+ tags: ["video:1"],
60
+ }
61
+ );
62
+ if (args.rollback) throw new Error("Roll back the application transaction");
63
+ return id;
64
+ },
65
+ }),
66
+ },
67
+ });
68
+
69
+ async function drain(t: ReturnType<typeof createTest>): Promise<void> {
70
+ for (let i = 0; i < 100; i++) {
71
+ vi.advanceTimersToNextTimer();
72
+ await t.finishInProgressScheduledFunctions();
73
+ const { page } = await t.query(app.components.trigger.runs.list, {
74
+ paginationOpts: { numItems: 10, cursor: null },
75
+ });
76
+ if (page.every((run) => run.syncStatus === "complete" || run.syncStatus === "paused")) return;
77
+ }
78
+ throw new Error("Runs did not settle");
79
+ }
80
+
81
+ let server: Server;
82
+ let submissions: number;
83
+ let polls: number;
84
+ let remoteRuns: Map<string, string>;
85
+ let loseFirstResponse: boolean;
86
+ let triggerStatus: number;
87
+ let pollFailures: number;
88
+ let pollFailureStatus: number;
89
+ let pollRetryHeader: string | undefined;
90
+ let activePolls: number;
91
+ let remoteStatus: string;
92
+ let remoteIsCompleted: boolean;
93
+ let largeOutput: boolean;
94
+ let submittedOptions: Record<string, unknown>;
95
+
96
+ beforeAll(async () => {
97
+ server = createServer(async (req, res) => {
98
+ res.setHeader("Content-Type", "application/json");
99
+ if (req.headers.authorization !== "Bearer test-only-key") {
100
+ res.writeHead(401).end("{}");
101
+ return;
102
+ }
103
+ if (req.method === "POST") {
104
+ submissions++;
105
+ let body = "";
106
+ for await (const chunk of req) body += chunk;
107
+ const data = JSON.parse(body);
108
+ submittedOptions = data.options;
109
+ if (triggerStatus !== 200) {
110
+ res.writeHead(triggerStatus).end("{}");
111
+ return;
112
+ }
113
+ const key: string = data.options.idempotencyKey;
114
+ if (!remoteRuns.has(key)) remoteRuns.set(key, `run_${remoteRuns.size + 1}`);
115
+ if (loseFirstResponse && submissions === 1) {
116
+ res.destroy();
117
+ return;
118
+ }
119
+ res.end(JSON.stringify({ id: remoteRuns.get(key) }));
120
+ } else {
121
+ polls++;
122
+ if (pollFailures-- > 0) {
123
+ if (pollRetryHeader !== undefined) res.setHeader("x-should-retry", pollRetryHeader);
124
+ res.writeHead(pollFailureStatus).end("{}");
125
+ return;
126
+ }
127
+ const completed = activePolls-- <= 0;
128
+ res.end(
129
+ JSON.stringify({
130
+ id: "run_1",
131
+ status: completed ? remoteStatus : "EXECUTING",
132
+ isCompleted: completed && remoteIsCompleted,
133
+ output: completed
134
+ ? largeOutput
135
+ ? "x".repeat(MAX_INLINE_BYTES + 1)
136
+ : { url: "https://example.com/video.mp4" }
137
+ : undefined,
138
+ metadata: { progress: completed ? 100 : 50 },
139
+ })
140
+ );
141
+ }
142
+ });
143
+ server.listen(0, "127.0.0.1");
144
+ await once(server, "listening");
145
+ const address = server.address();
146
+ if (!address || typeof address === "string") throw new Error("Expected TCP server");
147
+ baseUrl = `http://127.0.0.1:${address.port}`;
148
+ });
149
+
150
+ beforeEach(() => {
151
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
152
+ submissions = 0;
153
+ polls = 0;
154
+ remoteRuns = new Map();
155
+ loseFirstResponse = false;
156
+ triggerStatus = 200;
157
+ pollFailures = 0;
158
+ pollFailureStatus = 503;
159
+ pollRetryHeader = undefined;
160
+ activePolls = 0;
161
+ remoteStatus = "COMPLETED";
162
+ remoteIsCompleted = true;
163
+ largeOutput = false;
164
+ submittedOptions = {};
165
+ });
166
+ afterEach(() => vi.useRealTimers());
167
+ afterAll(
168
+ () =>
169
+ new Promise<void>((resolve, reject) => {
170
+ server.close((error) => (error ? reject(error) : resolve()));
171
+ server.closeAllConnections();
172
+ })
173
+ );
174
+
175
+ test("rolling back the application mutation never submits work", async () => {
176
+ const t = createTest();
177
+ await expect(t.mutation(api.jobs.request, { rollback: true })).rejects.toThrow("Roll back");
178
+ await drain(t);
179
+ expect(submissions).toBe(0);
180
+ expect(
181
+ await t.query(app.components.trigger.runs.list, {
182
+ paginationOpts: { numItems: 10, cursor: null },
183
+ })
184
+ ).toMatchObject({ page: [] });
185
+ });
186
+
187
+ test("deduplicates requests, stores reactive results, and calls the app once without exposing credentials", async () => {
188
+ const t = createTest();
189
+ const id = await t.mutation(api.jobs.request, { key: "render:1" });
190
+ expect(await t.mutation(api.jobs.request, { key: "render:1" })).toBe(id);
191
+ await drain(t);
192
+ await drain(t);
193
+ const run = await t.query(app.components.trigger.runs.get, { id });
194
+ expect(run).toMatchObject({
195
+ runId: "run_1",
196
+ status: "COMPLETED",
197
+ syncStatus: "complete",
198
+ output: { url: "https://example.com/video.mp4" },
199
+ metadata: { progress: 100 },
200
+ });
201
+ expect(JSON.stringify(run)).not.toContain("test-only-key");
202
+ expect(
203
+ JSON.stringify(
204
+ await t.query(app.components.trigger.runs.list, {
205
+ paginationOpts: { numItems: 10, cursor: null },
206
+ })
207
+ )
208
+ ).not.toContain("test-only-key");
209
+ expect(submissions).toBe(1);
210
+ expect(submittedOptions).toMatchObject({ tags: ["video:1"], idempotencyKeyTTL: "24h" });
211
+ expect(await t.run((ctx) => ctx.db.query("completions").collect())).toHaveLength(1);
212
+ });
213
+
214
+ test("retries an ambiguous submission using the same remote idempotency key", async () => {
215
+ loseFirstResponse = true;
216
+ const t = createTest();
217
+ const id = await t.mutation(api.jobs.request, {});
218
+ await drain(t);
219
+ expect(submissions).toBe(2);
220
+ expect(remoteRuns.size).toBe(1);
221
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
222
+ syncStatus: "complete",
223
+ });
224
+ });
225
+
226
+ test("recovers from temporary polling failures and observes cancellation without task hooks", async () => {
227
+ pollFailures = 2;
228
+ remoteStatus = "CANCELED";
229
+ const t = createTest();
230
+ const id = await t.mutation(api.jobs.request, {});
231
+ await drain(t);
232
+ expect(polls).toBe(3);
233
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
234
+ status: "CANCELED",
235
+ syncStatus: "complete",
236
+ });
237
+ });
238
+
239
+ test.each(["EXPIRED", "TIMED_OUT"])(
240
+ "finishes %s runs even when isCompleted is false",
241
+ async (status) => {
242
+ remoteStatus = status;
243
+ remoteIsCompleted = false;
244
+ const t = createTest();
245
+ const id = await t.mutation(api.jobs.request, {});
246
+ await drain(t);
247
+ await drain(t);
248
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
249
+ status,
250
+ syncStatus: "complete",
251
+ });
252
+ expect(polls).toBe(1);
253
+ const completions = await t.run((ctx) => ctx.db.query("completions").collect());
254
+ expect(completions).toHaveLength(1);
255
+ expect(completions[0]?.run.status).toBe(status);
256
+ }
257
+ );
258
+
259
+ test.each([
260
+ { status: 404, header: "true", syncStatus: "complete", expectedPolls: 2 },
261
+ { status: 404, header: undefined, syncStatus: "paused", expectedPolls: 1 },
262
+ { status: 503, header: "false", syncStatus: "paused", expectedPolls: 1 },
263
+ ])(
264
+ "honors retry policy for HTTP $status with x-should-retry=$header",
265
+ async ({ status, header, syncStatus, expectedPolls }) => {
266
+ pollFailures = 1;
267
+ pollFailureStatus = status;
268
+ pollRetryHeader = header;
269
+ const t = createTest();
270
+ const id = await t.mutation(api.jobs.request, {});
271
+ await drain(t);
272
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({ syncStatus });
273
+ expect(polls).toBe(expectedPolls);
274
+ }
275
+ );
276
+
277
+ test("pauses a rejected submission and can resume it inside the retry window", async () => {
278
+ triggerStatus = 401;
279
+ const t = createTest();
280
+ const id = await t.mutation(api.jobs.request, {});
281
+ await drain(t);
282
+ expect(submissions).toBe(1);
283
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
284
+ syncStatus: "paused",
285
+ });
286
+ triggerStatus = 200;
287
+ await t.mutation(app.components.trigger.runs.resume, {
288
+ id,
289
+ config: { baseUrl, secretKey: "test-only-key" },
290
+ });
291
+ await drain(t);
292
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
293
+ syncStatus: "complete",
294
+ });
295
+ });
296
+
297
+ test("keeps completion usable when the remote output exceeds the document budget", async () => {
298
+ largeOutput = true;
299
+ const t = createTest();
300
+ const id = await t.mutation(api.jobs.request, {});
301
+ await drain(t);
302
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
303
+ syncStatus: "complete",
304
+ outputOmitted: true,
305
+ });
306
+ });
307
+
308
+ test("tracks active work without invoking completion early", async () => {
309
+ activePolls = 1;
310
+ const t = createTest();
311
+ const id = await t.mutation(api.jobs.request, {});
312
+ for (let i = 0; i < 20 && polls === 0; i++) {
313
+ vi.advanceTimersToNextTimer();
314
+ await t.finishInProgressScheduledFunctions();
315
+ }
316
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
317
+ status: "EXECUTING",
318
+ syncStatus: "active",
319
+ metadata: { progress: 50 },
320
+ });
321
+ expect(await t.run((ctx) => ctx.db.query("completions").collect())).toHaveLength(0);
322
+ await drain(t);
323
+ expect(await t.run((ctx) => ctx.db.query("completions").collect())).toHaveLength(1);
324
+ });
325
+
326
+ test("a failing callback rolls back completion and can be retried without duplicate application writes", async () => {
327
+ const t = createTest();
328
+ const controlId = await t.run((ctx) => ctx.db.insert("controls", { failCallback: true }));
329
+ const id = await t.mutation(api.jobs.request, {});
330
+ await drain(t);
331
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
332
+ syncStatus: "paused",
333
+ });
334
+ expect(await t.run((ctx) => ctx.db.query("completions").collect())).toHaveLength(0);
335
+ await t.run((ctx) => ctx.db.patch(controlId, { failCallback: false }));
336
+ await t.mutation(app.components.trigger.runs.resume, {
337
+ id,
338
+ config: { secretKey: "test-only-key", baseUrl },
339
+ });
340
+ await drain(t);
341
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
342
+ syncStatus: "complete",
343
+ });
344
+ expect(await t.run((ctx) => ctx.db.query("completions").collect())).toHaveLength(1);
345
+ });
346
+
347
+ test("rejects large inputs before scheduling remote work", async () => {
348
+ const t = createTest();
349
+ await expect(
350
+ t.mutation(api.jobs.request, { payload: "x".repeat(MAX_INLINE_BYTES + 1) })
351
+ ).rejects.toThrow("256 KiB");
352
+ await drain(t);
353
+ expect(submissions).toBe(0);
354
+ });