Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/nested-event-filter-exists-false.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@trigger.dev/core": patch
---

Fix `eventFilterMatches` so nested filters that assert the absence of a value match when an ancestor key is missing or `null`, instead of failing before the filter is evaluated. A missing payload is now matched like an empty object: it's how `$exists` behaves on nested paths elsewhere, and how these filters already behaved for a missing key at the top level.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Lead the release note with user behavior

The changeset opens with eventFilterMatches instead of the user-visible change to nested filters. Repository guidance asks release notes to lead with user behavior.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.


This changes the result only for filters whose leaves match a missing key today, i.e. `$exists: false`, `$isNull: false` and `$anythingBut`. Filters that require a value (`$exists: true`, `$startsWith`, `$gt`, value arrays, ...) are unchanged. It fixes `retry.fetch`'s `bodyFilter` (e.g. `{ error: { retryAfter: [{ $exists: false }] } }`) not matching responses that have no `error` object.
17 changes: 9 additions & 8 deletions packages/core/src/eventFilterMatches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@ import type { EventFilter } from "./schemas/eventFilter.js";
// If the values of the array are objects, then we are doing content filtering
// An example would be [{ $endsWith: ".png" }, { $startsWith: "images/" } ]
export function eventFilterMatches(payload: any, filter: EventFilter): boolean {
if (payload === undefined || payload === null) {
if (Object.entries(filter).length === 0) {
return true;
} else {
return false;
}
}
// A missing (undefined/null) payload is matched like an empty object, so that a missing payload and
// a missing key are treated the same way. Filters that assert the absence of a value (`$exists:
// false`, `$isNull: false`, `$anythingBut`) therefore match when an ancestor key is missing or
// null, instead of failing before the filter is evaluated. This is the same result nested paths
// give in MongoDB-style filters, and the same result these filters already gave for a missing key
// at the top level. Filters that need a value (`$exists: true`, `$startsWith`, `$gt`, ...) still
// don't match `undefined`.
const payloadObject = payload === undefined || payload === null ? {} : payload;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Missing parents match empty nested filters

When an ancestor is missing, eventFilterMatches now accepts nested {} or [] filters without an absence condition. A retry.fetch body filter like { error: {} } can retry responses without error.

Learn more

An empty nested object or array matches any present value, but the previous nullish guard rejected it when its parent was missing. The new normalization lets those empty filters succeed despite having no leaf that tests absence. This also changes SDK retry selection because getRetryStrategyForResponse uses this matcher for response bodies.

Example: With payload {} and filter { error: {} }, the previous matcher returned false; it now returns true. A retry.fetch strategy using that filter can retry a response with no error field.

Recommended fix: When traversing a missing or null ancestor, distinguish filters that actually match a missing leaf from vacuous matches produced by empty nested objects or arrays. Preserve the previous result for the latter and add tests for both empty forms.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.


for (const [patternKey, patternValue] of Object.entries(filter)) {
const payloadValue = payload[patternKey];
const payloadValue = payloadObject[patternKey];

if (Array.isArray(patternValue)) {
if (patternValue.length === 0) {
Expand Down
316 changes: 316 additions & 0 deletions packages/core/test/eventFilterMatches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ describe("eventFilterMatches", () => {
expect(eventFilterMatches(payload, filter)).toBe(true);
});

it("should return true if the payload is null and the filter is empty", () => {
const payload = null;
const filter: EventFilter = {};
expect(eventFilterMatches(payload, filter)).toBe(true);
});

it("should return false if the payload is undefined and the filter isn't empty", () => {
const payload = undefined;
const filter: EventFilter = {
Expand All @@ -16,6 +22,22 @@ describe("eventFilterMatches", () => {
expect(eventFilterMatches(payload, filter)).toBe(false);
});

it("should return true if the payload is undefined and the filter asserts the absence of a key", () => {
const payload = undefined;
const filter: EventFilter = {
name: [{ $exists: false }],
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});

it("should return true if the payload is null and the filter asserts the absence of a key", () => {
const payload = null;
const filter: EventFilter = {
name: [{ $exists: false }],
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});

it("should return true when payload matches string filter", () => {
const payload = {
name: "John",
Expand Down Expand Up @@ -209,6 +231,139 @@ describe("eventFilterMatches", () => {
expect(eventFilterMatches(payload, filter)).toBe(true);
});

it("should return true when payload does NOT have a nested key that $exists = false", () => {
const payload = {
name: "John",
age: 30,
isAdmin: false,
hobbies: ["reading", "swimming"],
address: {
street: "123 Main St",
city: "Anytown",
},
};
const filter: EventFilter = {
address: {
zip: [{ $exists: false }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});

it("should return true when the parent key of a nested $exists = false filter is missing", () => {
const payload = {};
const filter: EventFilter = {
user: {
email: [{ $exists: false }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});

it("should return true when the parent key of a nested $exists = false filter is null", () => {
const payload = {
user: null,
};
const filter: EventFilter = {
user: {
email: [{ $exists: false }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});

it("should return true when the parent key of a nested $exists = false filter is an empty object", () => {
const payload = {
user: {},
};
const filter: EventFilter = {
user: {
email: [{ $exists: false }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});

it("should return true when the payload is null and a nested $exists = false filter is used", () => {
const payload = null;
const filter: EventFilter = {
user: {
email: [{ $exists: false }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});

it("should return true when the parent key of a nested $exists = false filter isn't an object", () => {
const payload = {
user: 42,
};
const filter: EventFilter = {
user: {
email: [{ $exists: false }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});

it("should return true when a nested $isNull = false filter has no parent key", () => {
const payload = {
name: "John",
};
const filter: EventFilter = {
user: {
email: [{ $isNull: false }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});

it("should return true when a nested $anythingBut filter has no parent key", () => {
const payload = {
name: "John",
};
const filter: EventFilter = {
user: {
email: [{ $anythingBut: "banned@example.com" }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});

it("should return true when an ancestor key of a nested $exists = false filter is missing", () => {
const payload = {
account: {},
};
const filter: EventFilter = {
account: {
profile: {
settings: {
theme: [{ $exists: false }],
},
},
},
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});

it("should return true when an array item does NOT have a nested key that $exists = false", () => {
const payload = {
items: [
{ id: 1 },
{
id: 2,
deletedAt: "2024-01-01T00:00:00.000Z",
},
],
};
const filter: EventFilter = {
items: {
deletedAt: [{ $exists: false }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(true);
});

it("should return true when payload does match numeric condition", () => {
const payload = {
name: "John",
Expand Down Expand Up @@ -560,6 +715,167 @@ describe("eventFilterMatches", () => {
expect(eventFilterMatches(payload, filter)).toBe(false);
});

it("should return false when the parent key of a nested $exists = true filter is missing", () => {
const payload = {
name: "Jane",
age: 25,
isAdmin: true,
};
const filter: EventFilter = {
user: {
email: [{ $exists: true }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});

it("should return false when the parent key of a nested $exists = true filter is null", () => {
const payload = {
name: "Jane",
age: 25,
isAdmin: true,
user: null,
};
const filter: EventFilter = {
user: {
email: [{ $exists: true }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});

it("should return false when the parent key of a nested $exists = true filter isn't an object", () => {
const payload = {
name: "Jane",
age: 25,
isAdmin: true,
user: 42,
};
const filter: EventFilter = {
user: {
email: [{ $exists: true }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});

it("should return false when a nested $isNull = true filter has no parent key", () => {
const payload = {
name: "Jane",
};
const filter: EventFilter = {
user: {
email: [{ $isNull: true }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});

it("should return false when payload does have a nested key that $exists = false", () => {
const payload = {
name: "Jane",
age: 25,
isAdmin: true,
address: {
street: "456 Elm St",
city: "Othertown",
},
};
const filter: EventFilter = {
address: {
city: [{ $exists: false }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});

it("should return false when the parent key of a nested value filter is missing", () => {
const payload = {
name: "Jane",
age: 25,
isAdmin: true,
};
const filter: EventFilter = {
user: {
email: ["john@example.com"],
},
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});

it("should return false when the parent key of a nested value filter is null", () => {
const payload = {
name: "Jane",
age: 25,
isAdmin: true,
user: null,
};
const filter: EventFilter = {
user: {
email: ["john@example.com"],
},
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});

it("should return false when the parent key of a nested $startsWith filter is missing", () => {
const payload = {
name: "Jane",
age: 25,
isAdmin: true,
};
const filter: EventFilter = {
user: {
email: [{ $startsWith: "john" }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});

it("should return false when the parent key of a nested $gt filter is missing", () => {
const payload = {
name: "Jane",
age: 25,
isAdmin: true,
};
const filter: EventFilter = {
metrics: {
score: [{ $gt: 10 }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});

it("should return false when a nested filter doesn't match even though a sibling key does", () => {
const payload = {
name: "Jane",
age: 25,
isAdmin: true,
};
const filter: EventFilter = {
name: ["Jane"],
user: {
email: [{ $exists: true }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});

it("should return false when a nested filter doesn't match any item of an array", () => {
const payload = {
items: [
{ id: 1, deletedAt: "2024-01-01T00:00:00.000Z" },
{ id: 2, deletedAt: "2024-02-01T00:00:00.000Z" },
],
};
const filter: EventFilter = {
items: {
deletedAt: [{ $exists: false }],
},
};
expect(eventFilterMatches(payload, filter)).toBe(false);
});

it("should return false when the payload does not match the numeric filters", () => {
const payload = {
name: "Jane",
Expand Down
Loading