Skip to content

Commit 71e2fea

Browse files
fix(core): match nested event filters when an ancestor key is missing or null
`eventFilterMatches` returned `false` for any non-empty filter as soon as the payload was `undefined`/`null`, without evaluating the filter itself. Because missing nested keys recurse with an `undefined` payload, a nested `$exists: false` filter failed whenever its parent key was missing or `null`, while the same filter returned `true` at the top level. A nullish payload is now matched like an empty object, so a missing payload and a missing key take the same path. Only filters whose leaves already match a missing key change behavior: `$exists: false`, `$isNull: false` and `$anythingBut` now match a missing/`null`/non-object ancestor too, which is how `$exists` behaves on nested paths elsewhere (e.g. MongoDB) and how these filters already behaved for a missing key at the top level. Filters that require a value (`$exists: true`, `$startsWith`, `$gt`, value arrays, `$includes`, `$not`, ...) are unchanged. This is the user-facing path behind `retry.fetch`'s `bodyFilter`, so a filter like `{ error: { retryAfter: [{ $exists: false }] } }` now matches a response body with no `error` object instead of silently skipping the retry strategy. - core: unit tests for missing/null/non-object ancestors, deep nesting, arrays, and guards proving positive filters don't over-match - trigger-sdk: end-to-end tests for `retry.fetch` + `bodyFilter` against a local server
1 parent 93465d8 commit 71e2fea

4 files changed

Lines changed: 449 additions & 8 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
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.
6+
7+
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.

‎packages/core/src/eventFilterMatches.ts‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,17 @@ import type { EventFilter } from "./schemas/eventFilter.js";
55
// If the values of the array are objects, then we are doing content filtering
66
// An example would be [{ $endsWith: ".png" }, { $startsWith: "images/" } ]
77
export function eventFilterMatches(payload: any, filter: EventFilter): boolean {
8-
if (payload === undefined || payload === null) {
9-
if (Object.entries(filter).length === 0) {
10-
return true;
11-
} else {
12-
return false;
13-
}
14-
}
8+
// A missing (undefined/null) payload is matched like an empty object, so that a missing payload and
9+
// a missing key are treated the same way. Filters that assert the absence of a value (`$exists:
10+
// false`, `$isNull: false`, `$anythingBut`) therefore match when an ancestor key is missing or
11+
// null, instead of failing before the filter is evaluated. This is the same result nested paths
12+
// give in MongoDB-style filters, and the same result these filters already gave for a missing key
13+
// at the top level. Filters that need a value (`$exists: true`, `$startsWith`, `$gt`, ...) still
14+
// don't match `undefined`.
15+
const payloadObject = payload === undefined || payload === null ? {} : payload;
1516

1617
for (const [patternKey, patternValue] of Object.entries(filter)) {
17-
const payloadValue = payload[patternKey];
18+
const payloadValue = payloadObject[patternKey];
1819

1920
if (Array.isArray(patternValue)) {
2021
if (patternValue.length === 0) {

‎packages/core/test/eventFilterMatches.test.ts‎

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ describe("eventFilterMatches", () => {
88
expect(eventFilterMatches(payload, filter)).toBe(true);
99
});
1010

11+
it("should return true if the payload is null and the filter is empty", () => {
12+
const payload = null;
13+
const filter: EventFilter = {};
14+
expect(eventFilterMatches(payload, filter)).toBe(true);
15+
});
16+
1117
it("should return false if the payload is undefined and the filter isn't empty", () => {
1218
const payload = undefined;
1319
const filter: EventFilter = {
@@ -16,6 +22,22 @@ describe("eventFilterMatches", () => {
1622
expect(eventFilterMatches(payload, filter)).toBe(false);
1723
});
1824

25+
it("should return true if the payload is undefined and the filter asserts the absence of a key", () => {
26+
const payload = undefined;
27+
const filter: EventFilter = {
28+
name: [{ $exists: false }],
29+
};
30+
expect(eventFilterMatches(payload, filter)).toBe(true);
31+
});
32+
33+
it("should return true if the payload is null and the filter asserts the absence of a key", () => {
34+
const payload = null;
35+
const filter: EventFilter = {
36+
name: [{ $exists: false }],
37+
};
38+
expect(eventFilterMatches(payload, filter)).toBe(true);
39+
});
40+
1941
it("should return true when payload matches string filter", () => {
2042
const payload = {
2143
name: "John",
@@ -209,6 +231,139 @@ describe("eventFilterMatches", () => {
209231
expect(eventFilterMatches(payload, filter)).toBe(true);
210232
});
211233

234+
it("should return true when payload does NOT have a nested key that $exists = false", () => {
235+
const payload = {
236+
name: "John",
237+
age: 30,
238+
isAdmin: false,
239+
hobbies: ["reading", "swimming"],
240+
address: {
241+
street: "123 Main St",
242+
city: "Anytown",
243+
},
244+
};
245+
const filter: EventFilter = {
246+
address: {
247+
zip: [{ $exists: false }],
248+
},
249+
};
250+
expect(eventFilterMatches(payload, filter)).toBe(true);
251+
});
252+
253+
it("should return true when the parent key of a nested $exists = false filter is missing", () => {
254+
const payload = {};
255+
const filter: EventFilter = {
256+
user: {
257+
email: [{ $exists: false }],
258+
},
259+
};
260+
expect(eventFilterMatches(payload, filter)).toBe(true);
261+
});
262+
263+
it("should return true when the parent key of a nested $exists = false filter is null", () => {
264+
const payload = {
265+
user: null,
266+
};
267+
const filter: EventFilter = {
268+
user: {
269+
email: [{ $exists: false }],
270+
},
271+
};
272+
expect(eventFilterMatches(payload, filter)).toBe(true);
273+
});
274+
275+
it("should return true when the parent key of a nested $exists = false filter is an empty object", () => {
276+
const payload = {
277+
user: {},
278+
};
279+
const filter: EventFilter = {
280+
user: {
281+
email: [{ $exists: false }],
282+
},
283+
};
284+
expect(eventFilterMatches(payload, filter)).toBe(true);
285+
});
286+
287+
it("should return true when the payload is null and a nested $exists = false filter is used", () => {
288+
const payload = null;
289+
const filter: EventFilter = {
290+
user: {
291+
email: [{ $exists: false }],
292+
},
293+
};
294+
expect(eventFilterMatches(payload, filter)).toBe(true);
295+
});
296+
297+
it("should return true when the parent key of a nested $exists = false filter isn't an object", () => {
298+
const payload = {
299+
user: 42,
300+
};
301+
const filter: EventFilter = {
302+
user: {
303+
email: [{ $exists: false }],
304+
},
305+
};
306+
expect(eventFilterMatches(payload, filter)).toBe(true);
307+
});
308+
309+
it("should return true when a nested $isNull = false filter has no parent key", () => {
310+
const payload = {
311+
name: "John",
312+
};
313+
const filter: EventFilter = {
314+
user: {
315+
email: [{ $isNull: false }],
316+
},
317+
};
318+
expect(eventFilterMatches(payload, filter)).toBe(true);
319+
});
320+
321+
it("should return true when a nested $anythingBut filter has no parent key", () => {
322+
const payload = {
323+
name: "John",
324+
};
325+
const filter: EventFilter = {
326+
user: {
327+
email: [{ $anythingBut: "banned@example.com" }],
328+
},
329+
};
330+
expect(eventFilterMatches(payload, filter)).toBe(true);
331+
});
332+
333+
it("should return true when an ancestor key of a nested $exists = false filter is missing", () => {
334+
const payload = {
335+
account: {},
336+
};
337+
const filter: EventFilter = {
338+
account: {
339+
profile: {
340+
settings: {
341+
theme: [{ $exists: false }],
342+
},
343+
},
344+
},
345+
};
346+
expect(eventFilterMatches(payload, filter)).toBe(true);
347+
});
348+
349+
it("should return true when an array item does NOT have a nested key that $exists = false", () => {
350+
const payload = {
351+
items: [
352+
{ id: 1 },
353+
{
354+
id: 2,
355+
deletedAt: "2024-01-01T00:00:00.000Z",
356+
},
357+
],
358+
};
359+
const filter: EventFilter = {
360+
items: {
361+
deletedAt: [{ $exists: false }],
362+
},
363+
};
364+
expect(eventFilterMatches(payload, filter)).toBe(true);
365+
});
366+
212367
it("should return true when payload does match numeric condition", () => {
213368
const payload = {
214369
name: "John",
@@ -560,6 +715,167 @@ describe("eventFilterMatches", () => {
560715
expect(eventFilterMatches(payload, filter)).toBe(false);
561716
});
562717

718+
it("should return false when the parent key of a nested $exists = true filter is missing", () => {
719+
const payload = {
720+
name: "Jane",
721+
age: 25,
722+
isAdmin: true,
723+
};
724+
const filter: EventFilter = {
725+
user: {
726+
email: [{ $exists: true }],
727+
},
728+
};
729+
expect(eventFilterMatches(payload, filter)).toBe(false);
730+
});
731+
732+
it("should return false when the parent key of a nested $exists = true filter is null", () => {
733+
const payload = {
734+
name: "Jane",
735+
age: 25,
736+
isAdmin: true,
737+
user: null,
738+
};
739+
const filter: EventFilter = {
740+
user: {
741+
email: [{ $exists: true }],
742+
},
743+
};
744+
expect(eventFilterMatches(payload, filter)).toBe(false);
745+
});
746+
747+
it("should return false when the parent key of a nested $exists = true filter isn't an object", () => {
748+
const payload = {
749+
name: "Jane",
750+
age: 25,
751+
isAdmin: true,
752+
user: 42,
753+
};
754+
const filter: EventFilter = {
755+
user: {
756+
email: [{ $exists: true }],
757+
},
758+
};
759+
expect(eventFilterMatches(payload, filter)).toBe(false);
760+
});
761+
762+
it("should return false when a nested $isNull = true filter has no parent key", () => {
763+
const payload = {
764+
name: "Jane",
765+
};
766+
const filter: EventFilter = {
767+
user: {
768+
email: [{ $isNull: true }],
769+
},
770+
};
771+
expect(eventFilterMatches(payload, filter)).toBe(false);
772+
});
773+
774+
it("should return false when payload does have a nested key that $exists = false", () => {
775+
const payload = {
776+
name: "Jane",
777+
age: 25,
778+
isAdmin: true,
779+
address: {
780+
street: "456 Elm St",
781+
city: "Othertown",
782+
},
783+
};
784+
const filter: EventFilter = {
785+
address: {
786+
city: [{ $exists: false }],
787+
},
788+
};
789+
expect(eventFilterMatches(payload, filter)).toBe(false);
790+
});
791+
792+
it("should return false when the parent key of a nested value filter is missing", () => {
793+
const payload = {
794+
name: "Jane",
795+
age: 25,
796+
isAdmin: true,
797+
};
798+
const filter: EventFilter = {
799+
user: {
800+
email: ["john@example.com"],
801+
},
802+
};
803+
expect(eventFilterMatches(payload, filter)).toBe(false);
804+
});
805+
806+
it("should return false when the parent key of a nested value filter is null", () => {
807+
const payload = {
808+
name: "Jane",
809+
age: 25,
810+
isAdmin: true,
811+
user: null,
812+
};
813+
const filter: EventFilter = {
814+
user: {
815+
email: ["john@example.com"],
816+
},
817+
};
818+
expect(eventFilterMatches(payload, filter)).toBe(false);
819+
});
820+
821+
it("should return false when the parent key of a nested $startsWith filter is missing", () => {
822+
const payload = {
823+
name: "Jane",
824+
age: 25,
825+
isAdmin: true,
826+
};
827+
const filter: EventFilter = {
828+
user: {
829+
email: [{ $startsWith: "john" }],
830+
},
831+
};
832+
expect(eventFilterMatches(payload, filter)).toBe(false);
833+
});
834+
835+
it("should return false when the parent key of a nested $gt filter is missing", () => {
836+
const payload = {
837+
name: "Jane",
838+
age: 25,
839+
isAdmin: true,
840+
};
841+
const filter: EventFilter = {
842+
metrics: {
843+
score: [{ $gt: 10 }],
844+
},
845+
};
846+
expect(eventFilterMatches(payload, filter)).toBe(false);
847+
});
848+
849+
it("should return false when a nested filter doesn't match even though a sibling key does", () => {
850+
const payload = {
851+
name: "Jane",
852+
age: 25,
853+
isAdmin: true,
854+
};
855+
const filter: EventFilter = {
856+
name: ["Jane"],
857+
user: {
858+
email: [{ $exists: true }],
859+
},
860+
};
861+
expect(eventFilterMatches(payload, filter)).toBe(false);
862+
});
863+
864+
it("should return false when a nested filter doesn't match any item of an array", () => {
865+
const payload = {
866+
items: [
867+
{ id: 1, deletedAt: "2024-01-01T00:00:00.000Z" },
868+
{ id: 2, deletedAt: "2024-02-01T00:00:00.000Z" },
869+
],
870+
};
871+
const filter: EventFilter = {
872+
items: {
873+
deletedAt: [{ $exists: false }],
874+
},
875+
};
876+
expect(eventFilterMatches(payload, filter)).toBe(false);
877+
});
878+
563879
it("should return false when the payload does not match the numeric filters", () => {
564880
const payload = {
565881
name: "Jane",

0 commit comments

Comments
 (0)