Skip to content

Commit c084fa6

Browse files
authored
fix(sdk,react-hooks): forward debounce when batch triggering with an array (#4520)
Passing `debounce` in the per-item options of a batch trigger did nothing when the items were an array. The option was accepted by the types and by the API, then dropped before the request went out, so every item created its own run instead of collapsing onto the debounce key. Four public entry points were affected: `task.batchTrigger`, `task.batchTriggerAndWait`, `tasks.batchTrigger`, and `tasks.batchTriggerAndWait`. The streaming (async iterable) forms of the same calls were already correct, as were `batch.trigger`, `batch.triggerAndWait`, `batch.triggerByTask`, and `batch.triggerByTaskAndWait`. `useTaskTrigger` in `@trigger.dev/react-hooks` had the same silent drop on the single-trigger path, so that is fixed here too. It also drops `machine`, `priority`, `region`, `idempotencyKeyTTL`, and `idempotencyKeyOptions`; those are left alone, since forwarding them is a behaviour change beyond this bug. Each batch item builder constructs its options field by field, which is why one of them could fall behind without anything catching it. TypeScript did not help: the literal is returned from a `.map` callback inside `Promise.all`, so excess-property checking never fired against the `BatchItemNDJSON[]` annotation, and the server's schema silently strips unknown keys. A misspelled option name therefore reproduced this bug with no compile error and no server error. Every builder now ends in `satisfies BatchItemNDJSON`, which does catch it: ``` error TS2561: Object literal may only specify known properties, but 'debounceTYPO' does not exist in type '{ ... debounce?: {...} | undefined; }'. Did you mean to write 'debounce'? ``` The new test drives all six public batch surfaces in both array and async-iterable form and asserts on the NDJSON that actually reaches the wire. Each item carries a distinct debounce key so the test catches a wrong item-to-option pairing, not just a wholesale drop. Fixes #3304
1 parent f8e1c91 commit c084fa6

4 files changed

Lines changed: 272 additions & 12 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/react-hooks": patch
4+
---
5+
6+
`debounce` now works when you pass an array of items to `batchTrigger` or `batchTriggerAndWait`, and when you trigger from `useTaskTrigger`. Previously the option was accepted by the types and dropped before the request was sent, so every trigger created its own run instead of collapsing onto the debounce key.
7+
8+
```ts
9+
await myTask.batchTrigger([
10+
{ payload: { id: "a" }, options: { debounce: { key: "same-key", delay: "30s" } } },
11+
{ payload: { id: "b" }, options: { debounce: { key: "same-key", delay: "30s" } } },
12+
]);
13+
```
14+
15+
The streaming (async iterable) forms of the batch calls were already forwarding `debounce` correctly.

packages/react-hooks/src/hooks/useTaskTrigger.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export function useTaskTrigger<TTask extends AnyTask>(
8787
metadata: options?.metadata,
8888
maxDuration: options?.maxDuration,
8989
lockToVersion: options?.version,
90+
debounce: options?.debounce,
9091
},
9192
});
9293

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { apiClientManager } from "@trigger.dev/core/v3";
2+
import { runInMockTaskContext } from "@trigger.dev/core/v3/test";
3+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
4+
import { batch } from "./batch.js";
5+
import { createTask } from "./shared.js";
6+
import { tasks } from "./tasks.js";
7+
8+
const debounceFor = (i: number) => ({
9+
key: `warm-conn-notify:${i}`,
10+
delay: "12h",
11+
maxDelay: "24h",
12+
mode: "trailing" as const,
13+
});
14+
15+
const EXPECTED = [debounceFor(0), debounceFor(1)];
16+
17+
type Payload = { i: number };
18+
19+
const taskA = createTask({
20+
id: "task-a",
21+
run: async (_payload: Payload) => ({ ok: true }),
22+
});
23+
24+
const taskB = createTask({
25+
id: "task-b",
26+
run: async (_payload: Payload) => ({ ok: true }),
27+
});
28+
29+
type SentItem = {
30+
index: number;
31+
task: string;
32+
options?: { debounce?: { key: string; delay: string; mode?: string; maxDelay?: string } };
33+
};
34+
35+
/**
36+
* Captures the NDJSON item stream the SDK sends in phase 2 of a batch trigger,
37+
* answering the only question these tests care about: what actually reached the
38+
* wire. Phase 1 (create) and the item stream get canned success responses.
39+
*/
40+
function installBatchCapture() {
41+
const sent: SentItem[] = [];
42+
const originalFetch = globalThis.fetch;
43+
44+
globalThis.fetch = (async (input: any, init?: RequestInit) => {
45+
const url = typeof input === "string" ? input : (input?.url ?? String(input));
46+
47+
if (url.endsWith("/api/v3/batches")) {
48+
const body = JSON.parse(String(init?.body));
49+
return Response.json({ id: "batch_test", runCount: body.runCount, isCached: false });
50+
}
51+
52+
if (url.includes("/api/v3/batches/") && url.endsWith("/items")) {
53+
const ndjson = await new Response(init?.body as any).text();
54+
const lines = ndjson.split("\n").filter((line) => line.trim().length > 0);
55+
sent.push(...lines.map((line) => JSON.parse(line) as SentItem));
56+
57+
return Response.json({
58+
id: "batch_test",
59+
itemsAccepted: lines.length,
60+
itemsDeduplicated: 0,
61+
sealed: true,
62+
});
63+
}
64+
65+
throw new Error(`Unexpected request during batch trigger: ${url}`);
66+
}) as typeof fetch;
67+
68+
return {
69+
debounceOptions: () =>
70+
[...sent].sort((a, b) => a.index - b.index).map((item) => item.options?.debounce),
71+
restore: () => {
72+
globalThis.fetch = originalFetch;
73+
},
74+
};
75+
}
76+
77+
async function* asAsyncIterable<T>(items: T[]): AsyncIterable<T> {
78+
for (const item of items) {
79+
yield item;
80+
}
81+
}
82+
83+
describe("batch trigger debounce forwarding", () => {
84+
let capture: ReturnType<typeof installBatchCapture>;
85+
86+
beforeEach(() => {
87+
apiClientManager.setGlobalAPIClientConfiguration({
88+
baseURL: "http://localhost:3030",
89+
accessToken: "tr_dev_test",
90+
});
91+
capture = installBatchCapture();
92+
});
93+
94+
afterEach(() => {
95+
capture.restore();
96+
apiClientManager.disable();
97+
});
98+
99+
const surfaces: Array<{ name: string; call: () => Promise<unknown> }> = [
100+
{
101+
name: "task.batchTrigger(array)",
102+
call: () =>
103+
taskA.batchTrigger([
104+
{ payload: { i: 0 }, options: { debounce: debounceFor(0) } },
105+
{ payload: { i: 1 }, options: { debounce: debounceFor(1) } },
106+
]),
107+
},
108+
{
109+
name: "task.batchTrigger(asyncIterable)",
110+
call: () =>
111+
taskA.batchTrigger(
112+
asAsyncIterable([
113+
{ payload: { i: 0 }, options: { debounce: debounceFor(0) } },
114+
{ payload: { i: 1 }, options: { debounce: debounceFor(1) } },
115+
])
116+
),
117+
},
118+
{
119+
name: "tasks.batchTrigger(array)",
120+
call: () =>
121+
tasks.batchTrigger<typeof taskA>("task-a", [
122+
{ payload: { i: 0 }, options: { debounce: debounceFor(0) } },
123+
{ payload: { i: 1 }, options: { debounce: debounceFor(1) } },
124+
]),
125+
},
126+
{
127+
name: "batch.trigger(array)",
128+
call: () =>
129+
batch.trigger<typeof taskA | typeof taskB>([
130+
{ id: "task-a", payload: { i: 0 }, options: { debounce: debounceFor(0) } },
131+
{ id: "task-b", payload: { i: 1 }, options: { debounce: debounceFor(1) } },
132+
]),
133+
},
134+
{
135+
name: "batch.trigger(asyncIterable)",
136+
call: () =>
137+
batch.trigger<typeof taskA | typeof taskB>(
138+
asAsyncIterable([
139+
{ id: "task-a" as const, payload: { i: 0 }, options: { debounce: debounceFor(0) } },
140+
{ id: "task-b" as const, payload: { i: 1 }, options: { debounce: debounceFor(1) } },
141+
])
142+
),
143+
},
144+
{
145+
name: "batch.triggerByTask(array)",
146+
call: () =>
147+
batch.triggerByTask([
148+
{ task: taskA, payload: { i: 0 }, options: { debounce: debounceFor(0) } },
149+
{ task: taskB, payload: { i: 1 }, options: { debounce: debounceFor(1) } },
150+
]),
151+
},
152+
{
153+
name: "batch.triggerByTask(asyncIterable)",
154+
call: () =>
155+
batch.triggerByTask(
156+
asAsyncIterable([
157+
{ task: taskA, payload: { i: 0 }, options: { debounce: debounceFor(0) } },
158+
{ task: taskB, payload: { i: 1 }, options: { debounce: debounceFor(1) } },
159+
])
160+
),
161+
},
162+
];
163+
164+
it.each(surfaces)("$name forwards debounce for every item", async ({ call }) => {
165+
await call();
166+
167+
expect(capture.debounceOptions()).toEqual(EXPECTED);
168+
});
169+
170+
const waitSurfaces: Array<{ name: string; call: () => Promise<unknown> }> = [
171+
{
172+
name: "task.batchTriggerAndWait(array)",
173+
call: () =>
174+
taskA.batchTriggerAndWait([
175+
{ payload: { i: 0 }, options: { debounce: debounceFor(0) } },
176+
{ payload: { i: 1 }, options: { debounce: debounceFor(1) } },
177+
]),
178+
},
179+
{
180+
name: "task.batchTriggerAndWait(asyncIterable)",
181+
call: () =>
182+
taskA.batchTriggerAndWait(
183+
asAsyncIterable([
184+
{ payload: { i: 0 }, options: { debounce: debounceFor(0) } },
185+
{ payload: { i: 1 }, options: { debounce: debounceFor(1) } },
186+
])
187+
),
188+
},
189+
{
190+
name: "tasks.batchTriggerAndWait(array)",
191+
call: () =>
192+
tasks.batchTriggerAndWait<typeof taskA>("task-a", [
193+
{ payload: { i: 0 }, options: { debounce: debounceFor(0) } },
194+
{ payload: { i: 1 }, options: { debounce: debounceFor(1) } },
195+
]),
196+
},
197+
{
198+
name: "batch.triggerAndWait(array)",
199+
call: () =>
200+
batch.triggerAndWait<typeof taskA | typeof taskB>([
201+
{ id: "task-a", payload: { i: 0 }, options: { debounce: debounceFor(0) } },
202+
{ id: "task-b", payload: { i: 1 }, options: { debounce: debounceFor(1) } },
203+
]),
204+
},
205+
{
206+
name: "batch.triggerAndWait(asyncIterable)",
207+
call: () =>
208+
batch.triggerAndWait<typeof taskA | typeof taskB>(
209+
asAsyncIterable([
210+
{ id: "task-a" as const, payload: { i: 0 }, options: { debounce: debounceFor(0) } },
211+
{ id: "task-b" as const, payload: { i: 1 }, options: { debounce: debounceFor(1) } },
212+
])
213+
),
214+
},
215+
{
216+
name: "batch.triggerByTaskAndWait(array)",
217+
call: () =>
218+
batch.triggerByTaskAndWait([
219+
{ task: taskA, payload: { i: 0 }, options: { debounce: debounceFor(0) } },
220+
{ task: taskB, payload: { i: 1 }, options: { debounce: debounceFor(1) } },
221+
]),
222+
},
223+
{
224+
name: "batch.triggerByTaskAndWait(asyncIterable)",
225+
call: () =>
226+
batch.triggerByTaskAndWait(
227+
asAsyncIterable([
228+
{ task: taskA, payload: { i: 0 }, options: { debounce: debounceFor(0) } },
229+
{ task: taskB, payload: { i: 1 }, options: { debounce: debounceFor(1) } },
230+
])
231+
),
232+
},
233+
];
234+
235+
it.each(waitSurfaces)("$name forwards debounce for every item", async ({ call }) => {
236+
await runInMockTaskContext(async () => {
237+
await call();
238+
});
239+
240+
expect(capture.debounceOptions()).toEqual(EXPECTED);
241+
});
242+
});

packages/trigger-sdk/src/v3/shared.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -749,7 +749,7 @@ export async function batchTriggerById<TTask extends AnyTask>(
749749
lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"),
750750
debounce: item.options?.debounce,
751751
},
752-
};
752+
} satisfies BatchItemNDJSON;
753753
})
754754
);
755755

@@ -1005,7 +1005,7 @@ export async function batchTriggerByIdAndWait<TTask extends AnyTask>(
10051005
region: item.options?.region,
10061006
debounce: item.options?.debounce,
10071007
},
1008-
};
1008+
} satisfies BatchItemNDJSON;
10091009
})
10101010
);
10111011

@@ -1271,7 +1271,7 @@ export async function batchTriggerTasks<TTasks extends readonly AnyTask[]>(
12711271
lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"),
12721272
debounce: item.options?.debounce,
12731273
},
1274-
};
1274+
} satisfies BatchItemNDJSON;
12751275
})
12761276
);
12771277

@@ -1532,7 +1532,7 @@ export async function batchTriggerAndWaitTasks<TTasks extends readonly AnyTask[]
15321532
region: item.options?.region,
15331533
debounce: item.options?.debounce,
15341534
},
1535-
};
1535+
} satisfies BatchItemNDJSON;
15361536
})
15371537
);
15381538

@@ -2019,7 +2019,7 @@ async function* transformBatchItemsStream<TTask extends AnyTask>(
20192019
lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"),
20202020
debounce: item.options?.debounce,
20212021
},
2022-
};
2022+
} satisfies BatchItemNDJSON;
20232023
}
20242024
}
20252025

@@ -2071,7 +2071,7 @@ async function* transformBatchItemsStreamForWait<TTask extends AnyTask>(
20712071
region: item.options?.region,
20722072
debounce: item.options?.debounce,
20732073
},
2074-
};
2074+
} satisfies BatchItemNDJSON;
20752075
}
20762076
}
20772077

@@ -2122,7 +2122,7 @@ async function* transformBatchByTaskItemsStream<TTasks extends readonly AnyTask[
21222122
lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"),
21232123
debounce: item.options?.debounce,
21242124
},
2125-
};
2125+
} satisfies BatchItemNDJSON;
21262126
}
21272127
}
21282128

@@ -2173,7 +2173,7 @@ async function* transformBatchByTaskItemsStreamForWait<TTasks extends readonly A
21732173
region: item.options?.region,
21742174
debounce: item.options?.debounce,
21752175
},
2176-
};
2176+
} satisfies BatchItemNDJSON;
21772177
}
21782178
}
21792179

@@ -2226,7 +2226,7 @@ async function* transformSingleTaskBatchItemsStream<TPayload>(
22262226
lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"),
22272227
debounce: item.options?.debounce,
22282228
},
2229-
};
2229+
} satisfies BatchItemNDJSON;
22302230
}
22312231
}
22322232

@@ -2286,7 +2286,7 @@ async function* transformSingleTaskBatchItemsStreamForWait<TPayload>(
22862286
region: item.options?.region,
22872287
debounce: item.options?.debounce,
22882288
},
2289-
};
2289+
} satisfies BatchItemNDJSON;
22902290
}
22912291
}
22922292

@@ -2423,8 +2423,9 @@ async function batchTrigger_internal<TRunTypes extends AnyRunTypes>(
24232423
priority: item.options?.priority,
24242424
region: item.options?.region,
24252425
lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"),
2426+
debounce: item.options?.debounce,
24262427
},
2427-
};
2428+
} satisfies BatchItemNDJSON;
24282429
})
24292430
);
24302431

@@ -2854,8 +2855,9 @@ async function batchTriggerAndWait_internal<TIdentifier extends string, TPayload
28542855
machine: item.options?.machine,
28552856
priority: item.options?.priority,
28562857
region: item.options?.region,
2858+
debounce: item.options?.debounce,
28572859
},
2858-
};
2860+
} satisfies BatchItemNDJSON;
28592861
})
28602862
);
28612863

0 commit comments

Comments
 (0)