Skip to content

Commit d789912

Browse files
committed
disallow empty filter
1 parent 6dcdfc8 commit d789912

4 files changed

Lines changed: 65 additions & 20 deletions

File tree

apps/webapp/test/bulk-actions-api.e2e.full.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,22 +159,41 @@ describe("Bulk actions API", () => {
159159
const response = await server.webapp.fetch("/api/v1/bulk-actions", {
160160
method: "POST",
161161
headers: authHeaders(apiKey),
162-
body: JSON.stringify({ action: "cancel", filter: {}, runIds: ["run_123"] }),
162+
body: JSON.stringify({ action: "cancel", filter: { status: "FAILED" }, runIds: ["run_123"] }),
163163
});
164164

165165
expect(response.status).toBe(400);
166166
const body = await response.json();
167167
expect(body.error).toContain("Exactly one of filter or runIds must be provided");
168168
});
169169

170+
it("rejects create requests with an empty filter", async () => {
171+
const server = getTestServer();
172+
const { apiKey } = await seedTestEnvironment(server.prisma);
173+
174+
const response = await server.webapp.fetch("/api/v1/bulk-actions", {
175+
method: "POST",
176+
headers: authHeaders(apiKey),
177+
body: JSON.stringify({ action: "cancel", filter: {} }),
178+
});
179+
180+
expect(response.status).toBe(400);
181+
const body = await response.json();
182+
expect(body.error).toContain("At least one filter must be provided");
183+
});
184+
170185
it("returns a generic error for unexpected create failures", async () => {
171186
const server = getTestServer();
172187
const { apiKey } = await seedTestEnvironment(server.prisma);
173188

174189
const response = await server.webapp.fetch("/api/v1/bulk-actions", {
175190
method: "POST",
176191
headers: authHeaders(apiKey),
177-
body: JSON.stringify({ action: "cancel", filter: {}, name: "No ClickHouse in this suite" }),
192+
body: JSON.stringify({
193+
action: "cancel",
194+
filter: { status: "FAILED" },
195+
name: "No ClickHouse in this suite",
196+
}),
178197
});
179198

180199
expect(response.status).toBe(500);

docs/management/runs/bulk-actions.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const completed = await runs.bulk.poll(action.id);
2828
console.log(completed.status, completed.counts);
2929
```
3030

31-
`filter` accepts the same filters as [`runs.list()`](/management/runs/list), excluding pagination fields. Relative time filters such as `period` are resolved when the bulk action is created, so later batches process the same fixed time range.
31+
`filter` accepts the same filters as [`runs.list()`](/management/runs/list), excluding pagination fields. Provide at least one filter field; use `runIds` when you want to target specific runs. Relative time filters such as `period` are resolved when the bulk action is created, so later batches process the same fixed time range.
3232

3333
<ParamField body="filter" type="BulkActionFilter">
3434
Selects runs using the same filter shape as `runs.list()`, excluding `limit`, `after`, and `before`.

packages/core/src/v3/apiClient/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,13 @@ export interface ListProjectRunsQueryParams extends CursorPageParams, ListRunsQu
7070
env?: Array<"dev" | "staging" | "prod"> | "dev" | "staging" | "prod";
7171
}
7272

73+
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = T &
74+
{
75+
[K in Keys]-?: Required<Pick<T, K>>;
76+
}[Keys];
77+
7378
/** Same filters as runs.list(), excluding pagination. */
74-
export type BulkActionFilter = Omit<ListRunsQueryParams, keyof CursorPageParams>;
79+
export type BulkActionFilter = RequireAtLeastOne<Omit<ListRunsQueryParams, keyof CursorPageParams>>;
7580

7681
export type BulkActionSelection =
7782
| { filter: BulkActionFilter; runIds?: never }

packages/core/src/v3/schemas/api.ts

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1229,22 +1229,43 @@ const MachineOrMachineArray = z.union([MachinePresetName, z.array(MachinePresetN
12291229
const QueueOrQueueArray = z.union([QueueTypeName, z.array(QueueTypeName)]);
12301230
const DateOrNumber = z.union([z.coerce.date(), z.number()]);
12311231

1232-
const BulkActionFilterRequestBody = z.object({
1233-
status: z.union([RunStatus, z.array(RunStatus)]).optional(),
1234-
taskIdentifier: StringOrStringArray.optional(),
1235-
version: StringOrStringArray.optional(),
1236-
from: DateOrNumber.optional(),
1237-
to: DateOrNumber.optional(),
1238-
period: z.string().optional(),
1239-
bulkAction: z.string().optional(),
1240-
tag: StringOrStringArray.optional(),
1241-
schedule: z.string().optional(),
1242-
isTest: z.boolean().optional(),
1243-
batch: z.string().optional(),
1244-
queue: QueueOrQueueArray.optional(),
1245-
machine: MachineOrMachineArray.optional(),
1246-
region: StringOrStringArray.optional(),
1247-
});
1232+
const BulkActionFilterRequestBody = z
1233+
.object({
1234+
status: z.union([RunStatus, z.array(RunStatus)]).optional(),
1235+
taskIdentifier: StringOrStringArray.optional(),
1236+
version: StringOrStringArray.optional(),
1237+
from: DateOrNumber.optional(),
1238+
to: DateOrNumber.optional(),
1239+
period: z.string().optional(),
1240+
bulkAction: z.string().optional(),
1241+
tag: StringOrStringArray.optional(),
1242+
schedule: z.string().optional(),
1243+
isTest: z.boolean().optional(),
1244+
batch: z.string().optional(),
1245+
queue: QueueOrQueueArray.optional(),
1246+
machine: MachineOrMachineArray.optional(),
1247+
region: StringOrStringArray.optional(),
1248+
})
1249+
.refine((filter) => Object.values(filter).some(isNonEmptyBulkActionFilterValue), {
1250+
message: "At least one filter must be provided",
1251+
});
1252+
1253+
/** Recursively checks for at least one non-undefined, non-empty value. */
1254+
function isNonEmptyBulkActionFilterValue(value: unknown): boolean {
1255+
if (value === undefined) {
1256+
return false;
1257+
}
1258+
1259+
if (Array.isArray(value)) {
1260+
return value.some(isNonEmptyBulkActionFilterValue);
1261+
}
1262+
1263+
if (typeof value === "string") {
1264+
return value.trim().length > 0;
1265+
}
1266+
1267+
return true;
1268+
}
12481269

12491270
const BulkActionSelectionRequestBody = {
12501271
filter: BulkActionFilterRequestBody.optional(),

0 commit comments

Comments
 (0)