From 7f6ef078b2de1f12858982b3a1f69f8d0843c288 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 6 Aug 2026 10:58:29 +0100 Subject: [PATCH 1/2] fix(sdk): forward debounce when batch triggering with an array The array branches of batchTrigger and batchTriggerAndWait build each item's options field by field and never copied debounce across, so the option was accepted by the types and the API but silently dropped before the request went out. Every item then created its own run instead of collapsing onto the debounce key. The streaming forms of the same calls already forwarded it. --- .changeset/batch-trigger-debounce.md | 14 ++ .../trigger-sdk/src/v3/batchDebounce.test.ts | 234 ++++++++++++++++++ packages/trigger-sdk/src/v3/shared.ts | 2 + 3 files changed, 250 insertions(+) create mode 100644 .changeset/batch-trigger-debounce.md create mode 100644 packages/trigger-sdk/src/v3/batchDebounce.test.ts diff --git a/.changeset/batch-trigger-debounce.md b/.changeset/batch-trigger-debounce.md new file mode 100644 index 0000000000..36f5c62ab7 --- /dev/null +++ b/.changeset/batch-trigger-debounce.md @@ -0,0 +1,14 @@ +--- +"@trigger.dev/sdk": patch +--- + +`debounce` now works when you pass an array of items to `batchTrigger` or `batchTriggerAndWait`. Previously the option was accepted by the types and dropped before the request was sent, so every item created its own run instead of collapsing onto the debounce key. + +```ts +await myTask.batchTrigger([ + { payload: { id: "a" }, options: { debounce: { key: "same-key", delay: "30s" } } }, + { payload: { id: "b" }, options: { debounce: { key: "same-key", delay: "30s" } } }, +]); +``` + +The streaming (async iterable) forms of these calls were already forwarding `debounce` correctly. diff --git a/packages/trigger-sdk/src/v3/batchDebounce.test.ts b/packages/trigger-sdk/src/v3/batchDebounce.test.ts new file mode 100644 index 0000000000..739162d89b --- /dev/null +++ b/packages/trigger-sdk/src/v3/batchDebounce.test.ts @@ -0,0 +1,234 @@ +import { apiClientManager } from "@trigger.dev/core/v3"; +import { runInMockTaskContext } from "@trigger.dev/core/v3/test"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { batch } from "./batch.js"; +import { createTask } from "./shared.js"; +import { tasks } from "./tasks.js"; + +const DEBOUNCE = { key: "warm-conn-notify", delay: "12h", mode: "trailing" as const }; + +type Payload = { i: number }; + +const taskA = createTask({ + id: "task-a", + run: async (_payload: Payload) => ({ ok: true }), +}); + +const taskB = createTask({ + id: "task-b", + run: async (_payload: Payload) => ({ ok: true }), +}); + +type SentItem = { + index: number; + task: string; + options?: { debounce?: { key: string; delay: string; mode?: string; maxDelay?: string } }; +}; + +/** + * Captures the NDJSON item stream the SDK sends in phase 2 of a batch trigger, + * answering the only question these tests care about: what actually reached the + * wire. Phase 1 (create) and the item stream get canned success responses. + */ +function installBatchCapture() { + const sent: SentItem[] = []; + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async (input: any, init?: RequestInit) => { + const url = typeof input === "string" ? input : (input?.url ?? String(input)); + + if (url.endsWith("/api/v3/batches")) { + const body = JSON.parse(String(init?.body)); + return Response.json({ id: "batch_test", runCount: body.runCount, isCached: false }); + } + + if (url.includes("/api/v3/batches/") && url.endsWith("/items")) { + const ndjson = await new Response(init?.body as any).text(); + const lines = ndjson.split("\n").filter((line) => line.trim().length > 0); + sent.push(...lines.map((line) => JSON.parse(line) as SentItem)); + + return Response.json({ + id: "batch_test", + itemsAccepted: lines.length, + itemsDeduplicated: 0, + sealed: true, + }); + } + + return Response.json({}); + }) as typeof fetch; + + return { + debounceOptions: () => sent.sort((a, b) => a.index - b.index).map((i) => i.options?.debounce), + restore: () => { + globalThis.fetch = originalFetch; + }, + }; +} + +async function* asAsyncIterable(items: T[]): AsyncIterable { + for (const item of items) { + yield item; + } +} + +describe("batch trigger debounce forwarding", () => { + let capture: ReturnType; + + beforeEach(() => { + apiClientManager.setGlobalAPIClientConfiguration({ + baseURL: "http://localhost:3030", + accessToken: "tr_dev_test", + }); + capture = installBatchCapture(); + }); + + afterEach(() => { + capture.restore(); + apiClientManager.disable(); + }); + + const surfaces: Array<{ name: string; call: () => Promise }> = [ + { + name: "task.batchTrigger(array)", + call: () => + taskA.batchTrigger([ + { payload: { i: 0 }, options: { debounce: DEBOUNCE } }, + { payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + ]), + }, + { + name: "task.batchTrigger(asyncIterable)", + call: () => + taskA.batchTrigger( + asAsyncIterable([ + { payload: { i: 0 }, options: { debounce: DEBOUNCE } }, + { payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + ]) + ), + }, + { + name: "tasks.batchTrigger(array)", + call: () => + tasks.batchTrigger("task-a", [ + { payload: { i: 0 }, options: { debounce: DEBOUNCE } }, + { payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + ]), + }, + { + name: "batch.trigger(array)", + call: () => + batch.trigger([ + { id: "task-a", payload: { i: 0 }, options: { debounce: DEBOUNCE } }, + { id: "task-b", payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + ]), + }, + { + name: "batch.trigger(asyncIterable)", + call: () => + batch.trigger( + asAsyncIterable([ + { id: "task-a" as const, payload: { i: 0 }, options: { debounce: DEBOUNCE } }, + { id: "task-b" as const, payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + ]) + ), + }, + { + name: "batch.triggerByTask(array)", + call: () => + batch.triggerByTask([ + { task: taskA, payload: { i: 0 }, options: { debounce: DEBOUNCE } }, + { task: taskB, payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + ]), + }, + { + name: "batch.triggerByTask(asyncIterable)", + call: () => + batch.triggerByTask( + asAsyncIterable([ + { task: taskA, payload: { i: 0 }, options: { debounce: DEBOUNCE } }, + { task: taskB, payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + ]) + ), + }, + ]; + + it.each(surfaces)("$name forwards debounce for every item", async ({ call }) => { + await call(); + + expect(capture.debounceOptions()).toEqual([DEBOUNCE, DEBOUNCE]); + }); + + const waitSurfaces: Array<{ name: string; call: () => Promise }> = [ + { + name: "task.batchTriggerAndWait(array)", + call: () => + taskA.batchTriggerAndWait([ + { payload: { i: 0 }, options: { debounce: DEBOUNCE } }, + { payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + ]), + }, + { + name: "task.batchTriggerAndWait(asyncIterable)", + call: () => + taskA.batchTriggerAndWait( + asAsyncIterable([ + { payload: { i: 0 }, options: { debounce: DEBOUNCE } }, + { payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + ]) + ), + }, + { + name: "tasks.batchTriggerAndWait(array)", + call: () => + tasks.batchTriggerAndWait("task-a", [ + { payload: { i: 0 }, options: { debounce: DEBOUNCE } }, + { payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + ]), + }, + { + name: "batch.triggerAndWait(array)", + call: () => + batch.triggerAndWait([ + { id: "task-a", payload: { i: 0 }, options: { debounce: DEBOUNCE } }, + { id: "task-b", payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + ]), + }, + { + name: "batch.triggerAndWait(asyncIterable)", + call: () => + batch.triggerAndWait( + asAsyncIterable([ + { id: "task-a" as const, payload: { i: 0 }, options: { debounce: DEBOUNCE } }, + { id: "task-b" as const, payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + ]) + ), + }, + { + name: "batch.triggerByTaskAndWait(array)", + call: () => + batch.triggerByTaskAndWait([ + { task: taskA, payload: { i: 0 }, options: { debounce: DEBOUNCE } }, + { task: taskB, payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + ]), + }, + { + name: "batch.triggerByTaskAndWait(asyncIterable)", + call: () => + batch.triggerByTaskAndWait( + asAsyncIterable([ + { task: taskA, payload: { i: 0 }, options: { debounce: DEBOUNCE } }, + { task: taskB, payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + ]) + ), + }, + ]; + + it.each(waitSurfaces)("$name forwards debounce for every item", async ({ call }) => { + await runInMockTaskContext(async () => { + await call(); + }); + + expect(capture.debounceOptions()).toEqual([DEBOUNCE, DEBOUNCE]); + }); +}); diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index 50ab312eb3..30fa54df88 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -2423,6 +2423,7 @@ async function batchTrigger_internal( priority: item.options?.priority, region: item.options?.region, lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"), + debounce: item.options?.debounce, }, }; }) @@ -2854,6 +2855,7 @@ async function batchTriggerAndWait_internal Date: Thu, 6 Aug 2026 11:45:33 +0100 Subject: [PATCH 2/2] fix(sdk,react-hooks): forward debounce on the remaining trigger paths Adds a satisfies check to every batch item builder so a missing or misspelled option key fails to compile instead of being stripped by the server, and asserts a distinct debounce key per item so the tests catch a wrong item-to-option pairing, not just a wholesale drop. useTaskTrigger had the same silent drop as the batch array branches. --- .changeset/batch-trigger-debounce.md | 5 +- .../react-hooks/src/hooks/useTaskTrigger.ts | 1 + .../trigger-sdk/src/v3/batchDebounce.test.ts | 74 ++++++++++--------- packages/trigger-sdk/src/v3/shared.ts | 24 +++--- 4 files changed, 57 insertions(+), 47 deletions(-) diff --git a/.changeset/batch-trigger-debounce.md b/.changeset/batch-trigger-debounce.md index 36f5c62ab7..ccda1f7898 100644 --- a/.changeset/batch-trigger-debounce.md +++ b/.changeset/batch-trigger-debounce.md @@ -1,8 +1,9 @@ --- "@trigger.dev/sdk": patch +"@trigger.dev/react-hooks": patch --- -`debounce` now works when you pass an array of items to `batchTrigger` or `batchTriggerAndWait`. Previously the option was accepted by the types and dropped before the request was sent, so every item created its own run instead of collapsing onto the debounce key. +`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. ```ts await myTask.batchTrigger([ @@ -11,4 +12,4 @@ await myTask.batchTrigger([ ]); ``` -The streaming (async iterable) forms of these calls were already forwarding `debounce` correctly. +The streaming (async iterable) forms of the batch calls were already forwarding `debounce` correctly. diff --git a/packages/react-hooks/src/hooks/useTaskTrigger.ts b/packages/react-hooks/src/hooks/useTaskTrigger.ts index a26fc84c9c..979cd87983 100644 --- a/packages/react-hooks/src/hooks/useTaskTrigger.ts +++ b/packages/react-hooks/src/hooks/useTaskTrigger.ts @@ -87,6 +87,7 @@ export function useTaskTrigger( metadata: options?.metadata, maxDuration: options?.maxDuration, lockToVersion: options?.version, + debounce: options?.debounce, }, }); diff --git a/packages/trigger-sdk/src/v3/batchDebounce.test.ts b/packages/trigger-sdk/src/v3/batchDebounce.test.ts index 739162d89b..9b53699d05 100644 --- a/packages/trigger-sdk/src/v3/batchDebounce.test.ts +++ b/packages/trigger-sdk/src/v3/batchDebounce.test.ts @@ -5,7 +5,14 @@ import { batch } from "./batch.js"; import { createTask } from "./shared.js"; import { tasks } from "./tasks.js"; -const DEBOUNCE = { key: "warm-conn-notify", delay: "12h", mode: "trailing" as const }; +const debounceFor = (i: number) => ({ + key: `warm-conn-notify:${i}`, + delay: "12h", + maxDelay: "24h", + mode: "trailing" as const, +}); + +const EXPECTED = [debounceFor(0), debounceFor(1)]; type Payload = { i: number }; @@ -55,11 +62,12 @@ function installBatchCapture() { }); } - return Response.json({}); + throw new Error(`Unexpected request during batch trigger: ${url}`); }) as typeof fetch; return { - debounceOptions: () => sent.sort((a, b) => a.index - b.index).map((i) => i.options?.debounce), + debounceOptions: () => + [...sent].sort((a, b) => a.index - b.index).map((item) => item.options?.debounce), restore: () => { globalThis.fetch = originalFetch; }, @@ -93,8 +101,8 @@ describe("batch trigger debounce forwarding", () => { name: "task.batchTrigger(array)", call: () => taskA.batchTrigger([ - { payload: { i: 0 }, options: { debounce: DEBOUNCE } }, - { payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + { payload: { i: 0 }, options: { debounce: debounceFor(0) } }, + { payload: { i: 1 }, options: { debounce: debounceFor(1) } }, ]), }, { @@ -102,8 +110,8 @@ describe("batch trigger debounce forwarding", () => { call: () => taskA.batchTrigger( asAsyncIterable([ - { payload: { i: 0 }, options: { debounce: DEBOUNCE } }, - { payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + { payload: { i: 0 }, options: { debounce: debounceFor(0) } }, + { payload: { i: 1 }, options: { debounce: debounceFor(1) } }, ]) ), }, @@ -111,16 +119,16 @@ describe("batch trigger debounce forwarding", () => { name: "tasks.batchTrigger(array)", call: () => tasks.batchTrigger("task-a", [ - { payload: { i: 0 }, options: { debounce: DEBOUNCE } }, - { payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + { payload: { i: 0 }, options: { debounce: debounceFor(0) } }, + { payload: { i: 1 }, options: { debounce: debounceFor(1) } }, ]), }, { name: "batch.trigger(array)", call: () => batch.trigger([ - { id: "task-a", payload: { i: 0 }, options: { debounce: DEBOUNCE } }, - { id: "task-b", payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + { id: "task-a", payload: { i: 0 }, options: { debounce: debounceFor(0) } }, + { id: "task-b", payload: { i: 1 }, options: { debounce: debounceFor(1) } }, ]), }, { @@ -128,8 +136,8 @@ describe("batch trigger debounce forwarding", () => { call: () => batch.trigger( asAsyncIterable([ - { id: "task-a" as const, payload: { i: 0 }, options: { debounce: DEBOUNCE } }, - { id: "task-b" as const, payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + { id: "task-a" as const, payload: { i: 0 }, options: { debounce: debounceFor(0) } }, + { id: "task-b" as const, payload: { i: 1 }, options: { debounce: debounceFor(1) } }, ]) ), }, @@ -137,8 +145,8 @@ describe("batch trigger debounce forwarding", () => { name: "batch.triggerByTask(array)", call: () => batch.triggerByTask([ - { task: taskA, payload: { i: 0 }, options: { debounce: DEBOUNCE } }, - { task: taskB, payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + { task: taskA, payload: { i: 0 }, options: { debounce: debounceFor(0) } }, + { task: taskB, payload: { i: 1 }, options: { debounce: debounceFor(1) } }, ]), }, { @@ -146,8 +154,8 @@ describe("batch trigger debounce forwarding", () => { call: () => batch.triggerByTask( asAsyncIterable([ - { task: taskA, payload: { i: 0 }, options: { debounce: DEBOUNCE } }, - { task: taskB, payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + { task: taskA, payload: { i: 0 }, options: { debounce: debounceFor(0) } }, + { task: taskB, payload: { i: 1 }, options: { debounce: debounceFor(1) } }, ]) ), }, @@ -156,7 +164,7 @@ describe("batch trigger debounce forwarding", () => { it.each(surfaces)("$name forwards debounce for every item", async ({ call }) => { await call(); - expect(capture.debounceOptions()).toEqual([DEBOUNCE, DEBOUNCE]); + expect(capture.debounceOptions()).toEqual(EXPECTED); }); const waitSurfaces: Array<{ name: string; call: () => Promise }> = [ @@ -164,8 +172,8 @@ describe("batch trigger debounce forwarding", () => { name: "task.batchTriggerAndWait(array)", call: () => taskA.batchTriggerAndWait([ - { payload: { i: 0 }, options: { debounce: DEBOUNCE } }, - { payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + { payload: { i: 0 }, options: { debounce: debounceFor(0) } }, + { payload: { i: 1 }, options: { debounce: debounceFor(1) } }, ]), }, { @@ -173,8 +181,8 @@ describe("batch trigger debounce forwarding", () => { call: () => taskA.batchTriggerAndWait( asAsyncIterable([ - { payload: { i: 0 }, options: { debounce: DEBOUNCE } }, - { payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + { payload: { i: 0 }, options: { debounce: debounceFor(0) } }, + { payload: { i: 1 }, options: { debounce: debounceFor(1) } }, ]) ), }, @@ -182,16 +190,16 @@ describe("batch trigger debounce forwarding", () => { name: "tasks.batchTriggerAndWait(array)", call: () => tasks.batchTriggerAndWait("task-a", [ - { payload: { i: 0 }, options: { debounce: DEBOUNCE } }, - { payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + { payload: { i: 0 }, options: { debounce: debounceFor(0) } }, + { payload: { i: 1 }, options: { debounce: debounceFor(1) } }, ]), }, { name: "batch.triggerAndWait(array)", call: () => batch.triggerAndWait([ - { id: "task-a", payload: { i: 0 }, options: { debounce: DEBOUNCE } }, - { id: "task-b", payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + { id: "task-a", payload: { i: 0 }, options: { debounce: debounceFor(0) } }, + { id: "task-b", payload: { i: 1 }, options: { debounce: debounceFor(1) } }, ]), }, { @@ -199,8 +207,8 @@ describe("batch trigger debounce forwarding", () => { call: () => batch.triggerAndWait( asAsyncIterable([ - { id: "task-a" as const, payload: { i: 0 }, options: { debounce: DEBOUNCE } }, - { id: "task-b" as const, payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + { id: "task-a" as const, payload: { i: 0 }, options: { debounce: debounceFor(0) } }, + { id: "task-b" as const, payload: { i: 1 }, options: { debounce: debounceFor(1) } }, ]) ), }, @@ -208,8 +216,8 @@ describe("batch trigger debounce forwarding", () => { name: "batch.triggerByTaskAndWait(array)", call: () => batch.triggerByTaskAndWait([ - { task: taskA, payload: { i: 0 }, options: { debounce: DEBOUNCE } }, - { task: taskB, payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + { task: taskA, payload: { i: 0 }, options: { debounce: debounceFor(0) } }, + { task: taskB, payload: { i: 1 }, options: { debounce: debounceFor(1) } }, ]), }, { @@ -217,8 +225,8 @@ describe("batch trigger debounce forwarding", () => { call: () => batch.triggerByTaskAndWait( asAsyncIterable([ - { task: taskA, payload: { i: 0 }, options: { debounce: DEBOUNCE } }, - { task: taskB, payload: { i: 1 }, options: { debounce: DEBOUNCE } }, + { task: taskA, payload: { i: 0 }, options: { debounce: debounceFor(0) } }, + { task: taskB, payload: { i: 1 }, options: { debounce: debounceFor(1) } }, ]) ), }, @@ -229,6 +237,6 @@ describe("batch trigger debounce forwarding", () => { await call(); }); - expect(capture.debounceOptions()).toEqual([DEBOUNCE, DEBOUNCE]); + expect(capture.debounceOptions()).toEqual(EXPECTED); }); }); diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index 30fa54df88..d06e82ae1b 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -749,7 +749,7 @@ export async function batchTriggerById( lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"), debounce: item.options?.debounce, }, - }; + } satisfies BatchItemNDJSON; }) ); @@ -1005,7 +1005,7 @@ export async function batchTriggerByIdAndWait( region: item.options?.region, debounce: item.options?.debounce, }, - }; + } satisfies BatchItemNDJSON; }) ); @@ -1271,7 +1271,7 @@ export async function batchTriggerTasks( lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"), debounce: item.options?.debounce, }, - }; + } satisfies BatchItemNDJSON; }) ); @@ -1532,7 +1532,7 @@ export async function batchTriggerAndWaitTasks( lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"), debounce: item.options?.debounce, }, - }; + } satisfies BatchItemNDJSON; } } @@ -2071,7 +2071,7 @@ async function* transformBatchItemsStreamForWait( region: item.options?.region, debounce: item.options?.debounce, }, - }; + } satisfies BatchItemNDJSON; } } @@ -2122,7 +2122,7 @@ async function* transformBatchByTaskItemsStream( lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"), debounce: item.options?.debounce, }, - }; + } satisfies BatchItemNDJSON; } } @@ -2286,7 +2286,7 @@ async function* transformSingleTaskBatchItemsStreamForWait( region: item.options?.region, debounce: item.options?.debounce, }, - }; + } satisfies BatchItemNDJSON; } } @@ -2425,7 +2425,7 @@ async function batchTrigger_internal( lockToVersion: item.options?.version ?? scopedEnvVar("TRIGGER_VERSION"), debounce: item.options?.debounce, }, - }; + } satisfies BatchItemNDJSON; }) ); @@ -2857,7 +2857,7 @@ async function batchTriggerAndWait_internal