Repository navigation
Bug: eventFilterMatches returns false for nested $exists: false when the parent key is missing or null #5017
Description
Summary
eventFilterMatches returns false for a nested $exists: false filter when the parent key is missing or null in the payload. The function returns false for any non-empty filter as soon as it meets an undefined/null payload, before it evaluates $exists. This is user-facing through retry.fetch's bodyFilter, which calls eventFilterMatches on the response body.
Reproduction
- Setup:
packages/coreonmain. - Command:
npx tsx repro.tsfrompackages/corewith:
import { eventFilterMatches } from "./src/eventFilterMatches.js";
const f = { user: { email: [{ $exists: false }] } };
console.log(eventFilterMatches({}, f)); // false
console.log(eventFilterMatches({ user: null }, f)); // false
console.log(eventFilterMatches({ user: {} }, f)); // true
console.log(eventFilterMatches({}, { email: [{ $exists: false }] })); // true- Trigger: a nested filter whose leaf is
$exists: false, used with a payload where an ancestor key is absent ornull.
Actual behavior
{} {"user":{"email":[{"$exists":false}]}} => false
{"user":null} {"user":{"email":[{"$exists":false}]}} => false
{"user":{}} {"user":{"email":[{"$exists":false}]}} => true
{} {"email":[{"$exists":false}]} => true
Expected behavior
All four should return true. If user is missing, then user.email doesn't exist either, which is exactly what $exists: false asserts. Top-level $exists: false already behaves that way (case 4, and the existing test "should return true when payload does NOT have a key that $exists = false"). Nesting the same condition one level deeper shouldn't flip the result.
Environment
- Version/commit:
main@ 93465d8 - OS: Ubuntu (Linux), Node v24.21.0
- Relevant configuration: none; pure function. Reachable from
retry.fetch(url, { retry: { byStatus: { "...": { strategy: "backoff", bodyFilter } } } })
Evidence
- Early return for
undefined/nullpayload with a non-empty filter:trigger.dev/packages/core/src/eventFilterMatches.ts
Lines 7 to 14 in 93465d8
export function eventFilterMatches(payload: any, filter: EventFilter): boolean { if (payload === undefined || payload === null) { if (Object.entries(filter).length === 0) { return true; } else { return false; } } - Recursion into nested object filters:
trigger.dev/packages/core/src/eventFilterMatches.ts
Lines 61 to 71 in 93465d8
continue; } else if (typeof patternValue === "object") { if (Array.isArray(payloadValue)) { if (!payloadValue.some((item) => eventFilterMatches(item, patternValue))) { return false; } } else { if (!eventFilterMatches(payloadValue, patternValue)) { return false; } } retry.fetchbodyFilterpath:trigger.dev/packages/trigger-sdk/src/v3/retry.ts
Lines 449 to 460 in 93465d8
if (strategy.bodyFilter) { const body = safeJsonParse(await clonedResponse.text()); if (!body) { continue; } if (eventFilterMatches(body, strategy.bodyFilter)) { return strategy; } else { continue; } bodyFilter: EventFilterin the fetch retry schema:andbodyFilter: EventFilter.optional(), bodyFilter: EventFilter.optional(), - Existing tests cover only top-level
$exists:trigger.dev/packages/core/test/eventFilterMatches.test.ts
Lines 193 to 208 in 93465d8
it("should return true when payload does NOT have a key that $exists = false", () => { const payload = { name: "John", age: 30, isAdmin: false, hobbies: ["reading", "swimming"], address: { street: "123 Main St", city: "Anytown", state: "CA", zip: "12345", }, }; const filter: EventFilter = { foo: [{ $exists: false }], };
Additional context
Confirmed: the four results above, from running the source on main.
Inference: for retry.fetch, a bodyFilter such as { error: { retryAfter: [{ $exists: false }] } } won't match a body that has no error object at all. That strategy is then skipped. I haven't exercised this through retry.fetch end to end.
Possible fix: when the payload is undefined/null, evaluate the filter against an empty object (or recurse with {}) instead of returning false, so that $exists: false and $isNull-style leaves still get evaluated.
Found with an anti-slop oxlint scan (no-runtime-typeof), then checked by hand.
Activity