Files
triggerdotdev--trigger.dev/oxlint-plugins/prisma-in-filter.mjs
T
Eric Allam c526528d8f
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
feat(webapp,database): bound Prisma list filter arity (#4480)
## Summary

Prisma expands `in` / `notIn` into one bind parameter per element, so
every distinct list
length is a separate prepared statement. Where the length tracks data
volume (a batch size,
a run-graph fan-out, a prior query's id set) one call site can mint
hundreds of them. Each
is used about once, but inserting it evicts an entry that was being
reused, so the cost
lands on unrelated queries sharing the pooler's statement cache. An
unbounded list also
risks the 65535 bind-parameter ceiling.

`boundedIn()` pads a filter list to the next power of two by repeating
its last element.
`IN` and `NOT IN` ignore duplicates, so results are unchanged, and a
call site drops from
one statement per length to at most `log2(cap)`. Applied to all existing
sites.

## Enforcement

Two oxlint rules require the helper: a list filter must be an inline
array literal or a
`boundedIn()` call.

- The first covers filters reached through `where` / `having` /
`cursor`, and deliberately
never descends into `data`, `create`, `update`, `set` or `equals`. A key
named `in` in
those positions is user data, not a predicate, and rewriting it would
corrupt what gets
  stored or compared.
- The second covers bare filter objects passed to where-building
helpers, which the first
cannot see. It found five sites in the run-graph batch loaders that were
otherwise
  invisible.

Both rules follow filters through the shapes they are actually written
in: conditional
expressions, logical-and objects, spread-conditional properties,
computed keys, and call
arguments. An array literal only counts as fixed-arity when nothing
spreads into it, since
`[...new Set(ids)]` has a runtime length. Twelve sites were hidden
behind those shapes
until the rules handled them.

Scoped to `in` and `notIn`. The scalar-list filters `hasSome` and
`hasEvery` compile to
`&& $1` and `@> $1`, passing the whole array as a single bind parameter,
so their arity never
reaches the statement text and there is nothing to bound.

Both rules are `error`, so new call sites fail CI. That ratchet has
already caught four
sites added by other PRs while this one was in review.

## Notes

`boundedIn` pads by repeating rather than with null: `x NOT IN (a, b,
NULL)` is never true,
so null-padding a `notIn` filter would silently return no rows. Lists
above 32768 are
returned unchanged so padding can never push a query past the parameter
limit.

Route modules reach the helper through `~/db.server` rather than
importing the database
barrel directly, since a value import of that barrel into a module that
also exports a React
component is only safe while dead-code elimination prunes it.

Measured on a local rig: 300 distinct list lengths produce 300 prepared
statements
unpadded, 10 padded. Verified end-to-end against a local stack with the
full task-suite
sweep, which surfaced no regressions.
2026-08-07 16:39:58 +01:00

258 lines
8.8 KiB
JavaScript

/**
* oxlint plugin: trigger-prisma — flags `in:` / `notIn:` list filters.
*
* Prisma expands a list filter into one bind parameter per element, so every distinct list
* length is a separate prepared statement. Where the list length tracks data volume (batch
* size, run-graph fan-out, a prior query's id set) a single call site can mint hundreds of
* statements, and the pooler's prepared-statement cache evicts entries that were being
* reused to make room for ones that never will be.
*
* The fix is per call site: bound the list, chunk it to a fixed size, or rewrite to
* `= ANY($1)` so arity stops changing the SQL. This rule enumerates the sites that need
* that treatment and stops new ones appearing.
*
* Deliberately scoped to filter position. A key named `in` inside `data`, `create`,
* `update`, `set` or a JSON `equals` value is user data, not a predicate, and must never be
* touched — rewriting those corrupts what gets stored or compared.
*/
/** Subtrees that hold predicates. Descend into these. */
const FILTER_ROOTS = new Set(["where", "having", "cursor"]);
/**
* Keys whose values are stored or compared verbatim. Never descend into these, even inside
* a `where`: a JSON column's `equals` value is data, not a predicate.
*/
const VALUE_POSITION = new Set([
"data",
"create",
"update",
"set",
"equals",
"connect",
"connectOrCreate",
"select",
"include",
"_count",
]);
/**
* Only `in` and `notIn` expand to one bind parameter per element. The scalar-list filters
* `hasSome` and `hasEvery` compile to `&& $1` and `@> $1`, passing the whole array as a single
* parameter, so their arity never reaches the statement text and bounding them would add
* elements for no benefit.
*/
const LIST_FILTERS = new Set(["in", "notIn"]);
/**
* Helpers whose first argument IS a where clause, so the filter arrives as a bare object
* with no `where:` key for the main rule to key off. Repo-specific by design, in the same
* spirit as the delegate list in runops-residency.mjs: an explicit list cannot silently
* stop matching the way a heuristic can.
*/
const FILTER_ARG_HELPERS = new Set(["targetFindManyArgs"]);
/** Fallback for helpers that follow the naming convention but are not listed above. */
const FILTER_ARG_HELPER_PATTERN =
/(?:FindMany|FindFirst|FindUnique|Count|DeleteMany|UpdateMany)Args$/;
function isFilterArgHelper(callee) {
const name =
callee.type === "Identifier"
? callee.name
: callee.type === "MemberExpression" &&
!callee.computed &&
callee.property.type === "Identifier"
? callee.property.name
: undefined;
if (!name) return false;
return FILTER_ARG_HELPERS.has(name) || FILTER_ARG_HELPER_PATTERN.test(name);
}
/** The sanctioned bounding helper from `@trigger.dev/database`. */
const BOUNDING_HELPER = "boundedIn";
/**
* A list filter is acceptable when its arity cannot vary at runtime: an inline array
* literal (fixed in the source) or a `boundedIn()` call (padded to a power of two).
* Type-only wrappers are unwrapped so `boundedIn(ids) as string[]` still counts.
*
* An array literal counts only when nothing spreads into it. `[...new Set(ids)]` is an
* ArrayExpression whose length is decided at runtime, which is precisely the case the
* helper exists for.
*/
function isBounded(node) {
let current = node;
while (
current &&
(current.type === "TSAsExpression" ||
current.type === "TSSatisfiesExpression" ||
current.type === "TSNonNullExpression")
) {
current = current.expression;
}
if (!current) return false;
if (current.type === "ArrayExpression") {
return current.elements.every((element) => !element || element.type !== "SpreadElement");
}
if (current.type === "CallExpression") {
const callee = current.callee;
if (callee.type === "Identifier") return callee.name === BOUNDING_HELPER;
if (callee.type === "MemberExpression" && !callee.computed) {
return callee.property.type === "Identifier" && callee.property.name === BOUNDING_HELPER;
}
}
return false;
}
function propertyKeyName(node) {
if (!node || node.type !== "Property") return undefined;
const key = node.key;
if (!node.computed && key.type === "Identifier") return key.name;
if (key.type === "Literal" && typeof key.value === "string") return key.value;
return undefined;
}
/**
* Reports every `in` / `notIn` reachable from a filter root without passing through a
* value-position key. Depth-bounded so a pathological args object cannot stall the linter.
*
* Filters are routinely assembled conditionally, so the walk follows the shapes that carry
* them: `cond ? { … } : {}`, `cond && { … }`, and `...(cond ? { … } : {})`. Stopping at a
* plain ObjectExpression would leave those permanently invisible to the rule.
*
* It also follows call arguments, so a filter fragment built by a helper and spread into
* `where` is still inspected, and it descends through properties whose key it cannot read
* statically. A computed key inside a filter subtree is a column name, so the value below
* it is still predicate territory; skipping it would hide the whole branch.
*/
function reportListFilters(node, context, depth, messageId = "listFilter", extra = {}) {
if (!node || typeof node !== "object" || depth > 12) return;
const descend = (child) => reportListFilters(child, context, depth + 1, messageId, extra);
switch (node.type) {
case "TSAsExpression":
case "TSSatisfiesExpression":
case "TSNonNullExpression":
return descend(node.expression);
case "ConditionalExpression":
descend(node.consequent);
return descend(node.alternate);
case "LogicalExpression":
descend(node.left);
return descend(node.right);
case "ArrayExpression":
for (const element of node.elements) descend(element);
return;
case "SpreadElement":
return descend(node.argument);
case "CallExpression":
for (const argument of node.arguments) descend(argument);
return;
default:
break;
}
if (node.type !== "ObjectExpression") return;
for (const property of node.properties) {
if (property.type === "SpreadElement") {
descend(property.argument);
continue;
}
if (property.type !== "Property") continue;
const name = propertyKeyName(property);
if (!name) {
descend(property.value);
continue;
}
if (VALUE_POSITION.has(name)) continue;
if (LIST_FILTERS.has(name)) {
if (!isBounded(property.value)) {
context.report({
node: property,
messageId,
data: { filter: name, ...extra },
});
}
continue;
}
reportListFilters(property.value, context, depth + 1, messageId, extra);
}
}
/** @type {import("eslint").Rule.RuleModule} */
const noUnboundedListFilter = {
meta: {
type: "problem",
docs: {
description:
"Disallow `in` / `notIn` list filters, whose arity changes the generated SQL and churns the prepared-statement cache.",
},
messages: {
listFilter:
"Prisma `{{filter}}:` filter. Its length becomes the bind-parameter count, so each distinct length is a separate prepared statement. Bound or chunk the list, or rewrite to `= ANY($1)`. If the length is genuinely fixed and small, disable this line with a reason.",
},
schema: [],
},
create(context) {
return {
Property(node) {
const name = propertyKeyName(node);
if (!name || !FILTER_ROOTS.has(name)) return;
reportListFilters(node.value, context, 0);
},
};
},
};
/** @type {import("eslint").Rule.RuleModule} */
const noUnboundedListFilterInArgsHelper = {
meta: {
type: "problem",
docs: {
description:
"Disallow `in` / `notIn` in a bare filter object passed to a where-building helper, which the where-keyed rule cannot see.",
},
messages: {
listFilter:
"Prisma `{{filter}}:` filter passed to `{{helper}}()` as a bare where clause. Its length becomes the bind-parameter count, so each distinct length is a separate prepared statement. Bound or chunk the list, or rewrite to `= ANY($1)`.",
},
schema: [],
},
create(context) {
return {
CallExpression(node) {
if (!isFilterArgHelper(node.callee)) return;
const first = node.arguments[0];
if (!first || first.type !== "ObjectExpression") return;
const helper =
node.callee.type === "Identifier" ? node.callee.name : node.callee.property.name;
reportListFilters(first, context, 0, "listFilter", { helper });
},
};
},
};
/** @type {import("eslint").ESLint.Plugin} */
const plugin = {
meta: { name: "trigger-prisma" },
rules: {
"no-unbounded-list-filter": noUnboundedListFilter,
"no-unbounded-list-filter-in-args-helper": noUnboundedListFilterInArgsHelper,
},
};
export default plugin;