@trigger.dev/convex 4.8.2 → 4.8.4
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.
- package/README.md +51 -0
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.js +7 -0
- package/dist/component/runs.d.ts +7 -0
- package/dist/component/runs.js +40 -2
- package/dist/component/schema.d.ts +7 -1
- package/dist/component/schema.js +3 -0
- package/dist/component/shared.d.ts +1 -0
- package/dist/component/shared.js +1 -0
- package/dist/component/worker.js +16 -3
- package/package.json +4 -2
- package/skills/trigger-convex/SKILL.md +264 -0
- package/src/component/runs.test.ts +162 -15
- package/src/component/runs.ts +52 -1
- package/src/component/schema.ts +3 -0
- package/src/component/shared.ts +1 -0
- package/src/component/worker.ts +15 -3
package/README.md
CHANGED
|
@@ -6,6 +6,12 @@ Use Convex for application state and Trigger.dev for work that needs longer exec
|
|
|
6
6
|
|
|
7
7
|
**Experimental MVP.** No HTTP routes, webhooks, Trigger lifecycle hooks, or changes to your Trigger server are required.
|
|
8
8
|
|
|
9
|
+
## Example
|
|
10
|
+
|
|
11
|
+
[Try the live demo](https://ardent-rabbit-965.convex.site).
|
|
12
|
+
|
|
13
|
+
The [image-editing example](https://github.com/triggerdotdev/trigger.dev/tree/main/packages/convex/example) demonstrates private image processing, Realtime progress, and Convex synchronization. It uses Trigger.dev `4.8.3` from npmjs and runs as a standalone pnpm workspace. The example is not included in npm packages or component builds.
|
|
14
|
+
|
|
9
15
|
## Install
|
|
10
16
|
|
|
11
17
|
```sh
|
|
@@ -119,11 +125,27 @@ const run = useQuery(api.jobs.status, { jobId });
|
|
|
119
125
|
|
|
120
126
|
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
127
|
|
|
128
|
+
## Agent skill
|
|
129
|
+
|
|
130
|
+
The package ships a versioned [`trigger-convex` skill](https://github.com/triggerdotdev/trigger.dev/blob/main/packages/convex/skills/trigger-convex/SKILL.md) covering transactional triggering, owned reactive queries, callbacks, Realtime + refresh, reload recovery, and token safety.
|
|
131
|
+
|
|
132
|
+
Ask your coding assistant to read `node_modules/@trigger.dev/convex/skills/trigger-convex/SKILL.md`, or copy it into your assistant's native skill directory. For example, from a project with this package installed:
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
mkdir -p .agents/skills/trigger-convex
|
|
136
|
+
cp node_modules/@trigger.dev/convex/skills/trigger-convex/SKILL.md .agents/skills/trigger-convex/SKILL.md
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Use `.claude/skills/`, `.cursor/skills/`, or `.github/skills/` instead if appropriate for your assistant. Re-copy after upgrading `@trigger.dev/convex`; copied files do not update automatically. For non-hoisted installs, locate the package with `node -p "require.resolve('@trigger.dev/convex/package.json')"`.
|
|
140
|
+
|
|
141
|
+
This skill is installed manually, not through `npx trigger.dev@latest skills`. Older package versions may not contain it.
|
|
142
|
+
|
|
122
143
|
## API
|
|
123
144
|
|
|
124
145
|
- `trigger(ctx, taskId, payload, options?)`: enqueue work. Calling from a mutation makes the request atomic with your application's writes.
|
|
125
146
|
- `getRun(ctx, id)`: read one record reactively.
|
|
126
147
|
- `listRuns(ctx, paginationOpts)`: paginate records, newest first, up to 100 per page and a bounded read budget.
|
|
148
|
+
- `refresh(ctx, id)`: schedule an earlier status check for an active, submitted run. Returns `null` after scheduling, not the updated run.
|
|
127
149
|
- `resume(ctx, id)`: resume paused synchronization using the current credentials.
|
|
128
150
|
|
|
129
151
|
Trigger options include tags, delay, queue name, concurrency key, machine, metadata, max attempts, max duration, TTL, and a component-level idempotency key.
|
|
@@ -134,6 +156,35 @@ The constructor accepts `secretKey` (defaults to `TRIGGER_SECRET_KEY`), `baseUrl
|
|
|
134
156
|
|
|
135
157
|
**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
158
|
|
|
159
|
+
## Realtime progress and faster result delivery
|
|
160
|
+
|
|
161
|
+
Use [Trigger Realtime](https://trigger.dev/docs/realtime/overview) for responsive browser progress, and Convex queries for durable application state. When Realtime reports that a run is complete, call an ownership-checked application mutation that requests a component refresh:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
// convex/jobs.ts
|
|
165
|
+
export const refresh = mutation({
|
|
166
|
+
args: { jobId: v.id("jobs") },
|
|
167
|
+
handler: async (ctx, { jobId }) => {
|
|
168
|
+
const user = await ctx.auth.getUserIdentity();
|
|
169
|
+
const job = await ctx.db.get(jobId);
|
|
170
|
+
if (!user || !job || job.owner !== user.subject) throw new Error("Unauthorized");
|
|
171
|
+
if (job.runId) await trigger.refresh(ctx, job.runId);
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Pass the component record ID to `refresh`, not the remote Trigger run ID. Your browser subscribes to the latter, available as `run.runId` from `getRun`.
|
|
177
|
+
|
|
178
|
+
A refresh is only a hint: the component retrieves status and output directly from Trigger and uses the same validation, completion callback, and credential cleanup as normal polling. It never accepts client-supplied status or output. Observe `getRun` or your application query for the persisted result; awaiting `refresh` does not wait for that result.
|
|
179
|
+
|
|
180
|
+
- Refresh requests are coalesced per run. Status checks requested this way start at least five seconds after the previous status check.
|
|
181
|
+
- A hint received during a check queues one follow-up instead of interrupting or discarding the in-flight response. If that response completes the run, no follow-up is needed.
|
|
182
|
+
- Pending submissions, paused synchronization, and completed records are no-ops. Refresh never submits a task or resumes paused work; use `resume` after repairing a paused synchronization.
|
|
183
|
+
- Call refresh when Realtime observes completion, including an already-completed run after reconnecting. Do not call it on every metadata update to animate progress.
|
|
184
|
+
- Scheduled polling and its watchdog remain enabled. Convex can still finish the run if the browser closes, the subscription disconnects, or the refresh request fails. No persistent subscription inside a Convex action is required.
|
|
185
|
+
|
|
186
|
+
**Realtime token safety:** authenticate the token request, verify job ownership, and issue a short-lived token scoped to the exact remote run. Keep the root Trigger key server-side. A run-read token can also read the run through REST, including its payload; Realtime `skipColumns` is not a restriction on REST access. Keep worker credentials, storage-upload capabilities, and other secrets out of token-readable payloads, outputs, metadata, and errors. Application mutations must enforce ownership even when component refreshes are coalesced.
|
|
187
|
+
|
|
137
188
|
## Execution and reliability
|
|
138
189
|
|
|
139
190
|
1. The calling mutation writes a component record and schedules submission. If the transaction rolls back, neither survives.
|
package/dist/client/index.d.ts
CHANGED
|
@@ -30,5 +30,10 @@ export declare class Trigger {
|
|
|
30
30
|
isDone: boolean;
|
|
31
31
|
continueCursor: string;
|
|
32
32
|
}>;
|
|
33
|
+
/**
|
|
34
|
+
* Request an earlier status check without accepting client-supplied run data.
|
|
35
|
+
* The check is scheduled, coalesced, and rate-limited; observe getRun for its result.
|
|
36
|
+
*/
|
|
37
|
+
refresh(ctx: MutationCtx, id: string): Promise<null>;
|
|
33
38
|
resume(ctx: MutationCtx, id: string): Promise<null>;
|
|
34
39
|
}
|
package/dist/client/index.js
CHANGED
|
@@ -39,6 +39,13 @@ export class Trigger {
|
|
|
39
39
|
async listRuns(ctx, paginationOpts) {
|
|
40
40
|
return ctx.runQuery(this.component.runs.list, { paginationOpts });
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Request an earlier status check without accepting client-supplied run data.
|
|
44
|
+
* The check is scheduled, coalesced, and rate-limited; observe getRun for its result.
|
|
45
|
+
*/
|
|
46
|
+
async refresh(ctx, id) {
|
|
47
|
+
return ctx.runMutation(this.component.runs.refresh, { id });
|
|
48
|
+
}
|
|
42
49
|
async resume(ctx, id) {
|
|
43
50
|
return ctx.runMutation(this.component.runs.resume, { id, config: this.runtimeConfig() });
|
|
44
51
|
}
|
package/dist/component/runs.d.ts
CHANGED
|
@@ -58,6 +58,10 @@ export declare const list: import("convex/server").RegisteredQuery<"public", {
|
|
|
58
58
|
isDone: boolean;
|
|
59
59
|
continueCursor: string;
|
|
60
60
|
}>>;
|
|
61
|
+
export declare function scheduleRefresh(ctx: GenericMutationCtx<DataModel>, run: RunDoc): Promise<void>;
|
|
62
|
+
export declare const refresh: import("convex/server").RegisteredMutation<"public", {
|
|
63
|
+
id: string;
|
|
64
|
+
}, Promise<null>>;
|
|
61
65
|
export declare const resume: import("convex/server").RegisteredMutation<"public", {
|
|
62
66
|
config: {
|
|
63
67
|
baseUrl: string;
|
|
@@ -72,15 +76,18 @@ export declare const load: import("convex/server").RegisteredQuery<"internal", {
|
|
|
72
76
|
_creationTime: number;
|
|
73
77
|
_id: import("convex/values").GenericId<"runs">;
|
|
74
78
|
callback?: string | undefined;
|
|
79
|
+
checking?: boolean | undefined;
|
|
75
80
|
checks: number;
|
|
76
81
|
error?: any;
|
|
77
82
|
failures: number;
|
|
78
83
|
generation: number;
|
|
79
84
|
idempotencyKey?: string | undefined;
|
|
85
|
+
lastCheckAt?: number | undefined;
|
|
80
86
|
metadata?: any;
|
|
81
87
|
output?: any;
|
|
82
88
|
outputOmitted?: boolean | undefined;
|
|
83
89
|
outputPresignedUrl?: string | undefined;
|
|
90
|
+
refreshRequested?: boolean | undefined;
|
|
84
91
|
runId?: string | undefined;
|
|
85
92
|
status: string;
|
|
86
93
|
syncError?: string | undefined;
|
package/dist/component/runs.js
CHANGED
|
@@ -2,9 +2,9 @@ import { paginationOptsValidator, } from "convex/server";
|
|
|
2
2
|
import { v } from "convex/values";
|
|
3
3
|
import { internal } from "./api.js";
|
|
4
4
|
import { internalMutation, internalQuery, mutation, query } from "./server.js";
|
|
5
|
-
import { MAX_INLINE_BYTES, MAX_PAGE_BYTES, SUBMISSION_WINDOW_MS, vConfig, vRun, vTriggerOptions, } from "./shared.js";
|
|
5
|
+
import { MAX_INLINE_BYTES, MAX_PAGE_BYTES, MIN_REFRESH_INTERVAL_MS, SUBMISSION_WINDOW_MS, vConfig, vRun, vTriggerOptions, } from "./shared.js";
|
|
6
6
|
export function toRun(run) {
|
|
7
|
-
const { _id, _creationTime, generation, checks, failures, callback, ...fields } = run;
|
|
7
|
+
const { _id, _creationTime, generation, checks, failures, callback, checking, refreshRequested, lastCheckAt, ...fields } = run;
|
|
8
8
|
return { id: _id, createdAt: _creationTime, ...fields };
|
|
9
9
|
}
|
|
10
10
|
export const trigger = mutation({
|
|
@@ -83,6 +83,34 @@ export const list = query({
|
|
|
83
83
|
};
|
|
84
84
|
},
|
|
85
85
|
});
|
|
86
|
+
export async function scheduleRefresh(ctx, run) {
|
|
87
|
+
if (!run.refreshRequested)
|
|
88
|
+
return;
|
|
89
|
+
const delay = run.lastCheckAt === undefined
|
|
90
|
+
? 0
|
|
91
|
+
: Math.max(0, run.lastCheckAt + MIN_REFRESH_INTERVAL_MS - Date.now());
|
|
92
|
+
await ctx.scheduler.runAfter(delay, internal.worker.tick, {
|
|
93
|
+
id: run._id,
|
|
94
|
+
generation: run.generation,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
export const refresh = mutation({
|
|
98
|
+
args: { id: v.string() },
|
|
99
|
+
returns: v.null(),
|
|
100
|
+
handler: async (ctx, { id }) => {
|
|
101
|
+
const runId = ctx.db.normalizeId("runs", id);
|
|
102
|
+
const run = runId ? await ctx.db.get("runs", runId) : null;
|
|
103
|
+
if (!run)
|
|
104
|
+
throw new Error("Run not found");
|
|
105
|
+
if (!run.runId || run.syncStatus !== "active" || run.refreshRequested)
|
|
106
|
+
return null;
|
|
107
|
+
await ctx.db.patch(run._id, { refreshRequested: true });
|
|
108
|
+
// Preserve the in-flight observation; its result will schedule any requested follow-up.
|
|
109
|
+
if (!run.checking)
|
|
110
|
+
await scheduleRefresh(ctx, { ...run, refreshRequested: true });
|
|
111
|
+
return null;
|
|
112
|
+
},
|
|
113
|
+
});
|
|
86
114
|
export const resume = mutation({
|
|
87
115
|
args: { id: v.string(), config: vConfig },
|
|
88
116
|
returns: v.null(),
|
|
@@ -110,6 +138,8 @@ export const resume = mutation({
|
|
|
110
138
|
failures: 0,
|
|
111
139
|
checks: 0,
|
|
112
140
|
generation,
|
|
141
|
+
checking: undefined,
|
|
142
|
+
refreshRequested: undefined,
|
|
113
143
|
updatedAt: Date.now(),
|
|
114
144
|
});
|
|
115
145
|
await ctx.scheduler.runAfter(0, internal.worker.tick, { id: run._id, generation });
|
|
@@ -141,6 +171,7 @@ export const recordSubmission = internalMutation({
|
|
|
141
171
|
syncError: undefined,
|
|
142
172
|
failures: 0,
|
|
143
173
|
checks: 0,
|
|
174
|
+
checking: undefined,
|
|
144
175
|
updatedAt: Date.now(),
|
|
145
176
|
});
|
|
146
177
|
return null;
|
|
@@ -168,6 +199,8 @@ export const recordStatus = internalMutation({
|
|
|
168
199
|
syncStatus: completed ? "complete" : "active",
|
|
169
200
|
syncError: undefined,
|
|
170
201
|
failures: 0,
|
|
202
|
+
checking: undefined,
|
|
203
|
+
refreshRequested: completed ? undefined : run.refreshRequested,
|
|
171
204
|
updatedAt: Date.now(),
|
|
172
205
|
};
|
|
173
206
|
await ctx.db.patch("runs", id, updates);
|
|
@@ -184,6 +217,9 @@ export const recordStatus = internalMutation({
|
|
|
184
217
|
if (request)
|
|
185
218
|
await ctx.db.delete("requests", request._id);
|
|
186
219
|
}
|
|
220
|
+
else {
|
|
221
|
+
await scheduleRefresh(ctx, run);
|
|
222
|
+
}
|
|
187
223
|
return null;
|
|
188
224
|
},
|
|
189
225
|
});
|
|
@@ -191,6 +227,8 @@ export async function pause(ctx, run, message) {
|
|
|
191
227
|
await ctx.db.patch("runs", run._id, {
|
|
192
228
|
syncStatus: "paused",
|
|
193
229
|
syncError: message,
|
|
230
|
+
checking: undefined,
|
|
231
|
+
refreshRequested: undefined,
|
|
194
232
|
updatedAt: Date.now(),
|
|
195
233
|
});
|
|
196
234
|
}
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
declare const _default: import("convex/server").SchemaDefinition<{
|
|
2
2
|
runs: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
3
3
|
callback?: string | undefined;
|
|
4
|
+
checking?: boolean | undefined;
|
|
4
5
|
checks: number;
|
|
5
6
|
error?: any;
|
|
6
7
|
failures: number;
|
|
7
8
|
generation: number;
|
|
8
9
|
idempotencyKey?: string | undefined;
|
|
10
|
+
lastCheckAt?: number | undefined;
|
|
9
11
|
metadata?: any;
|
|
10
12
|
output?: any;
|
|
11
13
|
outputOmitted?: boolean | undefined;
|
|
12
14
|
outputPresignedUrl?: string | undefined;
|
|
15
|
+
refreshRequested?: boolean | undefined;
|
|
13
16
|
runId?: string | undefined;
|
|
14
17
|
status: string;
|
|
15
18
|
syncError?: string | undefined;
|
|
@@ -32,8 +35,11 @@ declare const _default: import("convex/server").SchemaDefinition<{
|
|
|
32
35
|
generation: import("convex/values").VFloat64<number, "required">;
|
|
33
36
|
checks: import("convex/values").VFloat64<number, "required">;
|
|
34
37
|
failures: import("convex/values").VFloat64<number, "required">;
|
|
38
|
+
checking: import("convex/values").VBoolean<boolean | undefined, "optional">;
|
|
39
|
+
refreshRequested: import("convex/values").VBoolean<boolean | undefined, "optional">;
|
|
40
|
+
lastCheckAt: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
35
41
|
callback: import("convex/values").VString<string | undefined, "optional">;
|
|
36
|
-
}, "required", "callback" | "checks" | "error" | "failures" | "generation" | "idempotencyKey" | "metadata" | "output" | "outputOmitted" | "outputPresignedUrl" | "runId" | "status" | "syncError" | "syncStatus" | "taskId" | "updatedAt" | `error.${string}` | `metadata.${string}` | `output.${string}`>, {
|
|
42
|
+
}, "required", "callback" | "checking" | "checks" | "error" | "failures" | "generation" | "idempotencyKey" | "lastCheckAt" | "metadata" | "output" | "outputOmitted" | "outputPresignedUrl" | "refreshRequested" | "runId" | "status" | "syncError" | "syncStatus" | "taskId" | "updatedAt" | `error.${string}` | `metadata.${string}` | `output.${string}`>, {
|
|
37
43
|
by_key: ["idempotencyKey", "_creationTime"];
|
|
38
44
|
}, {}, {}>;
|
|
39
45
|
requests: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
package/dist/component/schema.js
CHANGED
|
@@ -7,6 +7,9 @@ export default defineSchema({
|
|
|
7
7
|
generation: v.number(),
|
|
8
8
|
checks: v.number(),
|
|
9
9
|
failures: v.number(),
|
|
10
|
+
checking: v.optional(v.boolean()),
|
|
11
|
+
refreshRequested: v.optional(v.boolean()),
|
|
12
|
+
lastCheckAt: v.optional(v.number()),
|
|
10
13
|
callback: v.optional(v.string()),
|
|
11
14
|
}).index("by_key", ["idempotencyKey"]),
|
|
12
15
|
requests: defineTable({
|
|
@@ -82,6 +82,7 @@ export type Run = Infer<typeof vRun>;
|
|
|
82
82
|
export type TriggerOptions = Infer<typeof vTriggerOptions>;
|
|
83
83
|
export type Config = Infer<typeof vConfig>;
|
|
84
84
|
export declare const REQUEST_TIMEOUT_MS = 10000;
|
|
85
|
+
export declare const MIN_REFRESH_INTERVAL_MS = 5000;
|
|
85
86
|
export declare const WATCHDOG_MARGIN_MS = 5000;
|
|
86
87
|
export declare const MAX_SYNC_FAILURES = 8;
|
|
87
88
|
export declare const SUBMISSION_WINDOW_MS: number;
|
package/dist/component/shared.js
CHANGED
|
@@ -36,6 +36,7 @@ export const vRun = v.object({
|
|
|
36
36
|
const FIRST_POLL_MS = 15_000;
|
|
37
37
|
const MAX_POLL_MS = 60_000;
|
|
38
38
|
export const REQUEST_TIMEOUT_MS = 10_000;
|
|
39
|
+
export const MIN_REFRESH_INTERVAL_MS = 5_000;
|
|
39
40
|
export const WATCHDOG_MARGIN_MS = 5_000;
|
|
40
41
|
export const MAX_SYNC_FAILURES = 8;
|
|
41
42
|
export const SUBMISSION_WINDOW_MS = 60 * 60 * 1_000;
|
package/dist/component/worker.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
2
|
import { internal } from "./api.js";
|
|
3
3
|
import { internalAction, internalMutation } from "./server.js";
|
|
4
|
-
import { pause } from "./runs.js";
|
|
4
|
+
import { pause, scheduleRefresh } from "./runs.js";
|
|
5
5
|
import { ApiError, inlineValue, request } from "./apiClient.js";
|
|
6
6
|
import { IDEMPOTENCY_TTL, MAX_SYNC_FAILURES, REQUEST_TIMEOUT_MS, SUBMISSION_WINDOW_MS, WATCHDOG_MARGIN_MS, pollDelay, } from "./shared.js";
|
|
7
7
|
const jobArgs = { id: v.id("runs"), generation: v.number() };
|
|
@@ -20,7 +20,13 @@ export const tick = internalMutation({
|
|
|
20
20
|
return null;
|
|
21
21
|
}
|
|
22
22
|
const nextGeneration = generation + 1;
|
|
23
|
-
await ctx.db.patch("runs", id, {
|
|
23
|
+
await ctx.db.patch("runs", id, {
|
|
24
|
+
generation: nextGeneration,
|
|
25
|
+
checks: run.checks + 1,
|
|
26
|
+
checking: true,
|
|
27
|
+
refreshRequested: undefined,
|
|
28
|
+
lastCheckAt: run.runId ? Date.now() : run.lastCheckAt,
|
|
29
|
+
});
|
|
24
30
|
// Schedule the watchdog transactionally before starting an action that can be interrupted.
|
|
25
31
|
await ctx.scheduler.runAfter(Math.max(pollDelay(run.checks), REQUEST_TIMEOUT_MS + WATCHDOG_MARGIN_MS), internal.worker.tick, { id, generation: nextGeneration });
|
|
26
32
|
await ctx.scheduler.runAfter(0, internal.worker.step, { id, generation: nextGeneration });
|
|
@@ -97,9 +103,16 @@ export const recordError = internalMutation({
|
|
|
97
103
|
if (!run || run.generation !== generation || run.syncStatus === "complete")
|
|
98
104
|
return null;
|
|
99
105
|
const failures = run.failures + 1;
|
|
100
|
-
await ctx.db.patch("runs", id, {
|
|
106
|
+
await ctx.db.patch("runs", id, {
|
|
107
|
+
failures,
|
|
108
|
+
syncError: message,
|
|
109
|
+
checking: undefined,
|
|
110
|
+
updatedAt: Date.now(),
|
|
111
|
+
});
|
|
101
112
|
if (permanent || failures >= MAX_SYNC_FAILURES)
|
|
102
113
|
await pause(ctx, run, message);
|
|
114
|
+
else
|
|
115
|
+
await scheduleRefresh(ctx, run);
|
|
103
116
|
return null;
|
|
104
117
|
},
|
|
105
118
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trigger.dev/convex",
|
|
3
|
-
"version": "4.8.
|
|
3
|
+
"version": "4.8.4",
|
|
4
4
|
"description": "Run Trigger.dev tasks from Convex mutations and query their results reactively",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
"files": [
|
|
16
16
|
"dist",
|
|
17
17
|
"src/component",
|
|
18
|
-
"
|
|
18
|
+
"skills/*/SKILL.md",
|
|
19
|
+
"!src/component/**/*.test.ts",
|
|
20
|
+
"!example/**"
|
|
19
21
|
],
|
|
20
22
|
"exports": {
|
|
21
23
|
".": {
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: trigger-convex
|
|
3
|
+
description: >
|
|
4
|
+
Integrates Trigger.dev background tasks with Convex using @trigger.dev/convex.
|
|
5
|
+
Use when triggering work transactionally from a Convex mutation, showing reactive
|
|
6
|
+
run state, handling completion callbacks, combining Trigger Realtime with Convex,
|
|
7
|
+
restoring jobs after reload, or repairing paused synchronization. Covers ownership,
|
|
8
|
+
idempotency, scoped browser tokens, and credential-safe task payloads. Not for
|
|
9
|
+
Convex-only applications or Trigger.dev applications without Convex.
|
|
10
|
+
license: Apache-2.0
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Trigger.dev with Convex
|
|
14
|
+
|
|
15
|
+
## Check the installed contract first
|
|
16
|
+
|
|
17
|
+
Locate `@trigger.dev/convex/package.json`, then read the adjacent `README.md` and
|
|
18
|
+
`dist/client/index.d.ts`. In a non-hoisted installation, resolve the package with:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
node -p "require.resolve('@trigger.dev/convex/package.json')"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Use the installed declarations as authority. Older releases do not expose
|
|
25
|
+
`refresh()`. If it is absent, use durable polling or upgrade to a compatible
|
|
26
|
+
release; do not invent a replacement method or accept browser-supplied run state.
|
|
27
|
+
Keep the application's Convex version compatible with the package's peer dependency.
|
|
28
|
+
Do not pin an arbitrary preview URL unless the user explicitly needs that preview.
|
|
29
|
+
|
|
30
|
+
Choose the relevant flow:
|
|
31
|
+
|
|
32
|
+
| Request | Approach |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| Enqueue a report, render, import, or AI task | Call `trigger.trigger` inside the application's authenticated mutation. |
|
|
35
|
+
| Display durable status/results | Authorize the application's job, then call `trigger.getRun` from a query. |
|
|
36
|
+
| Update application records on completion | Register a short internal mutation using `vRun`. |
|
|
37
|
+
| Display responsive browser progress | Subscribe to Trigger Realtime; request an authorized Convex refresh on terminal events. |
|
|
38
|
+
| Restore an edit after reload or leaving the page | Persist the selected application job/request ID and inputs; recover owned work from Convex. |
|
|
39
|
+
| Recover paused status synchronization | Repair credentials/callback failures, then call authorized `resume`; do not enqueue another task. |
|
|
40
|
+
|
|
41
|
+
## Install and configure
|
|
42
|
+
|
|
43
|
+
Install `@trigger.dev/convex` and `convex`. Deploy the actual task separately with
|
|
44
|
+
Trigger.dev; the component does not deploy task code. Keep `TRIGGER_SECRET_KEY`
|
|
45
|
+
only in the Convex server environment, for the intended Trigger environment.
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
// convex/convex.config.ts
|
|
49
|
+
import { defineApp } from "convex/server";
|
|
50
|
+
import triggerComponent from "@trigger.dev/convex/convex.config.js";
|
|
51
|
+
|
|
52
|
+
const app = defineApp();
|
|
53
|
+
app.use(triggerComponent);
|
|
54
|
+
export default app;
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Run `npx convex dev` to generate the application's component references.
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
// convex/trigger.ts
|
|
61
|
+
import { Trigger } from "@trigger.dev/convex";
|
|
62
|
+
import { components } from "./_generated/api";
|
|
63
|
+
|
|
64
|
+
export const trigger: Trigger = new Trigger(components.trigger);
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Constructor options are `secretKey`, `baseUrl`, and `onComplete`. The default API
|
|
68
|
+
is `https://api.trigger.dev`; `TRIGGER_API_URL` is not automatically read by the
|
|
69
|
+
component. Set `baseUrl` explicitly for a different API host. HTTP is allowed only
|
|
70
|
+
for localhost development. Keep server keys out of `VITE_`/`NEXT_PUBLIC_` variables.
|
|
71
|
+
|
|
72
|
+
## Enqueue atomically and authorize reads
|
|
73
|
+
|
|
74
|
+
Maintain an application-owned job table. The component has no user/tenant model.
|
|
75
|
+
This example assumes an existing deployed `process-text` task that accepts `{ text }`.
|
|
76
|
+
Validate business limits and authorize any referenced documents/files before enqueueing.
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
// convex/schema.ts
|
|
80
|
+
import { defineSchema, defineTable } from "convex/server";
|
|
81
|
+
import { v } from "convex/values";
|
|
82
|
+
|
|
83
|
+
export default defineSchema({
|
|
84
|
+
jobs: defineTable({
|
|
85
|
+
owner: v.string(),
|
|
86
|
+
requestId: v.string(),
|
|
87
|
+
text: v.string(),
|
|
88
|
+
componentRunId: v.optional(v.string()),
|
|
89
|
+
}).index("by_owner_request", ["owner", "requestId"])
|
|
90
|
+
.index("by_componentRunId", ["componentRunId"]),
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
// convex/jobs.ts
|
|
96
|
+
import { vRun } from "@trigger.dev/convex";
|
|
97
|
+
import { v } from "convex/values";
|
|
98
|
+
import { mutation, query } from "./_generated/server";
|
|
99
|
+
import { trigger } from "./trigger";
|
|
100
|
+
|
|
101
|
+
export const request = mutation({
|
|
102
|
+
args: { text: v.string(), requestId: v.string() },
|
|
103
|
+
returns: v.id("jobs"),
|
|
104
|
+
handler: async (ctx, args) => {
|
|
105
|
+
const user = await ctx.auth.getUserIdentity();
|
|
106
|
+
if (!user) throw new Error("Unauthorized");
|
|
107
|
+
const owner = user.tokenIdentifier;
|
|
108
|
+
const existing = await ctx.db.query("jobs")
|
|
109
|
+
.withIndex("by_owner_request", q => q.eq("owner", owner).eq("requestId", args.requestId))
|
|
110
|
+
.unique();
|
|
111
|
+
if (existing) {
|
|
112
|
+
if (existing.text !== args.text) throw new Error("Request ID reused for different input");
|
|
113
|
+
return existing._id;
|
|
114
|
+
}
|
|
115
|
+
const jobId = await ctx.db.insert("jobs", { owner, ...args });
|
|
116
|
+
const componentRunId = await trigger.trigger(ctx, "process-text", { text: args.text }, {
|
|
117
|
+
idempotencyKey: `process-text:${owner}:${args.requestId}`,
|
|
118
|
+
});
|
|
119
|
+
await ctx.db.patch(jobId, { componentRunId });
|
|
120
|
+
return jobId;
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export const status = query({
|
|
125
|
+
args: { jobId: v.id("jobs") },
|
|
126
|
+
returns: v.union(vRun, v.null()),
|
|
127
|
+
handler: async (ctx, { jobId }) => {
|
|
128
|
+
const user = await ctx.auth.getUserIdentity();
|
|
129
|
+
const job = await ctx.db.get(jobId);
|
|
130
|
+
if (!user || !job || job.owner !== user.tokenIdentifier) return null;
|
|
131
|
+
return job.componentRunId ? trigger.getRun(ctx, job.componentRunId) : null;
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Preserve the same request ID when retrying an unconfirmed submission. Namespace
|
|
137
|
+
component idempotency keys by tenant/owner, task, and logical request. They dedupe
|
|
138
|
+
across the entire component instance, not just a task, and do not compare payloads.
|
|
139
|
+
An application mutation rollback also rolls back the component enqueue/scheduling.
|
|
140
|
+
Do not replace this with a direct SDK trigger from an action if atomicity is required.
|
|
141
|
+
Use type-only task imports when adding task payload inference; do not bundle task
|
|
142
|
+
implementations, Sharp, or provider clients into the Convex mutation runtime.
|
|
143
|
+
|
|
144
|
+
There are three distinct identifiers:
|
|
145
|
+
|
|
146
|
+
- Application `jobId`: accept this from the browser and enforce ownership.
|
|
147
|
+
- Component `run.id`: returned by `trigger.trigger`; pass it to `getRun`, `refresh`, and `resume`.
|
|
148
|
+
- Remote `run.runId`: available after submission; subscribe to this with Trigger Realtime.
|
|
149
|
+
|
|
150
|
+
For React, use an application query such as `useQuery(api.jobs.status, { jobId })`.
|
|
151
|
+
Only persist/display results verified by Convex. `getRun` omits stored credentials
|
|
152
|
+
and request payloads, but its output, errors, and metadata can still contain private data.
|
|
153
|
+
Do not expose `listRuns` as a global client-visible feed; use indexed owned jobs and
|
|
154
|
+
bounded lookups instead.
|
|
155
|
+
|
|
156
|
+
## Apply completion to application state
|
|
157
|
+
|
|
158
|
+
When an application record must change on completion, extend its schema with the
|
|
159
|
+
task-specific status/result fields, then add an internal mutation with
|
|
160
|
+
`args: { run: vRun }` and `returns: v.null()`. Find its job through the
|
|
161
|
+
`by_componentRunId` index using `run.id`, validate the task's output, and update it.
|
|
162
|
+
Configure the client with `onComplete: internal.jobs.complete`; keep the explicit
|
|
163
|
+
`Trigger` type annotation to avoid circular inference through the generated API.
|
|
164
|
+
|
|
165
|
+
Keep the callback short and transactional. It runs with the component's terminal
|
|
166
|
+
update; throwing rolls both back and schedules a retry. Do network I/O in separate
|
|
167
|
+
actions, not in the callback mutation. A callback is optional for reactive reads.
|
|
168
|
+
Treat omitted output and expiring output URLs explicitly rather than assuming every
|
|
169
|
+
completed run has an inline result.
|
|
170
|
+
|
|
171
|
+
## Combine Realtime progress with authoritative Convex state
|
|
172
|
+
|
|
173
|
+
Install `@trigger.dev/react-hooks` in a React frontend and `@trigger.dev/sdk` in the
|
|
174
|
+
server that mints tokens. These are separate from the component-only polling setup.
|
|
175
|
+
|
|
176
|
+
Only use the following wrapper if the installed component exposes `refresh()`:
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
// Add to convex/jobs.ts
|
|
180
|
+
export const refresh = mutation({
|
|
181
|
+
args: { jobId: v.id("jobs") },
|
|
182
|
+
returns: v.null(),
|
|
183
|
+
handler: async (ctx, { jobId }) => {
|
|
184
|
+
const user = await ctx.auth.getUserIdentity();
|
|
185
|
+
const job = await ctx.db.get(jobId);
|
|
186
|
+
if (!user || !job || job.owner !== user.tokenIdentifier) throw new Error("Unauthorized");
|
|
187
|
+
if (job.componentRunId) await trigger.refresh(ctx, job.componentRunId);
|
|
188
|
+
return null;
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
1. Authenticate a server-side token request and verify the application job's owner.
|
|
194
|
+
Derive its remote run ID on the server; never grant access to a caller-chosen run.
|
|
195
|
+
2. Mint a short-lived `auth.createPublicToken` scoped to `read: { runs: [runId] }`.
|
|
196
|
+
Use an SDK-capable server runtime, such as a Convex Node action, and scoped
|
|
197
|
+
`auth.withAuth` configuration rather than mutating shared global credentials.
|
|
198
|
+
The SDK spells its API host option `baseURL`, unlike the component's `baseUrl`.
|
|
199
|
+
Match the key/host environment, bound expiry to the application's access lifetime,
|
|
200
|
+
and return only the token, remote ID, expiry, and required public connection URL.
|
|
201
|
+
3. Subscribe with `useRealtimeRun` or `runs.subscribeToRun` only after the remote ID
|
|
202
|
+
exists. Limit the browser to the selected owned run, renew credentials through
|
|
203
|
+
the authorized endpoint, and clean up on job/session changes. Reject late token
|
|
204
|
+
responses and metadata for a previous selection.
|
|
205
|
+
4. On completion, including an already-terminal snapshot after reconnecting, call
|
|
206
|
+
the owned `refresh` mutation. Include failed, canceled, expired, and timed-out
|
|
207
|
+
runs. Do not refresh on every metadata update or retry a failed effect in a loop.
|
|
208
|
+
5. Observe the Convex query for persisted status/results. A refresh returns `null`
|
|
209
|
+
after scheduling; it does not wait for completion or return a new run snapshot.
|
|
210
|
+
Keep scheduled polling/watchdog recovery enabled when the browser goes away.
|
|
211
|
+
|
|
212
|
+
Refresh hints are coalesced per run, with refresh-driven checks at least five seconds
|
|
213
|
+
after the previous status check. An in-flight hint queues a follow-up without
|
|
214
|
+
invalidating that observation. Pending submissions, paused synchronization, and
|
|
215
|
+
completed records are no-ops; refresh neither submits work nor resumes paused work.
|
|
216
|
+
|
|
217
|
+
**Token safety:** a run-read token also authorizes REST reads of its payload.
|
|
218
|
+
Realtime `skipColumns: ["payload", "output"]` reduces streamed data; it does not
|
|
219
|
+
restrict REST access. Remove worker keys, upload capabilities, and other credentials
|
|
220
|
+
from token-readable payloads, outputs, metadata, and errors before issuing browser
|
|
221
|
+
tokens. Do not mint tokens for legacy runs that still contain credentials; deploying
|
|
222
|
+
new code or changing column filters does not rewrite old remote payloads. Derive worker authorization server-side or use server-only signing instead.
|
|
223
|
+
Authorize private artifact delivery separately; a public run token is not a file
|
|
224
|
+
access policy. Keep browser run tokens in memory and never log credentials.
|
|
225
|
+
|
|
226
|
+
Persist the selected application job ID, draft inputs, and unconfirmed request ID
|
|
227
|
+
before sending a request. Restore owned active jobs from Convex after reload;
|
|
228
|
+
otherwise restore only explicit selections, not any old result with matching defaults.
|
|
229
|
+
Use actual task metadata for phase progress, explain disabled submission states,
|
|
230
|
+
and do not publish results solely because Realtime reports completion.
|
|
231
|
+
|
|
232
|
+
## Troubleshoot without duplicating work
|
|
233
|
+
|
|
234
|
+
| Symptom | Fix |
|
|
235
|
+
| --- | --- |
|
|
236
|
+
| Duplicate jobs after a lost response | Reuse the logical request ID and query the owned job before enqueueing again. |
|
|
237
|
+
| Wrong run or a null remote ID | Distinguish application/component/remote IDs; wait for confirmed submission before subscribing. |
|
|
238
|
+
| Browser refresh resolves but results lag | Observe Convex; refresh is asynchronous, coalesced, and rate-limited. |
|
|
239
|
+
| Component/validator types are incompatible | Check for duplicate Convex installations or peer contexts; align component/consumer dependencies rather than hiding mismatches with casts. |
|
|
240
|
+
| `refresh` is missing | Check installed declarations; upgrade deliberately or retain polling, not a fabricated API. |
|
|
241
|
+
| `syncStatus === "paused"` | Inspect `syncError`, repair credentials/callbacks, then authorize `resume(ctx, componentId)`. |
|
|
242
|
+
| Old work disappears after reload | Persist selection/inputs/request ID; recover owned active work instead of resetting to defaults. |
|
|
243
|
+
| Token leaks worker access | Remove credentials from all readable run fields; column filtering cannot repair this. |
|
|
244
|
+
| Data from another tenant appears | Authorize every query/mutation/token/artifact request; never trust arbitrary IDs or shared tags. |
|
|
245
|
+
| Successful remote work never commits | Check callback errors and output validation; retries roll back application completion too. |
|
|
246
|
+
| Large or missing results | Use storage references; inspect `outputOmitted`/`outputPresignedUrl` and refresh expired URLs server-side. |
|
|
247
|
+
| Run exists in a different environment | Align API host/key; resume does not migrate remote runs between environments. |
|
|
248
|
+
|
|
249
|
+
Polling normally backs off from about 15 to 60 seconds. Account for Convex executions
|
|
250
|
+
and Trigger API requests at scale; use Realtime for responsive UI, not rapid polling.
|
|
251
|
+
Payload plus trigger options must fit within 256 KiB, as must each inline output,
|
|
252
|
+
error, and metadata value. Component records are retained; paused requests retain
|
|
253
|
+
credentials and payloads for recovery, so plan application retention separately.
|
|
254
|
+
Remote submission uses a 24-hour idempotency TTL, but automatic submission retries
|
|
255
|
+
stop within one hour. After that window, reconcile remotely before creating new work;
|
|
256
|
+
do not claim unlimited exactly-once execution or blindly retry expensive tasks.
|
|
257
|
+
|
|
258
|
+
## Verify the integration
|
|
259
|
+
|
|
260
|
+
Check mutation rollback, duplicate request IDs, cross-owner denial, callback failures,
|
|
261
|
+
and reconnecting to an already-terminal run. Verify that leaving the page does not
|
|
262
|
+
prevent durable completion. If tokens are issued, test cross-run denial and inspect
|
|
263
|
+
the full REST-readable payload for credentials. Add focused integration coverage;
|
|
264
|
+
do not spend on real provider calls or deploy without the user's authorization.
|
|
@@ -6,7 +6,7 @@ import { defineSchema, defineTable } from "convex/server";
|
|
|
6
6
|
import { v } from "convex/values";
|
|
7
7
|
import { Trigger, vRun, type ComponentApi } from "../client/index.js";
|
|
8
8
|
import schema from "./schema.js";
|
|
9
|
-
import { MAX_INLINE_BYTES } from "./shared.js";
|
|
9
|
+
import { MAX_INLINE_BYTES, MIN_REFRESH_INTERVAL_MS } from "./shared.js";
|
|
10
10
|
|
|
11
11
|
const modules = {
|
|
12
12
|
"./_generated/component.ts": () => import("./_generated/component.js"),
|
|
@@ -63,6 +63,10 @@ const { createTest, api, internal } = app.defineModules({
|
|
|
63
63
|
return id;
|
|
64
64
|
},
|
|
65
65
|
}),
|
|
66
|
+
refresh: app.mutation({
|
|
67
|
+
args: { id: v.string() },
|
|
68
|
+
handler: async (ctx, { id }) => new Trigger(app.components.trigger).refresh(ctx, id),
|
|
69
|
+
}),
|
|
66
70
|
},
|
|
67
71
|
});
|
|
68
72
|
|
|
@@ -92,6 +96,8 @@ let remoteStatus: string;
|
|
|
92
96
|
let remoteIsCompleted: boolean;
|
|
93
97
|
let largeOutput: boolean;
|
|
94
98
|
let submittedOptions: Record<string, unknown>;
|
|
99
|
+
let holdNextPoll: boolean;
|
|
100
|
+
let releasePoll: (() => void) | undefined;
|
|
95
101
|
|
|
96
102
|
beforeAll(async () => {
|
|
97
103
|
server = createServer(async (req, res) => {
|
|
@@ -125,19 +131,26 @@ beforeAll(async () => {
|
|
|
125
131
|
return;
|
|
126
132
|
}
|
|
127
133
|
const completed = activePolls-- <= 0;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
?
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
+
}
|
|
141
154
|
}
|
|
142
155
|
});
|
|
143
156
|
server.listen(0, "127.0.0.1");
|
|
@@ -162,8 +175,13 @@ beforeEach(() => {
|
|
|
162
175
|
remoteIsCompleted = true;
|
|
163
176
|
largeOutput = false;
|
|
164
177
|
submittedOptions = {};
|
|
178
|
+
holdNextPoll = false;
|
|
179
|
+
releasePoll = undefined;
|
|
180
|
+
});
|
|
181
|
+
afterEach(() => {
|
|
182
|
+
releasePoll?.();
|
|
183
|
+
vi.useRealTimers();
|
|
165
184
|
});
|
|
166
|
-
afterEach(() => vi.useRealTimers());
|
|
167
185
|
afterAll(
|
|
168
186
|
() =>
|
|
169
187
|
new Promise<void>((resolve, reject) => {
|
|
@@ -344,6 +362,135 @@ test("a failing callback rolls back completion and can be retried without duplic
|
|
|
344
362
|
expect(await t.run((ctx) => ctx.db.query("completions").collect())).toHaveLength(1);
|
|
345
363
|
});
|
|
346
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
|
+
|
|
347
494
|
test("rejects large inputs before scheduling remote work", async () => {
|
|
348
495
|
const t = createTest();
|
|
349
496
|
await expect(
|
package/src/component/runs.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { internalMutation, internalQuery, mutation, query, type DataModel } from
|
|
|
9
9
|
import {
|
|
10
10
|
MAX_INLINE_BYTES,
|
|
11
11
|
MAX_PAGE_BYTES,
|
|
12
|
+
MIN_REFRESH_INTERVAL_MS,
|
|
12
13
|
SUBMISSION_WINDOW_MS,
|
|
13
14
|
vConfig,
|
|
14
15
|
vRun,
|
|
@@ -19,7 +20,18 @@ import {
|
|
|
19
20
|
type RunDoc = DataModel["runs"]["document"];
|
|
20
21
|
|
|
21
22
|
export function toRun(run: RunDoc): Run {
|
|
22
|
-
const {
|
|
23
|
+
const {
|
|
24
|
+
_id,
|
|
25
|
+
_creationTime,
|
|
26
|
+
generation,
|
|
27
|
+
checks,
|
|
28
|
+
failures,
|
|
29
|
+
callback,
|
|
30
|
+
checking,
|
|
31
|
+
refreshRequested,
|
|
32
|
+
lastCheckAt,
|
|
33
|
+
...fields
|
|
34
|
+
} = run;
|
|
23
35
|
return { id: _id, createdAt: _creationTime, ...fields };
|
|
24
36
|
}
|
|
25
37
|
|
|
@@ -110,6 +122,36 @@ export const list = query({
|
|
|
110
122
|
},
|
|
111
123
|
});
|
|
112
124
|
|
|
125
|
+
export async function scheduleRefresh(
|
|
126
|
+
ctx: GenericMutationCtx<DataModel>,
|
|
127
|
+
run: RunDoc
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
if (!run.refreshRequested) return;
|
|
130
|
+
const delay =
|
|
131
|
+
run.lastCheckAt === undefined
|
|
132
|
+
? 0
|
|
133
|
+
: Math.max(0, run.lastCheckAt + MIN_REFRESH_INTERVAL_MS - Date.now());
|
|
134
|
+
await ctx.scheduler.runAfter(delay, internal.worker.tick, {
|
|
135
|
+
id: run._id,
|
|
136
|
+
generation: run.generation,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const refresh = mutation({
|
|
141
|
+
args: { id: v.string() },
|
|
142
|
+
returns: v.null(),
|
|
143
|
+
handler: async (ctx, { id }): Promise<null> => {
|
|
144
|
+
const runId = ctx.db.normalizeId("runs", id);
|
|
145
|
+
const run = runId ? await ctx.db.get("runs", runId) : null;
|
|
146
|
+
if (!run) throw new Error("Run not found");
|
|
147
|
+
if (!run.runId || run.syncStatus !== "active" || run.refreshRequested) return null;
|
|
148
|
+
await ctx.db.patch(run._id, { refreshRequested: true });
|
|
149
|
+
// Preserve the in-flight observation; its result will schedule any requested follow-up.
|
|
150
|
+
if (!run.checking) await scheduleRefresh(ctx, { ...run, refreshRequested: true });
|
|
151
|
+
return null;
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
113
155
|
export const resume = mutation({
|
|
114
156
|
args: { id: v.string(), config: vConfig },
|
|
115
157
|
returns: v.null(),
|
|
@@ -136,6 +178,8 @@ export const resume = mutation({
|
|
|
136
178
|
failures: 0,
|
|
137
179
|
checks: 0,
|
|
138
180
|
generation,
|
|
181
|
+
checking: undefined,
|
|
182
|
+
refreshRequested: undefined,
|
|
139
183
|
updatedAt: Date.now(),
|
|
140
184
|
});
|
|
141
185
|
await ctx.scheduler.runAfter(0, internal.worker.tick, { id: run._id, generation });
|
|
@@ -168,6 +212,7 @@ export const recordSubmission = internalMutation({
|
|
|
168
212
|
syncError: undefined,
|
|
169
213
|
failures: 0,
|
|
170
214
|
checks: 0,
|
|
215
|
+
checking: undefined,
|
|
171
216
|
updatedAt: Date.now(),
|
|
172
217
|
});
|
|
173
218
|
return null;
|
|
@@ -195,6 +240,8 @@ export const recordStatus = internalMutation({
|
|
|
195
240
|
syncStatus: completed ? ("complete" as const) : ("active" as const),
|
|
196
241
|
syncError: undefined,
|
|
197
242
|
failures: 0,
|
|
243
|
+
checking: undefined,
|
|
244
|
+
refreshRequested: completed ? undefined : run.refreshRequested,
|
|
198
245
|
updatedAt: Date.now(),
|
|
199
246
|
};
|
|
200
247
|
await ctx.db.patch("runs", id, updates);
|
|
@@ -209,6 +256,8 @@ export const recordStatus = internalMutation({
|
|
|
209
256
|
.withIndex("by_run", (q) => q.eq("run", id))
|
|
210
257
|
.unique();
|
|
211
258
|
if (request) await ctx.db.delete("requests", request._id);
|
|
259
|
+
} else {
|
|
260
|
+
await scheduleRefresh(ctx, run);
|
|
212
261
|
}
|
|
213
262
|
return null;
|
|
214
263
|
},
|
|
@@ -222,6 +271,8 @@ export async function pause(
|
|
|
222
271
|
await ctx.db.patch("runs", run._id, {
|
|
223
272
|
syncStatus: "paused",
|
|
224
273
|
syncError: message,
|
|
274
|
+
checking: undefined,
|
|
275
|
+
refreshRequested: undefined,
|
|
225
276
|
updatedAt: Date.now(),
|
|
226
277
|
});
|
|
227
278
|
}
|
package/src/component/schema.ts
CHANGED
|
@@ -8,6 +8,9 @@ export default defineSchema({
|
|
|
8
8
|
generation: v.number(),
|
|
9
9
|
checks: v.number(),
|
|
10
10
|
failures: v.number(),
|
|
11
|
+
checking: v.optional(v.boolean()),
|
|
12
|
+
refreshRequested: v.optional(v.boolean()),
|
|
13
|
+
lastCheckAt: v.optional(v.number()),
|
|
11
14
|
callback: v.optional(v.string()),
|
|
12
15
|
}).index("by_key", ["idempotencyKey"]),
|
|
13
16
|
requests: defineTable({
|
package/src/component/shared.ts
CHANGED
|
@@ -49,6 +49,7 @@ export type Config = Infer<typeof vConfig>;
|
|
|
49
49
|
const FIRST_POLL_MS = 15_000;
|
|
50
50
|
const MAX_POLL_MS = 60_000;
|
|
51
51
|
export const REQUEST_TIMEOUT_MS = 10_000;
|
|
52
|
+
export const MIN_REFRESH_INTERVAL_MS = 5_000;
|
|
52
53
|
export const WATCHDOG_MARGIN_MS = 5_000;
|
|
53
54
|
export const MAX_SYNC_FAILURES = 8;
|
|
54
55
|
export const SUBMISSION_WINDOW_MS = 60 * 60 * 1_000;
|
package/src/component/worker.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
2
|
import { internal } from "./api.js";
|
|
3
3
|
import { internalAction, internalMutation } from "./server.js";
|
|
4
|
-
import { pause } from "./runs.js";
|
|
4
|
+
import { pause, scheduleRefresh } from "./runs.js";
|
|
5
5
|
import { ApiError, inlineValue, request } from "./apiClient.js";
|
|
6
6
|
import {
|
|
7
7
|
IDEMPOTENCY_TTL,
|
|
@@ -35,7 +35,13 @@ export const tick = internalMutation({
|
|
|
35
35
|
return null;
|
|
36
36
|
}
|
|
37
37
|
const nextGeneration = generation + 1;
|
|
38
|
-
await ctx.db.patch("runs", id, {
|
|
38
|
+
await ctx.db.patch("runs", id, {
|
|
39
|
+
generation: nextGeneration,
|
|
40
|
+
checks: run.checks + 1,
|
|
41
|
+
checking: true,
|
|
42
|
+
refreshRequested: undefined,
|
|
43
|
+
lastCheckAt: run.runId ? Date.now() : run.lastCheckAt,
|
|
44
|
+
});
|
|
39
45
|
// Schedule the watchdog transactionally before starting an action that can be interrupted.
|
|
40
46
|
await ctx.scheduler.runAfter(
|
|
41
47
|
Math.max(pollDelay(run.checks), REQUEST_TIMEOUT_MS + WATCHDOG_MARGIN_MS),
|
|
@@ -124,8 +130,14 @@ export const recordError = internalMutation({
|
|
|
124
130
|
const run = await ctx.db.get("runs", id);
|
|
125
131
|
if (!run || run.generation !== generation || run.syncStatus === "complete") return null;
|
|
126
132
|
const failures = run.failures + 1;
|
|
127
|
-
await ctx.db.patch("runs", id, {
|
|
133
|
+
await ctx.db.patch("runs", id, {
|
|
134
|
+
failures,
|
|
135
|
+
syncError: message,
|
|
136
|
+
checking: undefined,
|
|
137
|
+
updatedAt: Date.now(),
|
|
138
|
+
});
|
|
128
139
|
if (permanent || failures >= MAX_SYNC_FAILURES) await pause(ctx, run, message);
|
|
140
|
+
else await scheduleRefresh(ctx, run);
|
|
129
141
|
return null;
|
|
130
142
|
},
|
|
131
143
|
});
|