@trigger.dev/convex 0.0.1 → 4.8.3

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,501 @@
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, MIN_REFRESH_INTERVAL_MS } 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
+ refresh: app.mutation({
67
+ args: { id: v.string() },
68
+ handler: async (ctx, { id }) => new Trigger(app.components.trigger).refresh(ctx, id),
69
+ }),
70
+ },
71
+ });
72
+
73
+ async function drain(t: ReturnType<typeof createTest>): Promise<void> {
74
+ for (let i = 0; i < 100; i++) {
75
+ vi.advanceTimersToNextTimer();
76
+ await t.finishInProgressScheduledFunctions();
77
+ const { page } = await t.query(app.components.trigger.runs.list, {
78
+ paginationOpts: { numItems: 10, cursor: null },
79
+ });
80
+ if (page.every((run) => run.syncStatus === "complete" || run.syncStatus === "paused")) return;
81
+ }
82
+ throw new Error("Runs did not settle");
83
+ }
84
+
85
+ let server: Server;
86
+ let submissions: number;
87
+ let polls: number;
88
+ let remoteRuns: Map<string, string>;
89
+ let loseFirstResponse: boolean;
90
+ let triggerStatus: number;
91
+ let pollFailures: number;
92
+ let pollFailureStatus: number;
93
+ let pollRetryHeader: string | undefined;
94
+ let activePolls: number;
95
+ let remoteStatus: string;
96
+ let remoteIsCompleted: boolean;
97
+ let largeOutput: boolean;
98
+ let submittedOptions: Record<string, unknown>;
99
+ let holdNextPoll: boolean;
100
+ let releasePoll: (() => void) | undefined;
101
+
102
+ beforeAll(async () => {
103
+ server = createServer(async (req, res) => {
104
+ res.setHeader("Content-Type", "application/json");
105
+ if (req.headers.authorization !== "Bearer test-only-key") {
106
+ res.writeHead(401).end("{}");
107
+ return;
108
+ }
109
+ if (req.method === "POST") {
110
+ submissions++;
111
+ let body = "";
112
+ for await (const chunk of req) body += chunk;
113
+ const data = JSON.parse(body);
114
+ submittedOptions = data.options;
115
+ if (triggerStatus !== 200) {
116
+ res.writeHead(triggerStatus).end("{}");
117
+ return;
118
+ }
119
+ const key: string = data.options.idempotencyKey;
120
+ if (!remoteRuns.has(key)) remoteRuns.set(key, `run_${remoteRuns.size + 1}`);
121
+ if (loseFirstResponse && submissions === 1) {
122
+ res.destroy();
123
+ return;
124
+ }
125
+ res.end(JSON.stringify({ id: remoteRuns.get(key) }));
126
+ } else {
127
+ polls++;
128
+ if (pollFailures-- > 0) {
129
+ if (pollRetryHeader !== undefined) res.setHeader("x-should-retry", pollRetryHeader);
130
+ res.writeHead(pollFailureStatus).end("{}");
131
+ return;
132
+ }
133
+ const completed = activePolls-- <= 0;
134
+ const body = JSON.stringify({
135
+ id: "run_1",
136
+ status: completed ? remoteStatus : "EXECUTING",
137
+ isCompleted: completed && remoteIsCompleted,
138
+ output: completed
139
+ ? largeOutput
140
+ ? "x".repeat(MAX_INLINE_BYTES + 1)
141
+ : { url: "https://example.com/video.mp4" }
142
+ : undefined,
143
+ metadata: { progress: completed ? 100 : 50 },
144
+ });
145
+ if (holdNextPoll) {
146
+ holdNextPoll = false;
147
+ releasePoll = () => {
148
+ if (!res.writableEnded) res.end(body);
149
+ releasePoll = undefined;
150
+ };
151
+ } else {
152
+ res.end(body);
153
+ }
154
+ }
155
+ });
156
+ server.listen(0, "127.0.0.1");
157
+ await once(server, "listening");
158
+ const address = server.address();
159
+ if (!address || typeof address === "string") throw new Error("Expected TCP server");
160
+ baseUrl = `http://127.0.0.1:${address.port}`;
161
+ });
162
+
163
+ beforeEach(() => {
164
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
165
+ submissions = 0;
166
+ polls = 0;
167
+ remoteRuns = new Map();
168
+ loseFirstResponse = false;
169
+ triggerStatus = 200;
170
+ pollFailures = 0;
171
+ pollFailureStatus = 503;
172
+ pollRetryHeader = undefined;
173
+ activePolls = 0;
174
+ remoteStatus = "COMPLETED";
175
+ remoteIsCompleted = true;
176
+ largeOutput = false;
177
+ submittedOptions = {};
178
+ holdNextPoll = false;
179
+ releasePoll = undefined;
180
+ });
181
+ afterEach(() => {
182
+ releasePoll?.();
183
+ vi.useRealTimers();
184
+ });
185
+ afterAll(
186
+ () =>
187
+ new Promise<void>((resolve, reject) => {
188
+ server.close((error) => (error ? reject(error) : resolve()));
189
+ server.closeAllConnections();
190
+ })
191
+ );
192
+
193
+ test("rolling back the application mutation never submits work", async () => {
194
+ const t = createTest();
195
+ await expect(t.mutation(api.jobs.request, { rollback: true })).rejects.toThrow("Roll back");
196
+ await drain(t);
197
+ expect(submissions).toBe(0);
198
+ expect(
199
+ await t.query(app.components.trigger.runs.list, {
200
+ paginationOpts: { numItems: 10, cursor: null },
201
+ })
202
+ ).toMatchObject({ page: [] });
203
+ });
204
+
205
+ test("deduplicates requests, stores reactive results, and calls the app once without exposing credentials", async () => {
206
+ const t = createTest();
207
+ const id = await t.mutation(api.jobs.request, { key: "render:1" });
208
+ expect(await t.mutation(api.jobs.request, { key: "render:1" })).toBe(id);
209
+ await drain(t);
210
+ await drain(t);
211
+ const run = await t.query(app.components.trigger.runs.get, { id });
212
+ expect(run).toMatchObject({
213
+ runId: "run_1",
214
+ status: "COMPLETED",
215
+ syncStatus: "complete",
216
+ output: { url: "https://example.com/video.mp4" },
217
+ metadata: { progress: 100 },
218
+ });
219
+ expect(JSON.stringify(run)).not.toContain("test-only-key");
220
+ expect(
221
+ JSON.stringify(
222
+ await t.query(app.components.trigger.runs.list, {
223
+ paginationOpts: { numItems: 10, cursor: null },
224
+ })
225
+ )
226
+ ).not.toContain("test-only-key");
227
+ expect(submissions).toBe(1);
228
+ expect(submittedOptions).toMatchObject({ tags: ["video:1"], idempotencyKeyTTL: "24h" });
229
+ expect(await t.run((ctx) => ctx.db.query("completions").collect())).toHaveLength(1);
230
+ });
231
+
232
+ test("retries an ambiguous submission using the same remote idempotency key", async () => {
233
+ loseFirstResponse = true;
234
+ const t = createTest();
235
+ const id = await t.mutation(api.jobs.request, {});
236
+ await drain(t);
237
+ expect(submissions).toBe(2);
238
+ expect(remoteRuns.size).toBe(1);
239
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
240
+ syncStatus: "complete",
241
+ });
242
+ });
243
+
244
+ test("recovers from temporary polling failures and observes cancellation without task hooks", async () => {
245
+ pollFailures = 2;
246
+ remoteStatus = "CANCELED";
247
+ const t = createTest();
248
+ const id = await t.mutation(api.jobs.request, {});
249
+ await drain(t);
250
+ expect(polls).toBe(3);
251
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
252
+ status: "CANCELED",
253
+ syncStatus: "complete",
254
+ });
255
+ });
256
+
257
+ test.each(["EXPIRED", "TIMED_OUT"])(
258
+ "finishes %s runs even when isCompleted is false",
259
+ async (status) => {
260
+ remoteStatus = status;
261
+ remoteIsCompleted = false;
262
+ const t = createTest();
263
+ const id = await t.mutation(api.jobs.request, {});
264
+ await drain(t);
265
+ await drain(t);
266
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
267
+ status,
268
+ syncStatus: "complete",
269
+ });
270
+ expect(polls).toBe(1);
271
+ const completions = await t.run((ctx) => ctx.db.query("completions").collect());
272
+ expect(completions).toHaveLength(1);
273
+ expect(completions[0]?.run.status).toBe(status);
274
+ }
275
+ );
276
+
277
+ test.each([
278
+ { status: 404, header: "true", syncStatus: "complete", expectedPolls: 2 },
279
+ { status: 404, header: undefined, syncStatus: "paused", expectedPolls: 1 },
280
+ { status: 503, header: "false", syncStatus: "paused", expectedPolls: 1 },
281
+ ])(
282
+ "honors retry policy for HTTP $status with x-should-retry=$header",
283
+ async ({ status, header, syncStatus, expectedPolls }) => {
284
+ pollFailures = 1;
285
+ pollFailureStatus = status;
286
+ pollRetryHeader = header;
287
+ const t = createTest();
288
+ const id = await t.mutation(api.jobs.request, {});
289
+ await drain(t);
290
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({ syncStatus });
291
+ expect(polls).toBe(expectedPolls);
292
+ }
293
+ );
294
+
295
+ test("pauses a rejected submission and can resume it inside the retry window", async () => {
296
+ triggerStatus = 401;
297
+ const t = createTest();
298
+ const id = await t.mutation(api.jobs.request, {});
299
+ await drain(t);
300
+ expect(submissions).toBe(1);
301
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
302
+ syncStatus: "paused",
303
+ });
304
+ triggerStatus = 200;
305
+ await t.mutation(app.components.trigger.runs.resume, {
306
+ id,
307
+ config: { baseUrl, secretKey: "test-only-key" },
308
+ });
309
+ await drain(t);
310
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
311
+ syncStatus: "complete",
312
+ });
313
+ });
314
+
315
+ test("keeps completion usable when the remote output exceeds the document budget", async () => {
316
+ largeOutput = true;
317
+ const t = createTest();
318
+ const id = await t.mutation(api.jobs.request, {});
319
+ await drain(t);
320
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
321
+ syncStatus: "complete",
322
+ outputOmitted: true,
323
+ });
324
+ });
325
+
326
+ test("tracks active work without invoking completion early", async () => {
327
+ activePolls = 1;
328
+ const t = createTest();
329
+ const id = await t.mutation(api.jobs.request, {});
330
+ for (let i = 0; i < 20 && polls === 0; i++) {
331
+ vi.advanceTimersToNextTimer();
332
+ await t.finishInProgressScheduledFunctions();
333
+ }
334
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
335
+ status: "EXECUTING",
336
+ syncStatus: "active",
337
+ metadata: { progress: 50 },
338
+ });
339
+ expect(await t.run((ctx) => ctx.db.query("completions").collect())).toHaveLength(0);
340
+ await drain(t);
341
+ expect(await t.run((ctx) => ctx.db.query("completions").collect())).toHaveLength(1);
342
+ });
343
+
344
+ test("a failing callback rolls back completion and can be retried without duplicate application writes", async () => {
345
+ const t = createTest();
346
+ const controlId = await t.run((ctx) => ctx.db.insert("controls", { failCallback: true }));
347
+ const id = await t.mutation(api.jobs.request, {});
348
+ await drain(t);
349
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
350
+ syncStatus: "paused",
351
+ });
352
+ expect(await t.run((ctx) => ctx.db.query("completions").collect())).toHaveLength(0);
353
+ await t.run((ctx) => ctx.db.patch(controlId, { failCallback: false }));
354
+ await t.mutation(app.components.trigger.runs.resume, {
355
+ id,
356
+ config: { secretKey: "test-only-key", baseUrl },
357
+ });
358
+ await drain(t);
359
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
360
+ syncStatus: "complete",
361
+ });
362
+ expect(await t.run((ctx) => ctx.db.query("completions").collect())).toHaveLength(1);
363
+ });
364
+
365
+ async function runScheduled(t: ReturnType<typeof createTest>, advanceMs = 0): Promise<void> {
366
+ // Convex timestamps have sub-millisecond precision, so zero-delay jobs may fall just after now.
367
+ vi.advanceTimersByTime(advanceMs + 1);
368
+ await t.finishInProgressScheduledFunctions();
369
+ vi.advanceTimersByTime(1);
370
+ await t.finishInProgressScheduledFunctions();
371
+ }
372
+
373
+ // Catches: refresh bursts bypassing the check cooldown or racing the polling callback.
374
+ test("refreshes promptly, bounds repeated checks, and completes once alongside polling", async () => {
375
+ activePolls = 1;
376
+ const t = createTest();
377
+ const id = await t.mutation(api.jobs.request, {});
378
+ await runScheduled(t);
379
+ expect(submissions).toBe(1);
380
+ expect(polls).toBe(0);
381
+
382
+ await Promise.all(Array.from({ length: 3 }, () => t.mutation(api.jobs.refresh, { id })));
383
+ await runScheduled(t);
384
+ expect(polls).toBe(1);
385
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
386
+ status: "EXECUTING",
387
+ metadata: { progress: 50 },
388
+ });
389
+
390
+ await Promise.all(Array.from({ length: 3 }, () => t.mutation(api.jobs.refresh, { id })));
391
+ await runScheduled(t, MIN_REFRESH_INTERVAL_MS / 2);
392
+ expect(polls).toBe(1);
393
+ await runScheduled(t, MIN_REFRESH_INTERVAL_MS / 2);
394
+ expect(polls).toBe(2);
395
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
396
+ status: "COMPLETED",
397
+ syncStatus: "complete",
398
+ output: { url: "https://example.com/video.mp4" },
399
+ });
400
+ await t.mutation(api.jobs.refresh, { id });
401
+ await drain(t);
402
+ expect(polls).toBe(2);
403
+ expect(submissions).toBe(1);
404
+ expect(await t.run((ctx) => ctx.db.query("completions").collect())).toHaveLength(1);
405
+ });
406
+
407
+ // Catches: refresh fencing out an in-flight observation or losing its completion hint.
408
+ test("coalesces hints during an in-flight request and follows it with another check", async () => {
409
+ activePolls = 1;
410
+ const t = createTest();
411
+ const id = await t.mutation(api.jobs.request, {});
412
+ await runScheduled(t);
413
+ holdNextPoll = true;
414
+ const received = once(server, "request");
415
+ await t.mutation(api.jobs.refresh, { id });
416
+ vi.advanceTimersByTime(1);
417
+ await t.finishInProgressScheduledFunctions();
418
+ vi.advanceTimersByTime(1);
419
+ const checking = t.finishInProgressScheduledFunctions();
420
+ await received;
421
+ try {
422
+ await t.mutation(api.jobs.refresh, { id });
423
+ await t.mutation(api.jobs.refresh, { id });
424
+ vi.advanceTimersByTime(MIN_REFRESH_INTERVAL_MS);
425
+ expect(polls).toBe(1);
426
+ releasePoll!();
427
+ await checking;
428
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
429
+ status: "EXECUTING",
430
+ syncStatus: "active",
431
+ metadata: { progress: 50 },
432
+ });
433
+ await runScheduled(t);
434
+ expect(polls).toBe(2);
435
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
436
+ status: "COMPLETED",
437
+ syncStatus: "complete",
438
+ });
439
+ expect(await t.run((ctx) => ctx.db.query("completions").collect())).toHaveLength(1);
440
+ } finally {
441
+ releasePoll?.();
442
+ await checking;
443
+ }
444
+ });
445
+
446
+ // Catches: a failed status request leaving refresh permanently blocked as in-flight.
447
+ test("allows a refresh after a retryable status error", async () => {
448
+ pollFailures = 1;
449
+ const t = createTest();
450
+ const id = await t.mutation(api.jobs.request, {});
451
+ await runScheduled(t);
452
+ await t.mutation(api.jobs.refresh, { id });
453
+ await runScheduled(t);
454
+ expect(polls).toBe(1);
455
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
456
+ syncStatus: "active",
457
+ });
458
+ await t.mutation(api.jobs.refresh, { id });
459
+ await runScheduled(t, MIN_REFRESH_INTERVAL_MS);
460
+ expect(polls).toBe(2);
461
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
462
+ syncStatus: "complete",
463
+ });
464
+ });
465
+
466
+ // Catches: browser hints resubmitting pending work or bypassing a permanent synchronization failure.
467
+ test("does not submit pending work early or resume paused synchronization", async () => {
468
+ pollFailures = 1;
469
+ pollFailureStatus = 401;
470
+ const t = createTest();
471
+ const id = await t.mutation(api.jobs.request, {});
472
+ await t.mutation(api.jobs.refresh, { id });
473
+ expect(submissions).toBe(0);
474
+ await runScheduled(t);
475
+ await t.mutation(api.jobs.refresh, { id });
476
+ await runScheduled(t);
477
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
478
+ syncStatus: "paused",
479
+ });
480
+ await t.mutation(api.jobs.refresh, { id });
481
+ await runScheduled(t, MIN_REFRESH_INTERVAL_MS);
482
+ expect(polls).toBe(1);
483
+ expect(submissions).toBe(1);
484
+ await t.mutation(app.components.trigger.runs.resume, {
485
+ id,
486
+ config: { secretKey: "test-only-key", baseUrl },
487
+ });
488
+ await drain(t);
489
+ expect(await t.query(app.components.trigger.runs.get, { id })).toMatchObject({
490
+ syncStatus: "complete",
491
+ });
492
+ });
493
+
494
+ test("rejects large inputs before scheduling remote work", async () => {
495
+ const t = createTest();
496
+ await expect(
497
+ t.mutation(api.jobs.request, { payload: "x".repeat(MAX_INLINE_BYTES + 1) })
498
+ ).rejects.toThrow("256 KiB");
499
+ await drain(t);
500
+ expect(submissions).toBe(0);
501
+ });