Skip to content

Commit eb4604d

Browse files
committed
fix(webapp): close three blind spots in the list-filter lint rule
The rule accepted any array literal as fixed-arity, but a literal containing a spread has runtime-variable length, so [...new Set(ids)] passed. It also walked only plain object properties, leaving filters assembled conditionally invisible: spread-conditional properties, ternary-valued properties, and logical-and objects. Ten further call sites were unbounded behind those shapes, including one in PostgresRunStore whose four sibling hydrators had all been converted.
1 parent d447f96 commit eb4604d

10 files changed

Lines changed: 53 additions & 19 deletions

apps/webapp/app/models/member.server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.se
1111
import { rbac } from "~/services/rbac.server";
1212
import { ssoController } from "~/services/sso.server";
1313

14+
import { boundedIn } from "@trigger.dev/database";
1415
export const INVITE_NOT_FOUND = "Invite not found";
1516
export const INVITE_BLOCKED_DIRECTORY_MANAGED =
1617
"Membership for this organization is managed by Directory Sync, so invites can't be accepted.";
@@ -134,7 +135,7 @@ export async function inviteMembers({
134135
const existingMembers = await prisma.orgMember.findMany({
135136
where: {
136137
organizationId: org.id,
137-
user: { email: { in: [...uniqueEmails] } },
138+
user: { email: { in: boundedIn([...uniqueEmails]) } },
138139
},
139140
select: { user: { select: { email: true } } },
140141
});

apps/webapp/app/presenters/v3/BatchListPresenter.server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type BatchTaskRunStatus } from "@trigger.dev/database";
1+
import { type BatchTaskRunStatus, boundedIn } from "@trigger.dev/database";
22
import { type RunOpsPrismaClient } from "@internal/run-ops-database";
33
import parse from "parse-duration";
44
import { type PrismaClientOrTransaction } from "~/db.server";
@@ -263,7 +263,7 @@ export class BatchListPresenter extends BasePresenter {
263263
: {}),
264264
...(friendlyId ? { friendlyId } : {}),
265265
...(statuses && statuses.length > 0
266-
? { status: { in: statuses }, batchVersion: { not: "v1" } }
266+
? { status: { in: boundedIn(statuses) }, batchVersion: { not: "v1" } }
267267
: {}),
268268
...(createdAtGte !== undefined || createdAtLte !== undefined
269269
? {

apps/webapp/app/presenters/v3/RegionsPresenter.server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type WorkloadType } from "@trigger.dev/database";
1+
import { type WorkloadType, boundedIn } from "@trigger.dev/database";
22
import { type Project } from "~/models/project.server";
33
import { type User } from "~/models/user.server";
44
import { FEATURE_FLAG } from "~/v3/featureFlags";
@@ -87,7 +87,7 @@ export class RegionsPresenter extends BasePresenter {
8787
: // Hide hidden unless they're allowed to use them
8888
project.allowedWorkerQueues.length > 0
8989
? {
90-
masterQueue: { in: project.allowedWorkerQueues },
90+
masterQueue: { in: boundedIn(project.allowedWorkerQueues) },
9191
}
9292
: defaultVisibilityFilter(hasComputeAccess),
9393
orderBy: {

apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type RuntimeEnvironmentType, type ScheduleType } from "@trigger.dev/database";
1+
import { type RuntimeEnvironmentType, type ScheduleType, boundedIn } from "@trigger.dev/database";
22
import { type ScheduleListFilters } from "~/components/runs/v3/ScheduleFilters";
33
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
44
import { getTaskIdentifiers } from "~/models/task.server";
@@ -164,7 +164,7 @@ export class ScheduleListPresenter extends BasePresenter {
164164
const totalCount = await this._replica.taskSchedule.count({
165165
where: {
166166
projectId: project.id,
167-
taskIdentifier: tasks ? { in: tasks } : undefined,
167+
taskIdentifier: tasks ? { in: boundedIn(tasks) } : undefined,
168168
instances: {
169169
some: {
170170
environmentId,
@@ -227,7 +227,7 @@ export class ScheduleListPresenter extends BasePresenter {
227227
},
228228
where: {
229229
projectId: project.id,
230-
taskIdentifier: tasks ? { in: tasks } : undefined,
230+
taskIdentifier: tasks ? { in: boundedIn(tasks) } : undefined,
231231
instances: {
232232
some: {
233233
environmentId,

apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type RunEngineVersion,
44
type RuntimeEnvironmentType,
55
type WaitpointStatus,
6+
boundedIn,
67
} from "@trigger.dev/database";
78
import { type Direction } from "~/components/ListPagination";
89
import { type PrismaClientOrTransaction } from "~/db.server";
@@ -186,7 +187,7 @@ export class WaitpointListPresenter extends BasePresenter {
186187
type: "MANUAL",
187188
...(cursor ? { id: direction === "forward" ? { lt: cursor } : { gt: cursor } } : {}),
188189
...(id ? { friendlyId: id } : {}),
189-
...(statusesToFilter.length ? { status: { in: statusesToFilter } } : {}),
190+
...(statusesToFilter.length ? { status: { in: boundedIn(statusesToFilter) } } : {}),
190191
...(filterOutputIsError !== undefined ? { outputIsError: filterOutputIsError } : {}),
191192
...(idempotencyKey
192193
? { OR: [{ idempotencyKey }, { inactiveIdempotencyKey: idempotencyKey }] }

apps/webapp/app/routes/admin.api.v1.environments.$environmentId.engine.repair-queues.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
77
import { determineEngineVersion } from "~/v3/engineVersion.server";
88
import { engine } from "~/v3/runEngine.server";
99

10+
import { boundedIn } from "@trigger.dev/database";
1011
const ParamsSchema = z.object({
1112
environmentId: z.string(),
1213
});
@@ -49,7 +50,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
4950
where: {
5051
runtimeEnvironmentId: environment.id,
5152
version: "V2",
52-
name: parsedBody.queues.length > 0 ? { in: parsedBody.queues } : undefined,
53+
name: parsedBody.queues.length > 0 ? { in: boundedIn(parsedBody.queues) } : undefined,
5354
},
5455
select: {
5556
friendlyId: true,

apps/webapp/app/v3/services/billingLimit/billingLimitConvergeEnvironments.server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type PrismaClient,
55
type Project,
66
type RuntimeEnvironment,
7+
boundedIn,
78
} from "@trigger.dev/database";
89
import { prisma } from "~/db.server";
910
import { logger } from "~/services/logger.server";
@@ -71,7 +72,7 @@ async function pauseBillingLimitEnvironments(
7172
const environments = await db.runtimeEnvironment.findMany({
7273
where: {
7374
organizationId,
74-
type: { in: [...BILLABLE_ENVIRONMENT_TYPES] },
75+
type: { in: boundedIn([...BILLABLE_ENVIRONMENT_TYPES]) },
7576
paused: false,
7677
},
7778
take: batchSize,

apps/webapp/app/v3/services/billingLimit/billingLimitQueuedRuns.server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstan
55
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
66
import { BILLABLE_ENVIRONMENT_TYPES } from "./billingLimitConstants";
77

8+
import { boundedIn } from "@trigger.dev/database";
89
export type BillableEnvironmentRef = {
910
id: string;
1011
projectId: string;
@@ -17,7 +18,7 @@ export async function getBillableEnvironmentsForBillingLimit(
1718
return prismaClient.runtimeEnvironment.findMany({
1819
where: {
1920
organizationId,
20-
type: { in: [...BILLABLE_ENVIRONMENT_TYPES] },
21+
type: { in: boundedIn([...BILLABLE_ENVIRONMENT_TYPES]) },
2122
},
2223
select: {
2324
id: true,

internal-packages/run-store/src/PostgresRunStore.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ async function batchHydrateEdgeTarget(
427427
return byParent;
428428
}
429429
const rows = (await targetDelegate.findMany(
430-
targetFindManyArgs({ id: { in: [...new Set(targetIds)] } }, projection, ["id"])
430+
targetFindManyArgs({ id: { in: boundedIn([...new Set(targetIds)]) } }, projection, ["id"])
431431
)) as Record<string, unknown>[];
432432
const byTargetId = new Map(rows.map((r) => [r.id as string, r]));
433433
for (const p of parents) {

oxlint-plugins/prisma-in-filter.mjs

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ const BOUNDING_HELPER = "boundedIn";
7070
* A list filter is acceptable when its arity cannot vary at runtime: an inline array
7171
* literal (fixed in the source) or a `boundedIn()` call (padded to a power of two).
7272
* Type-only wrappers are unwrapped so `boundedIn(ids) as string[]` still counts.
73+
*
74+
* An array literal counts only when nothing spreads into it. `[...new Set(ids)]` is an
75+
* ArrayExpression whose length is decided at runtime, which is precisely the case the
76+
* helper exists for.
7377
*/
7478
function isBounded(node) {
7579
let current = node;
@@ -83,7 +87,9 @@ function isBounded(node) {
8387
}
8488
if (!current) return false;
8589

86-
if (current.type === "ArrayExpression") return true;
90+
if (current.type === "ArrayExpression") {
91+
return current.elements.every((element) => !element || element.type !== "SpreadElement");
92+
}
8793

8894
if (current.type === "CallExpression") {
8995
const callee = current.callee;
@@ -107,20 +113,43 @@ function propertyKeyName(node) {
107113
/**
108114
* Reports every `in` / `notIn` reachable from a filter root without passing through a
109115
* value-position key. Depth-bounded so a pathological args object cannot stall the linter.
116+
*
117+
* Filters are routinely assembled conditionally, so the walk follows the shapes that carry
118+
* them: `cond ? { … } : {}`, `cond && { … }`, and `...(cond ? { … } : {})`. Stopping at a
119+
* plain ObjectExpression would leave those permanently invisible to the rule.
110120
*/
111121
function reportListFilters(node, context, depth, messageId = "listFilter", extra = {}) {
112122
if (!node || typeof node !== "object" || depth > 12) return;
113123

114-
if (node.type === "ArrayExpression") {
115-
for (const element of node.elements) {
116-
reportListFilters(element, context, depth + 1, messageId, extra);
117-
}
118-
return;
124+
const descend = (child) => reportListFilters(child, context, depth + 1, messageId, extra);
125+
126+
switch (node.type) {
127+
case "TSAsExpression":
128+
case "TSSatisfiesExpression":
129+
case "TSNonNullExpression":
130+
return descend(node.expression);
131+
case "ConditionalExpression":
132+
descend(node.consequent);
133+
return descend(node.alternate);
134+
case "LogicalExpression":
135+
descend(node.left);
136+
return descend(node.right);
137+
case "ArrayExpression":
138+
for (const element of node.elements) descend(element);
139+
return;
140+
case "SpreadElement":
141+
return descend(node.argument);
142+
default:
143+
break;
119144
}
120145

121146
if (node.type !== "ObjectExpression") return;
122147

123148
for (const property of node.properties) {
149+
if (property.type === "SpreadElement") {
150+
descend(property.argument);
151+
continue;
152+
}
124153
if (property.type !== "Property") continue;
125154

126155
const name = propertyKeyName(property);

0 commit comments

Comments
 (0)