feat(sdk): add bulk replay to api and sdk (#4105)
## Summary
Adds SDK and API support for run bulk actions. You can now create bulk
cancel or replay actions from `@trigger.dev/sdk` using run IDs or the
same filters as `runs.list()`, then retrieve, list, poll, or abort the
action by its `bulk_` handle.
Tests, docs, changesets added.
## Design
The dashboard bulk action service now accepts structured filters instead
of reading directly from a dashboard request, so the dashboard and API
share the same creation path. Replay actions created through the API are
attributed with the existing `api` trigger source, while
dashboard-created actions keep `dashboard`.
The SDK exposes the new surface under `runs.bulk.*`, including
`targetRegion` for replay region overrides and cursor pagination for
listing bulk actions.
## Filters and runIds
Nuance on filters. If `filter` is provided, it MUST have at least one
key. This is to remove the footgun of passing no filter and selecting
all runs.
```typescript
{ action: "cancel", runIds: ["run_1"] } // valid
{ action: "cancel", runIds: [] } // invalid, min(1)
{ action: "cancel", filter: { status: "FAILED" } } // valid
{ action: "cancel", filter: {} } // invalid
{ action: "cancel", filter: {}, runIds: ["run_1"] } // invalid
```
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { ApiClient } from "./index.js";
|
||||
|
||||
type ReceivedRequest = {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: IncomingMessage["headers"];
|
||||
body: string;
|
||||
};
|
||||
|
||||
type RequestHandler = (request: ReceivedRequest, response: ServerResponse) => void | Promise<void>;
|
||||
|
||||
describe("ApiClient bulk actions", () => {
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
let receivedRequests: ReceivedRequest[] = [];
|
||||
let requestHandler: RequestHandler | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
receivedRequests = [];
|
||||
requestHandler = undefined;
|
||||
|
||||
server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk) => chunks.push(chunk));
|
||||
req.on("end", async () => {
|
||||
const received = {
|
||||
method: req.method ?? "",
|
||||
url: req.url ?? "",
|
||||
headers: req.headers,
|
||||
body: Buffer.concat(chunks).toString(),
|
||||
} satisfies ReceivedRequest;
|
||||
receivedRequests.push(received);
|
||||
|
||||
try {
|
||||
if (requestHandler) {
|
||||
await requestHandler(received, res);
|
||||
} else {
|
||||
json(res, { error: "No handler" }, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
json(res, { error: error instanceof Error ? error.message : String(error) }, 500);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address() as AddressInfo;
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it("posts the exact create bulk action request body", async () => {
|
||||
requestHandler = (_request, response) => json(response, { id: "bulk_created" });
|
||||
|
||||
const client = new ApiClient(baseUrl, "tr_test_key");
|
||||
const result = await client.createBulkAction({
|
||||
action: "replay",
|
||||
filter: { status: ["FAILED"], taskIdentifier: "my-task" },
|
||||
name: "Replay failures",
|
||||
targetRegion: "eu_1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "bulk_created" });
|
||||
expect(receivedRequests).toHaveLength(1);
|
||||
expect(receivedRequests[0]?.method).toBe("POST");
|
||||
expect(receivedRequests[0]?.url).toBe("/api/v1/bulk-actions");
|
||||
expect(receivedRequests[0]?.headers.authorization).toBe("Bearer tr_test_key");
|
||||
expect(JSON.parse(receivedRequests[0]?.body ?? "{}")).toEqual({
|
||||
action: "replay",
|
||||
filter: { status: ["FAILED"], taskIdentifier: "my-task" },
|
||||
name: "Replay failures",
|
||||
targetRegion: "eu_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists bulk actions with cursor pagination params and parses dates", async () => {
|
||||
const createdAt = "2026-07-01T10:00:00.000Z";
|
||||
const completedAt = "2026-07-01T10:05:00.000Z";
|
||||
requestHandler = (_request, response) =>
|
||||
json(response, {
|
||||
data: [
|
||||
{
|
||||
id: "bulk_listed",
|
||||
name: "Cancel queued runs",
|
||||
type: "CANCEL",
|
||||
status: "COMPLETED",
|
||||
counts: { total: 3, success: 2, failure: 1 },
|
||||
createdAt,
|
||||
completedAt,
|
||||
},
|
||||
],
|
||||
pagination: { next: "cursor_next", previous: "cursor_previous" },
|
||||
});
|
||||
|
||||
const client = new ApiClient(baseUrl, "tr_test_key");
|
||||
const page = await client.listBulkActions({ limit: 2, after: "cursor_after" });
|
||||
|
||||
expect(receivedRequests[0]?.method).toBe("GET");
|
||||
const url = new URL(receivedRequests[0]?.url ?? "", baseUrl);
|
||||
expect(url.pathname).toBe("/api/v1/bulk-actions");
|
||||
expect(url.searchParams.get("page[size]")).toBe("2");
|
||||
expect(url.searchParams.get("page[after]")).toBe("cursor_after");
|
||||
expect(page.pagination).toEqual({ next: "cursor_next", previous: "cursor_previous" });
|
||||
expect(page.data[0]?.createdAt).toEqual(new Date(createdAt));
|
||||
expect(page.data[0]?.completedAt).toEqual(new Date(completedAt));
|
||||
});
|
||||
|
||||
it("auto-paginates bulk action lists", async () => {
|
||||
requestHandler = (request, response) => {
|
||||
const url = new URL(request.url, baseUrl);
|
||||
if (!url.searchParams.has("page[after]")) {
|
||||
return json(response, {
|
||||
data: [bulkActionObject("bulk_first")],
|
||||
pagination: { next: "cursor_next" },
|
||||
});
|
||||
}
|
||||
|
||||
expect(url.searchParams.get("page[after]")).toBe("cursor_next");
|
||||
return json(response, {
|
||||
data: [bulkActionObject("bulk_second")],
|
||||
pagination: {},
|
||||
});
|
||||
};
|
||||
|
||||
const client = new ApiClient(baseUrl, "tr_test_key");
|
||||
const ids: string[] = [];
|
||||
|
||||
for await (const bulkAction of client.listBulkActions({ limit: 1 })) {
|
||||
ids.push(bulkAction.id);
|
||||
}
|
||||
|
||||
expect(ids).toEqual(["bulk_first", "bulk_second"]);
|
||||
expect(receivedRequests).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("retrieves a bulk action by id", async () => {
|
||||
requestHandler = (_request, response) => json(response, bulkActionObject("bulk_retrieve"));
|
||||
|
||||
const client = new ApiClient(baseUrl, "tr_test_key");
|
||||
const bulkAction = await client.retrieveBulkAction("bulk_retrieve");
|
||||
|
||||
expect(receivedRequests[0]?.method).toBe("GET");
|
||||
expect(receivedRequests[0]?.url).toBe("/api/v1/bulk-actions/bulk_retrieve");
|
||||
expect(bulkAction.id).toBe("bulk_retrieve");
|
||||
});
|
||||
|
||||
it("aborts a bulk action by id", async () => {
|
||||
requestHandler = (_request, response) => json(response, { id: "bulk_abort" });
|
||||
|
||||
const client = new ApiClient(baseUrl, "tr_test_key");
|
||||
const result = await client.abortBulkAction("bulk_abort");
|
||||
|
||||
expect(receivedRequests[0]?.method).toBe("POST");
|
||||
expect(receivedRequests[0]?.url).toBe("/api/v1/bulk-actions/bulk_abort/abort");
|
||||
expect(result).toEqual({ id: "bulk_abort" });
|
||||
});
|
||||
});
|
||||
|
||||
function json(response: ServerResponse, body: unknown, status = 200) {
|
||||
response.writeHead(status, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function bulkActionObject(id: string) {
|
||||
return {
|
||||
id,
|
||||
type: "REPLAY",
|
||||
status: "PENDING",
|
||||
counts: { total: 1, success: 0, failure: 0 },
|
||||
createdAt: "2026-07-01T10:00:00.000Z",
|
||||
};
|
||||
}
|
||||
@@ -36,7 +36,10 @@ import {
|
||||
type UpdateScheduleOptions,
|
||||
type UpdateSessionRequestBody,
|
||||
type WaitForDurationRequestBody,
|
||||
AbortBulkActionResponseBody,
|
||||
ApiDeploymentListResponseItem,
|
||||
BulkActionObject,
|
||||
CreateBulkActionResponseBody,
|
||||
AppendToStreamResponseBody,
|
||||
BatchTaskRunExecutionResult,
|
||||
BatchTriggerTaskV3Response,
|
||||
@@ -118,8 +121,10 @@ import {
|
||||
type SSEStreamPart,
|
||||
} from "./runStream.js";
|
||||
import type {
|
||||
CreateBulkActionOptions,
|
||||
CreateEnvironmentVariableParams,
|
||||
ImportEnvironmentVariablesParams,
|
||||
ListBulkActionsQueryParams,
|
||||
ListProjectRunsQueryParams,
|
||||
ListRunsQueryParams,
|
||||
ListWaitpointTokensQueryParams,
|
||||
@@ -141,9 +146,11 @@ export type CreateBatchApiResponse = Prettify<
|
||||
>;
|
||||
|
||||
export type {
|
||||
CreateBulkActionOptions,
|
||||
CreateEnvironmentVariableParams,
|
||||
ImportEnvironmentVariablesParams,
|
||||
RealtimeRunSkipColumns,
|
||||
ListBulkActionsQueryParams,
|
||||
SubscribeToRunsQueryParams,
|
||||
UpdateEnvironmentVariableParams,
|
||||
};
|
||||
@@ -738,6 +745,64 @@ export class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
createBulkAction(options: CreateBulkActionOptions, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
CreateBulkActionResponseBody,
|
||||
`${this.baseUrl}/api/v1/bulk-actions`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: this.#getHeaders(false),
|
||||
body: JSON.stringify(options),
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
listBulkActions(
|
||||
query?: ListBulkActionsQueryParams,
|
||||
requestOptions?: ZodFetchOptions
|
||||
): CursorPagePromise<typeof BulkActionObject> {
|
||||
return zodfetchCursorPage(
|
||||
BulkActionObject,
|
||||
`${this.baseUrl}/api/v1/bulk-actions`,
|
||||
{
|
||||
query: new URLSearchParams(),
|
||||
limit: query?.limit,
|
||||
after: query?.after,
|
||||
before: query?.before,
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
headers: this.#getHeaders(false),
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
retrieveBulkAction(bulkActionId: string, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
BulkActionObject,
|
||||
`${this.baseUrl}/api/v1/bulk-actions/${bulkActionId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: this.#getHeaders(false),
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
abortBulkAction(bulkActionId: string, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
AbortBulkActionResponseBody,
|
||||
`${this.baseUrl}/api/v1/bulk-actions/${bulkActionId}/abort`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: this.#getHeaders(false),
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
resetIdempotencyKey(
|
||||
taskIdentifier: string,
|
||||
idempotencyKey: string,
|
||||
|
||||
@@ -70,6 +70,42 @@ export interface ListProjectRunsQueryParams extends CursorPageParams, ListRunsQu
|
||||
env?: Array<"dev" | "staging" | "prod"> | "dev" | "staging" | "prod";
|
||||
}
|
||||
|
||||
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = T &
|
||||
{
|
||||
[K in Keys]-?: Required<Pick<T, K>>;
|
||||
}[Keys];
|
||||
|
||||
/** Same filters as runs.list(), excluding pagination. */
|
||||
export type BulkActionFilter = RequireAtLeastOne<Omit<ListRunsQueryParams, keyof CursorPageParams>>;
|
||||
|
||||
export type BulkActionSelection =
|
||||
| { filter: BulkActionFilter; runIds?: never }
|
||||
| { runIds: string[]; filter?: never };
|
||||
|
||||
type BaseBulkActionOptions = BulkActionSelection & {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type TargetRegionOption = {
|
||||
/** Region identifier to replay runs in. When omitted, each replay keeps the original run's region. */
|
||||
targetRegion?: string;
|
||||
};
|
||||
|
||||
export type CreateBulkActionOptions =
|
||||
| (BaseBulkActionOptions & {
|
||||
action: "cancel";
|
||||
targetRegion?: never;
|
||||
})
|
||||
| (BaseBulkActionOptions & { action: "replay" } & TargetRegionOption);
|
||||
|
||||
export type CreateBulkCancelActionOptions = BaseBulkActionOptions & {
|
||||
targetRegion?: never;
|
||||
};
|
||||
|
||||
export type CreateBulkReplayActionOptions = BaseBulkActionOptions & TargetRegionOption;
|
||||
|
||||
export type ListBulkActionsQueryParams = CursorPageParams;
|
||||
|
||||
export interface SubscribeToRunsQueryParams {
|
||||
tasks?: Array<string> | string;
|
||||
tags?: Array<string> | string;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "./common.js";
|
||||
import { BackgroundWorkerMetadata } from "./resources.js";
|
||||
import { DequeuedMessage, MachineResources } from "./runEngine.js";
|
||||
import { QueueTypeName } from "./queues.js";
|
||||
|
||||
export const RunEngineVersion = z.union([z.literal("V1"), z.literal("V2")]);
|
||||
|
||||
@@ -1223,6 +1224,118 @@ export const ListRunResponse = z.object({
|
||||
|
||||
export type ListRunResponse = z.infer<typeof ListRunResponse>;
|
||||
|
||||
const StringOrStringArray = z.union([z.string(), z.array(z.string())]);
|
||||
const MachineOrMachineArray = z.union([MachinePresetName, z.array(MachinePresetName)]);
|
||||
const QueueOrQueueArray = z.union([QueueTypeName, z.array(QueueTypeName)]);
|
||||
const DateOrNumber = z.union([z.coerce.date(), z.number()]);
|
||||
|
||||
const BulkActionFilterRequestBody = z
|
||||
.object({
|
||||
status: z.union([RunStatus, z.array(RunStatus)]).optional(),
|
||||
taskIdentifier: StringOrStringArray.optional(),
|
||||
version: StringOrStringArray.optional(),
|
||||
from: DateOrNumber.optional(),
|
||||
to: DateOrNumber.optional(),
|
||||
period: z.string().optional(),
|
||||
bulkAction: z.string().optional(),
|
||||
tag: StringOrStringArray.optional(),
|
||||
schedule: z.string().optional(),
|
||||
isTest: z.boolean().optional(),
|
||||
batch: z.string().optional(),
|
||||
queue: QueueOrQueueArray.optional(),
|
||||
machine: MachineOrMachineArray.optional(),
|
||||
region: StringOrStringArray.optional(),
|
||||
})
|
||||
.refine((filter) => Object.values(filter).some(isNonEmptyBulkActionFilterValue), {
|
||||
message: "At least one filter must be provided",
|
||||
});
|
||||
|
||||
/** Recursively checks for at least one non-undefined, non-empty value. */
|
||||
function isNonEmptyBulkActionFilterValue(value: unknown): boolean {
|
||||
if (value === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(isNonEmptyBulkActionFilterValue);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value.trim().length > 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const BulkActionSelectionRequestBody = {
|
||||
filter: BulkActionFilterRequestBody.optional(),
|
||||
runIds: z.array(z.string()).min(1).optional(),
|
||||
name: z.string().max(255, "Name must be less than 255 characters").optional(),
|
||||
};
|
||||
|
||||
export const CreateBulkActionRequestBody = z
|
||||
.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("cancel"),
|
||||
targetRegion: z.never().optional(),
|
||||
...BulkActionSelectionRequestBody,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("replay"),
|
||||
targetRegion: z.string().optional(),
|
||||
...BulkActionSelectionRequestBody,
|
||||
}),
|
||||
])
|
||||
.refine((body) => (body.filter ? 1 : 0) + (body.runIds ? 1 : 0) === 1, {
|
||||
message: "Exactly one of filter or runIds must be provided",
|
||||
});
|
||||
|
||||
export type CreateBulkActionRequestBody = z.infer<typeof CreateBulkActionRequestBody>;
|
||||
|
||||
export const BulkActionStatus = z.enum(["PENDING", "COMPLETED", "ABORTED"]);
|
||||
export type BulkActionStatus = z.infer<typeof BulkActionStatus>;
|
||||
|
||||
export const BulkActionType = z.enum(["CANCEL", "REPLAY"]);
|
||||
export type BulkActionType = z.infer<typeof BulkActionType>;
|
||||
|
||||
export const BulkActionObject = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
type: BulkActionType,
|
||||
status: BulkActionStatus,
|
||||
counts: z.object({
|
||||
total: z.number(),
|
||||
success: z.number(),
|
||||
failure: z.number(),
|
||||
}),
|
||||
createdAt: z.coerce.date(),
|
||||
completedAt: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export type BulkActionObject = z.infer<typeof BulkActionObject>;
|
||||
|
||||
export const CreateBulkActionResponseBody = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export type CreateBulkActionResponseBody = z.infer<typeof CreateBulkActionResponseBody>;
|
||||
|
||||
export const AbortBulkActionResponseBody = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export type AbortBulkActionResponseBody = z.infer<typeof AbortBulkActionResponseBody>;
|
||||
|
||||
export const ListBulkActionsResponseBody = z.object({
|
||||
data: z.array(BulkActionObject),
|
||||
pagination: z.object({
|
||||
next: z.string().optional(),
|
||||
previous: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type ListBulkActionsResponseBody = z.infer<typeof ListBulkActionsResponseBody>;
|
||||
|
||||
export const CreateEnvironmentVariableRequestBody = z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
|
||||
@@ -55,6 +55,7 @@ export {
|
||||
type AnyRealtimeRun,
|
||||
type RetrieveRunResult,
|
||||
type AnyRetrieveRunResult,
|
||||
type BulkAction,
|
||||
} from "./runs.js";
|
||||
export * as schedules from "./schedules/index.js";
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { apiClientManager } from "@trigger.dev/core/v3";
|
||||
import { runs } from "./runs.js";
|
||||
|
||||
type ReceivedRequest = {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: IncomingMessage["headers"];
|
||||
body: string;
|
||||
};
|
||||
|
||||
type RequestHandler = (request: ReceivedRequest, response: ServerResponse) => void | Promise<void>;
|
||||
|
||||
describe("runs.bulk", () => {
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
let receivedRequests: ReceivedRequest[] = [];
|
||||
let requestHandler: RequestHandler | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
receivedRequests = [];
|
||||
requestHandler = undefined;
|
||||
|
||||
server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk) => chunks.push(chunk));
|
||||
req.on("end", async () => {
|
||||
const received = {
|
||||
method: req.method ?? "",
|
||||
url: req.url ?? "",
|
||||
headers: req.headers,
|
||||
body: Buffer.concat(chunks).toString(),
|
||||
} satisfies ReceivedRequest;
|
||||
receivedRequests.push(received);
|
||||
|
||||
try {
|
||||
if (requestHandler) {
|
||||
await requestHandler(received, res);
|
||||
} else {
|
||||
json(res, { error: "No handler" }, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
json(res, { error: error instanceof Error ? error.message : String(error) }, 500);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address() as AddressInfo;
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
apiClientManager.disable();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it("creates a cancel bulk action", async () => {
|
||||
requestHandler = (_request, response) => json(response, { id: "bulk_cancel" });
|
||||
|
||||
const result = await withApiClient(() =>
|
||||
runs.bulk.cancel({ runIds: ["run_1", "run_2"], name: "Cancel selected" })
|
||||
);
|
||||
|
||||
expect(result).toEqual({ id: "bulk_cancel" });
|
||||
expect(receivedRequests[0]?.method).toBe("POST");
|
||||
expect(receivedRequests[0]?.url).toBe("/api/v1/bulk-actions");
|
||||
expect(JSON.parse(receivedRequests[0]?.body ?? "{}")).toEqual({
|
||||
action: "cancel",
|
||||
runIds: ["run_1", "run_2"],
|
||||
name: "Cancel selected",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a replay bulk action", async () => {
|
||||
requestHandler = (_request, response) => json(response, { id: "bulk_replay" });
|
||||
|
||||
const result = await withApiClient(() =>
|
||||
runs.bulk.replay({
|
||||
filter: { status: "FAILED", taskIdentifier: ["task-a", "task-b"] },
|
||||
name: "Replay failed tasks",
|
||||
targetRegion: "eu_1",
|
||||
})
|
||||
);
|
||||
|
||||
expect(result).toEqual({ id: "bulk_replay" });
|
||||
expect(receivedRequests[0]?.method).toBe("POST");
|
||||
expect(receivedRequests[0]?.url).toBe("/api/v1/bulk-actions");
|
||||
expect(JSON.parse(receivedRequests[0]?.body ?? "{}")).toEqual({
|
||||
action: "replay",
|
||||
filter: { status: "FAILED", taskIdentifier: ["task-a", "task-b"] },
|
||||
name: "Replay failed tasks",
|
||||
targetRegion: "eu_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("retrieves and aborts bulk actions", async () => {
|
||||
requestHandler = (request, response) => {
|
||||
if (request.method === "GET") {
|
||||
return json(response, bulkActionObject("bulk_read", "PENDING"));
|
||||
}
|
||||
|
||||
return json(response, { id: "bulk_read" });
|
||||
};
|
||||
|
||||
const retrieved = await withApiClient(() => runs.bulk.retrieve("bulk_read"));
|
||||
const aborted = await withApiClient(() => runs.bulk.abort("bulk_read"));
|
||||
|
||||
expect(retrieved.id).toBe("bulk_read");
|
||||
expect(retrieved.createdAt).toEqual(new Date("2026-07-01T10:00:00.000Z"));
|
||||
expect(aborted).toEqual({ id: "bulk_read" });
|
||||
expect(receivedRequests.map((request) => `${request.method} ${request.url}`)).toEqual([
|
||||
"GET /api/v1/bulk-actions/bulk_read",
|
||||
"POST /api/v1/bulk-actions/bulk_read/abort",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists bulk actions", async () => {
|
||||
requestHandler = (_request, response) =>
|
||||
json(response, {
|
||||
data: [bulkActionObject("bulk_listed", "COMPLETED")],
|
||||
pagination: { next: "cursor_next" },
|
||||
});
|
||||
|
||||
const page = await withApiClient(() => runs.bulk.list({ limit: 1, before: "cursor_before" }));
|
||||
|
||||
const url = new URL(receivedRequests[0]?.url ?? "", baseUrl);
|
||||
expect(receivedRequests[0]?.method).toBe("GET");
|
||||
expect(url.pathname).toBe("/api/v1/bulk-actions");
|
||||
expect(url.searchParams.get("page[size]")).toBe("1");
|
||||
expect(url.searchParams.get("page[before]")).toBe("cursor_before");
|
||||
expect(page.data[0]?.id).toBe("bulk_listed");
|
||||
expect(page.pagination.next).toBe("cursor_next");
|
||||
});
|
||||
|
||||
it("polls until the bulk action finishes", async () => {
|
||||
requestHandler = (_request, response) => {
|
||||
const status = receivedRequests.length === 1 ? "PENDING" : "COMPLETED";
|
||||
return json(response, bulkActionObject("bulk_poll", status));
|
||||
};
|
||||
|
||||
const bulkAction = await withApiClient(() =>
|
||||
runs.bulk.poll("bulk_poll", { pollIntervalMs: 1 })
|
||||
);
|
||||
|
||||
expect(bulkAction.status).toBe("COMPLETED");
|
||||
expect(receivedRequests.map((request) => request.url)).toEqual([
|
||||
"/api/v1/bulk-actions/bulk_poll",
|
||||
"/api/v1/bulk-actions/bulk_poll",
|
||||
]);
|
||||
});
|
||||
|
||||
function withApiClient<T>(fn: () => Promise<T>) {
|
||||
return apiClientManager.runWithConfig(
|
||||
{ baseURL: baseUrl, accessToken: "tr_test_key" },
|
||||
async () => fn()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function json(response: ServerResponse, body: unknown, status = 200) {
|
||||
response.writeHead(status, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function bulkActionObject(id: string, status: "PENDING" | "COMPLETED" | "ABORTED") {
|
||||
return {
|
||||
id,
|
||||
type: "REPLAY",
|
||||
status,
|
||||
counts: { total: 2, success: status === "COMPLETED" ? 2 : 0, failure: 0 },
|
||||
createdAt: "2026-07-01T10:00:00.000Z",
|
||||
completedAt: status === "COMPLETED" ? "2026-07-01T10:05:00.000Z" : undefined,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,10 @@ import type {
|
||||
AnyRetrieveRunResult,
|
||||
AnyRunShape,
|
||||
ApiRequestOptions,
|
||||
CreateBulkCancelActionOptions,
|
||||
CreateBulkReplayActionOptions,
|
||||
InferRunTypes,
|
||||
ListBulkActionsQueryParams,
|
||||
ListProjectRunsQueryParams,
|
||||
ListRunsQueryParams,
|
||||
RescheduleRunRequestBody,
|
||||
@@ -16,7 +19,10 @@ import type {
|
||||
AsyncIterableStream,
|
||||
ApiPromise,
|
||||
RealtimeRunSkipColumns,
|
||||
AbortBulkActionResponseBody,
|
||||
BulkActionObject,
|
||||
CanceledRunResponse,
|
||||
CreateBulkActionResponseBody,
|
||||
CursorPagePromise,
|
||||
ListRunResponseItem,
|
||||
ReplayRunResponse,
|
||||
@@ -49,6 +55,14 @@ export const runs = {
|
||||
retrieve: retrieveRun,
|
||||
list: listRuns,
|
||||
reschedule: rescheduleRun,
|
||||
bulk: {
|
||||
cancel: bulkCancelRuns,
|
||||
replay: bulkReplayRuns,
|
||||
retrieve: retrieveBulkAction,
|
||||
abort: abortBulkAction,
|
||||
list: listBulkActions,
|
||||
poll: pollBulkAction,
|
||||
},
|
||||
poll,
|
||||
subscribeToRun,
|
||||
subscribeToRunsWithTag,
|
||||
@@ -57,6 +71,7 @@ export const runs = {
|
||||
};
|
||||
|
||||
export type ListRunsItem = ListRunResponseItem;
|
||||
export type BulkAction = BulkActionObject;
|
||||
|
||||
function listRuns(
|
||||
projectRef: string,
|
||||
@@ -278,6 +293,139 @@ function cancelRun(
|
||||
return apiClient.cancelRun(runId, $requestOptions);
|
||||
}
|
||||
|
||||
function bulkCancelRuns(
|
||||
options: CreateBulkCancelActionOptions,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<CreateBulkActionResponseBody> {
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
|
||||
const $requestOptions = mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "runs.bulk.cancel()",
|
||||
icon: "runs",
|
||||
attributes: {
|
||||
...flattenAttributes(options as Record<string, unknown>, "bulkAction"),
|
||||
},
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
|
||||
return apiClient.createBulkAction({ ...options, action: "cancel" }, $requestOptions);
|
||||
}
|
||||
|
||||
function bulkReplayRuns(
|
||||
options: CreateBulkReplayActionOptions,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<CreateBulkActionResponseBody> {
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
|
||||
const $requestOptions = mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "runs.bulk.replay()",
|
||||
icon: "runs",
|
||||
attributes: {
|
||||
...flattenAttributes(options as Record<string, unknown>, "bulkAction"),
|
||||
},
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
|
||||
return apiClient.createBulkAction({ ...options, action: "replay" }, $requestOptions);
|
||||
}
|
||||
|
||||
function retrieveBulkAction(
|
||||
bulkActionId: string,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<BulkActionObject> {
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
|
||||
const $requestOptions = mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "runs.bulk.retrieve()",
|
||||
icon: "runs",
|
||||
attributes: {
|
||||
bulkActionId,
|
||||
...accessoryAttributes({
|
||||
items: [{ text: bulkActionId, variant: "normal" }],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
|
||||
return apiClient.retrieveBulkAction(bulkActionId, $requestOptions);
|
||||
}
|
||||
|
||||
function abortBulkAction(
|
||||
bulkActionId: string,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<AbortBulkActionResponseBody> {
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
|
||||
const $requestOptions = mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "runs.bulk.abort()",
|
||||
icon: "runs",
|
||||
attributes: {
|
||||
bulkActionId,
|
||||
...accessoryAttributes({
|
||||
items: [{ text: bulkActionId, variant: "normal" }],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
|
||||
return apiClient.abortBulkAction(bulkActionId, $requestOptions);
|
||||
}
|
||||
|
||||
function listBulkActions(
|
||||
params?: ListBulkActionsQueryParams,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): CursorPagePromise<typeof BulkActionObject> {
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
|
||||
const $requestOptions = mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "runs.bulk.list()",
|
||||
icon: "runs",
|
||||
attributes: {
|
||||
...flattenAttributes(params as Record<string, unknown>, "queryParams"),
|
||||
},
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
|
||||
return apiClient.listBulkActions(params, $requestOptions);
|
||||
}
|
||||
|
||||
async function pollBulkAction(
|
||||
bulkActionId: string,
|
||||
options?: { pollIntervalMs?: number },
|
||||
requestOptions?: ApiRequestOptions
|
||||
): Promise<BulkActionObject> {
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts++ < MAX_POLL_ATTEMPTS) {
|
||||
const bulkAction = await retrieveBulkAction(bulkActionId, requestOptions);
|
||||
|
||||
if (bulkAction.status !== "PENDING") {
|
||||
return bulkAction;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, options?.pollIntervalMs ?? 1000));
|
||||
}
|
||||
|
||||
throw new Error(`Bulk action ${bulkActionId} did not finish after ${MAX_POLL_ATTEMPTS} attempts`);
|
||||
}
|
||||
|
||||
function rescheduleRun(
|
||||
runId: string,
|
||||
body: RescheduleRunRequestBody,
|
||||
|
||||
Reference in New Issue
Block a user