diff --git a/README.md b/README.md index 5edd037e..f3414593 100644 --- a/README.md +++ b/README.md @@ -8,16 +8,16 @@ This repository contains a set of tests to evaluate and compare the compatibilit -| Gateway | Compatibility | Test Cases | Test Suites | -| :---------------------------------------------------------------------------------------------------------: | :-----------: | :----------: | :---------: | -| [Hive Gateway](https://the-guild.dev/graphql/hive/gateway) | 100.00% | 🟢 192 | 🟢 44 | -| [Hive Gateway (Rust QP)](https://the-guild.dev/graphql/hive/docs/gateway/other-features/rust-query-planner) | 100.00% | 🟢 192 | 🟢 44 | -| [Hive Router](https://github.com/graphql-hive/router) | 100.00% | 🟢 192 | 🟢 44 | -| [Apollo Router](https://www.apollographql.com/) | 97.40% | 🟢 187 ❌ 5 | 🟢 41 ❌ 3 | -| [Apollo Gateway](https://www.apollographql.com/) | 96.88% | 🟢 186 ❌ 6 | 🟢 40 ❌ 4 | -| [Cosmo Router](https://wundergraph.com) | 94.27% | 🟢 181 ❌ 11 | 🟢 37 ❌ 7 | -| [Grafbase Gateway](https://grafbase.com) | 91.67% | 🟢 176 ❌ 16 | 🟢 38 ❌ 6 | -| [Inigo Gateway](https://inigo.io) | 47.92% | 🟢 92 ❌ 100 | 🟢 12 ❌ 32 | +| Gateway | Compatibility | Test Cases | Test Suites | +| :---------------------------------------------------------------------------------------------------------: | :-----------: | :-----------: | :---------: | +| [Hive Gateway (Rust QP)](https://the-guild.dev/graphql/hive/docs/gateway/other-features/rust-query-planner) | 100.00% | 🟢 203 | 🟢 45 | +| [Hive Router](https://github.com/graphql-hive/router) | 100.00% | 🟢 203 | 🟢 45 | +| [Hive Gateway](https://the-guild.dev/graphql/hive/gateway) | 99.51% | 🟢 202 ❌ 1 | 🟢 44 ❌ 1 | +| [Apollo Router](https://www.apollographql.com/) | 97.54% | 🟢 198 ❌ 5 | 🟢 42 ❌ 3 | +| [Apollo Gateway](https://www.apollographql.com/) | 97.04% | 🟢 197 ❌ 6 | 🟢 41 ❌ 4 | +| [Cosmo Router](https://wundergraph.com) | 94.58% | 🟢 192 ❌ 11 | 🟢 38 ❌ 7 | +| [Grafbase Gateway](https://grafbase.com) | 92.12% | 🟢 187 ❌ 16 | 🟢 39 ❌ 6 | +| [Inigo Gateway](https://inigo.io) | 50.25% | 🟢 102 ❌ 101 | 🟢 12 ❌ 33 | diff --git a/REPORT.md b/REPORT.md index 45ed2fd5..6c2db115 100644 --- a/REPORT.md +++ b/REPORT.md @@ -2,16 +2,16 @@ ## Summary -| Gateway | Compatibility | Test Cases | Test Suites | -| :---------------------------------------------------------------------------------------------------------: | :-----------: | :----------: | :---------: | -| [Hive Gateway](https://the-guild.dev/graphql/hive/gateway) | 100.00% | 🟢 192 | 🟢 44 | -| [Hive Gateway (Rust QP)](https://the-guild.dev/graphql/hive/docs/gateway/other-features/rust-query-planner) | 100.00% | 🟢 192 | 🟢 44 | -| [Hive Router](https://github.com/graphql-hive/router) | 100.00% | 🟢 192 | 🟢 44 | -| [Apollo Router](https://www.apollographql.com/) | 97.40% | 🟢 187 ❌ 5 | 🟢 41 ❌ 3 | -| [Apollo Gateway](https://www.apollographql.com/) | 96.88% | 🟢 186 ❌ 6 | 🟢 40 ❌ 4 | -| [Cosmo Router](https://wundergraph.com) | 94.27% | 🟢 181 ❌ 11 | 🟢 37 ❌ 7 | -| [Grafbase Gateway](https://grafbase.com) | 91.67% | 🟢 176 ❌ 16 | 🟢 38 ❌ 6 | -| [Inigo Gateway](https://inigo.io) | 47.92% | 🟢 92 ❌ 100 | 🟢 12 ❌ 32 | +| Gateway | Compatibility | Test Cases | Test Suites | +| :---------------------------------------------------------------------------------------------------------: | :-----------: | :-----------: | :---------: | +| [Hive Gateway (Rust QP)](https://the-guild.dev/graphql/hive/docs/gateway/other-features/rust-query-planner) | 100.00% | 🟢 203 | 🟢 45 | +| [Hive Router](https://github.com/graphql-hive/router) | 100.00% | 🟢 203 | 🟢 45 | +| [Hive Gateway](https://the-guild.dev/graphql/hive/gateway) | 99.51% | 🟢 202 ❌ 1 | 🟢 44 ❌ 1 | +| [Apollo Router](https://www.apollographql.com/) | 97.54% | 🟢 198 ❌ 5 | 🟢 42 ❌ 3 | +| [Apollo Gateway](https://www.apollographql.com/) | 97.04% | 🟢 197 ❌ 6 | 🟢 41 ❌ 4 | +| [Cosmo Router](https://wundergraph.com) | 94.58% | 🟢 192 ❌ 11 | 🟢 38 ❌ 7 | +| [Grafbase Gateway](https://grafbase.com) | 92.12% | 🟢 187 ❌ 16 | 🟢 39 ❌ 6 | +| [Inigo Gateway](https://inigo.io) | 50.25% | 🟢 102 ❌ 101 | 🟢 12 ❌ 33 | ## Detailed Results @@ -19,12 +19,12 @@ Take a closer look at the results for each gateway. You can look at the full list of tests [here](./src/test-suites/). Every test id corresponds to a directory in the `src/test-suites` folder. - + -### Hive Gateway +### Hive Gateway (Rust QP) - [Repository](https://github.com/graphql-hive/gateway) -- [Website](https://the-guild.dev/graphql/hive/gateway) +- [Website](https://the-guild.dev/graphql/hive/docs/gateway/other-features/rust-query-planner)
Results @@ -84,6 +84,8 @@ You can look at the full list of tests [here](./src/test-suites/). Every test id
🟢🟢
provides-on-union
🟢🟢
+provides-only-requested-fields +
🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢
requires-circular
🟢🟢
requires-interface @@ -118,12 +120,12 @@ You can look at the full list of tests [here](./src/test-suites/). Every test id
🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢
- + -### Hive Gateway (Rust QP) +### Hive Router -- [Repository](https://github.com/graphql-hive/gateway) -- [Website](https://the-guild.dev/graphql/hive/docs/gateway/other-features/rust-query-planner) +- [Repository](https://github.com/graphql-hive/router) +- [Website](https://github.com/graphql-hive/router)
Results @@ -183,6 +185,8 @@ You can look at the full list of tests [here](./src/test-suites/). Every test id
🟢🟢
provides-on-union
🟢🟢
+provides-only-requested-fields +
🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢
requires-circular
🟢🟢
requires-interface @@ -217,12 +221,12 @@ You can look at the full list of tests [here](./src/test-suites/). Every test id
🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢
- + -### Hive Router +### Hive Gateway -- [Repository](https://github.com/graphql-hive/router) -- [Website](https://github.com/graphql-hive/router) +- [Repository](https://github.com/graphql-hive/gateway) +- [Website](https://the-guild.dev/graphql/hive/gateway)
Results @@ -282,6 +286,8 @@ You can look at the full list of tests [here](./src/test-suites/). Every test id
🟢🟢
provides-on-union
🟢🟢
+provides-only-requested-fields +
🟢🟢❌🟢🟢🟢🟢🟢🟢🟢🟢
requires-circular
🟢🟢
requires-interface @@ -381,6 +387,8 @@ You can look at the full list of tests [here](./src/test-suites/). Every test id
🟢🟢
provides-on-union
🟢🟢
+provides-only-requested-fields +
🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢
requires-circular
🟢🟢
requires-interface @@ -480,6 +488,8 @@ You can look at the full list of tests [here](./src/test-suites/). Every test id
🟢🟢
provides-on-union
🟢🟢
+provides-only-requested-fields +
🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢
requires-circular
🟢🟢
requires-interface @@ -579,6 +589,8 @@ You can look at the full list of tests [here](./src/test-suites/). Every test id
❌❌
provides-on-union
❌❌
+provides-only-requested-fields +
🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢
requires-circular
🟢🟢
requires-interface @@ -678,6 +690,8 @@ You can look at the full list of tests [here](./src/test-suites/). Every test id
🟢🟢
provides-on-union
🟢🟢
+provides-only-requested-fields +
🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢🟢
requires-circular
🟢🟢
requires-interface @@ -777,6 +791,8 @@ You can look at the full list of tests [here](./src/test-suites/). Every test id
❌❌
provides-on-union
❌❌
+provides-only-requested-fields +
🟢🟢🟢🟢❌🟢🟢🟢🟢🟢🟢
requires-circular
❌❌
requires-interface diff --git a/src/index.ts b/src/index.ts index c5e372c9..be13b4f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,7 @@ async function getTestCases(router: ReturnType) { import("./test-suites/nested-provides/index.js"), import("./test-suites/provides-on-interface/index.js"), import("./test-suites/provides-on-union/index.js"), + import("./test-suites/provides-only-requested-fields/index.js"), import("./test-suites/requires-requires/index.js"), import("./test-suites/include-skip/index.js"), import("./test-suites/circular-reference-interface/index.js"), diff --git a/src/test-suites/provides-only-requested-fields/a.subgraph.ts b/src/test-suites/provides-only-requested-fields/a.subgraph.ts new file mode 100644 index 00000000..32e49416 --- /dev/null +++ b/src/test-suites/provides-only-requested-fields/a.subgraph.ts @@ -0,0 +1,67 @@ +import { shouldPunishForPoorPlans } from "../../env.js"; +import { createSubgraph } from "../../subgraph.js"; +import { entities } from "./data.js"; + +// "a" owns the Entity type. It is the only subgraph that can resolve `extra`, +// but `name` and `description` are also defined here so they have an owner. +// +// In a correct query plan the gateway only calls "a" when the client asks for +// `extra` (the field that "b" cannot @provide). It must NEVER ask "a" for +// `name` or `description` since "b" already declares it can resolve them via +// `@provides`. The throwing field resolvers below catch that mistake. +export default createSubgraph("a", { + typeDefs: /* GraphQL */ ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable"] + ) + + type Query { + _aPlaceholder: Boolean + } + + type Entity @key(fields: "id") { + id: ID! + name: String! @shareable + description: String! @shareable + extra: String! + } + `, + resolvers: { + Entity: { + __resolveReference(key: { id: string }) { + const entity = entities.find((e) => e.id === key.id); + + if (!entity) { + return null; + } + + return { + id: entity.id, + name: entity.name, + description: entity.description, + extra: entity.extra, + }; + }, + name(parent: { name: string }) { + if (shouldPunishForPoorPlans()) { + throw new Error( + "You should be using the 'b' subgraph for `name` (it is provided via @provides on Query.entity / Query.entities).", + ); + } + + return parent.name; + }, + description(parent: { description: string }) { + if (shouldPunishForPoorPlans()) { + throw new Error( + "You should be using the 'b' subgraph for `description` (it is provided via @provides on Query.entity / Query.entities).", + ); + } + + return parent.description; + }, + }, + }, +}); diff --git a/src/test-suites/provides-only-requested-fields/b.subgraph.ts b/src/test-suites/provides-only-requested-fields/b.subgraph.ts new file mode 100644 index 00000000..fdb4d3f3 --- /dev/null +++ b/src/test-suites/provides-only-requested-fields/b.subgraph.ts @@ -0,0 +1,89 @@ +import { shouldPunishForPoorPlans } from "../../env.js"; +import { createSubgraph } from "../../subgraph.js"; +import { entities } from "./data.js"; + +// "b" is the @provides side. It exposes `Query.entity` / `Query.entities` +// with `@provides(fields: "name description")`, but BOTH `name` and +// `description` are `@external` here because "a" owns them. +// +// `@provides` is purely a HINT that this subgraph can short-circuit the +// resolution of those external fields when (and only when) the client +// actually asks for them. It does NOT mean the gateway should always pull +// every listed field from this subgraph regardless of the client selection. +// +// The custom `description` field resolver below throws in punish mode so we +// can prove (black-box) that the gateway is NOT over-forwarding fields the +// client never requested. None of the client queries in `test.ts` ask for +// `description`, so a well-behaved gateway will never trigger this resolver. +// A buggy gateway that injects every `@provides` field unconditionally will +// trigger it, the field bubbles up to a non-null parent, and the test fails +// with `entity: null`. +export default createSubgraph("b", { + typeDefs: /* GraphQL */ ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@external", "@provides"] + ) + + type Query { + entity: Entity @provides(fields: "name description") + entities: [Entity!]! @provides(fields: "name description") + } + + type Entity @key(fields: "id") { + id: ID! + name: String! @external + description: String! @external + } + `, + resolvers: { + Query: { + entity() { + const entity = entities[0]; + return { + id: entity.id, + name: entity.name, + description: entity.description, + }; + }, + entities() { + return entities.map((entity) => ({ + id: entity.id, + name: entity.name, + description: entity.description, + })); + }, + }, + Entity: { + __resolveReference(key: { id: string }) { + if (shouldPunishForPoorPlans()) { + throw new Error( + "You should not be entering 'b' through `__resolveReference` for this suite. The provided fields must be served by the @provides path on Query.entity / Query.entities.", + ); + } + + const entity = entities.find((e) => e.id === key.id); + + if (!entity) { + return null; + } + + return { + id: entity.id, + name: entity.name, + description: entity.description, + }; + }, + description(parent: { description: string }) { + if (shouldPunishForPoorPlans()) { + throw new Error( + "Over-fetch detected: the gateway asked 'b' for `description` even though no test query selects it. `@provides` is a hint, not a forced injection.", + ); + } + + return parent.description; + }, + }, + }, +}); diff --git a/src/test-suites/provides-only-requested-fields/data.ts b/src/test-suites/provides-only-requested-fields/data.ts new file mode 100644 index 00000000..9bdcce77 --- /dev/null +++ b/src/test-suites/provides-only-requested-fields/data.ts @@ -0,0 +1,21 @@ +// The bug this suite covers (gateway over-forwarding @provides fields the +// client never asked for, plus re-delegating to the owner subgraph for +// @provides-covered fields) can only be observed through subgraph-level +// errors when the gateway asks for fields it shouldn't. Those throws are +// gated behind `PUNISH_FOR_POOR_PLANS=1` (see `src/env.ts`) so the default +// audit run remains a pure data-correctness check, while gateway authors +// can flip the env flag to lock down the spec-correct behavior. +export const entities = [ + { + id: "e1", + name: "Entity One", + description: "Description One", + extra: "Extra One", + }, + { + id: "e2", + name: "Entity Two", + description: "Description Two", + extra: "Extra Two", + }, +]; diff --git a/src/test-suites/provides-only-requested-fields/index.ts b/src/test-suites/provides-only-requested-fields/index.ts new file mode 100644 index 00000000..17024bdb --- /dev/null +++ b/src/test-suites/provides-only-requested-fields/index.ts @@ -0,0 +1,6 @@ +import { serve } from "../../supergraph.js"; +import a from "./a.subgraph.js"; +import b from "./b.subgraph.js"; +import test from "./test.js"; + +export default serve("provides-only-requested-fields", [a, b], test); diff --git a/src/test-suites/provides-only-requested-fields/test.ts b/src/test-suites/provides-only-requested-fields/test.ts new file mode 100644 index 00000000..9291c6c0 --- /dev/null +++ b/src/test-suites/provides-only-requested-fields/test.ts @@ -0,0 +1,274 @@ +import { createTest } from "../../testkit.js"; + +// Every query in this file is engineered around a single invariant: +// +// When a subgraph declares `@provides(fields: "X Y")`, the gateway MUST +// forward only the subset of {X, Y, ...} the CLIENT actually selected, +// never the full @provides set. +// +// `b` declares `@provides(fields: "name description")`. None of the client +// queries below select `description`, so a correct gateway will never ask +// `b` for it. A buggy gateway that injects every @provides field will +// trigger `b.Entity.description`, which throws in punish mode, bubbles up +// the non-null `description` to its parent, and turns the response into +// `{ entity: null }` plus an error. +export default [ + // Baseline: only the @key field is selected. The gateway should still + // route through `b` (no fields are required from `a` here), and it must + // not over-forward `name` or `description`. + createTest( + /* GraphQL */ ` + query { + entity { + id + } + } + `, + { + data: { + entity: { + id: "e1", + }, + }, + }, + ), + // The exact scenario reported by the user: client asks `id name`, gateway + // must NOT also ask the provider for `description`. + createTest( + /* GraphQL */ ` + query { + entity { + id + name + } + } + `, + { + data: { + entity: { + id: "e1", + name: "Entity One", + }, + }, + }, + ), + // Same shape but with an alias - the per-path injection logic must + // preserve the alias when restricting the provided selection. + createTest( + /* GraphQL */ ` + query { + entity { + id + displayName: name + } + } + `, + { + data: { + entity: { + id: "e1", + displayName: "Entity One", + }, + }, + }, + ), + // Inline fragment over the same concrete type. The injection path stack + // walks through the fragment and must not lose the original selection. + createTest( + /* GraphQL */ ` + query { + entity { + id + ... on Entity { + name + } + } + } + `, + { + data: { + entity: { + id: "e1", + name: "Entity One", + }, + }, + }, + ), + // Named fragment - same constraint as the inline-fragment case. + createTest( + /* GraphQL */ ` + query { + entity { + id + ...EntityName + } + } + + fragment EntityName on Entity { + name + } + `, + { + data: { + entity: { + id: "e1", + name: "Entity One", + }, + }, + }, + ), + // Mixed plan: `name` comes from `b` via @provides, `extra` forces a + // fallback hop to the owner `a`. The gateway must NOT over-forward + // `description` to `b`, and must NOT ask `a` for `name` (since `b` + // already provided it). + createTest( + /* GraphQL */ ` + query { + entity { + id + name + extra + } + } + `, + { + data: { + entity: { + id: "e1", + name: "Entity One", + extra: "Extra One", + }, + }, + }, + ), + // List variant - the same invariant applies once per element. A buggy + // gateway that injects @provides fields per visit would trigger the + // throw on every entity, not just the first. + createTest( + /* GraphQL */ ` + query { + entities { + id + name + } + } + `, + { + data: { + entities: [ + { + id: "e1", + name: "Entity One", + }, + { + id: "e2", + name: "Entity Two", + }, + ], + }, + }, + ), + // List variant with the owner-fallback hop - confirms the per-element + // join still works without over-forwarding. + createTest( + /* GraphQL */ ` + query { + entities { + id + name + extra + } + } + `, + { + data: { + entities: [ + { + id: "e1", + name: "Entity One", + extra: "Extra One", + }, + { + id: "e2", + name: "Entity Two", + extra: "Extra Two", + }, + ], + }, + }, + ), + // `@skip(if: true)` on the wrapping inline fragment must be preserved end + // to end. If the gateway drops the directive while rewriting the query for + // the providing subgraph, it would unconditionally fetch `description` + // there - which trips the throwing resolver and bubbles `entity` to null. + createTest( + /* GraphQL */ ` + query { + entity { + id + ... on Entity @skip(if: true) { + description + } + } + } + `, + { + data: { + entity: { + id: "e1", + }, + }, + }, + ), + // Same invariant via `@include(if: false)` - the equivalent way of + // opting out of a `@provides` field. The directive must still reach the + // providing subgraph intact. + createTest( + /* GraphQL */ ` + query { + entity { + id + ... on Entity @include(if: false) { + description + } + } + } + `, + { + data: { + entity: { + id: "e1", + }, + }, + }, + ), + // Multiple sibling *untyped* inline fragments under the same field. They + // all share the same path in the original document, so the gateway must + // walk every one when figuring out which @provides fields the client + // actually selected. A shallow walk that only considers the first sibling + // would either drop the other selections or over-forward to recover them. + createTest( + /* GraphQL */ ` + query { + entity { + id + ... { + name + } + ... { + extra + } + } + } + `, + { + data: { + entity: { + id: "e1", + name: "Entity One", + extra: "Extra One", + }, + }, + }, + ), +]; diff --git a/website/data.json b/website/data.json index f4b425fc..bb67b32f 100644 --- a/website/data.json +++ b/website/data.json @@ -1,106 +1,106 @@ [ { - "name": "Hive Gateway", + "name": "Hive Gateway (Rust QP)", "cases": { - "total": 192, - "passed": 192, + "total": 203, + "passed": 203, "failed": 0 }, "suites": { - "total": 44, - "passed": 44, + "total": 45, + "passed": 45, "failed": 0 } }, { - "name": "Hive Gateway (Rust QP)", + "name": "Hive Router", "cases": { - "total": 192, - "passed": 192, + "total": 203, + "passed": 203, "failed": 0 }, "suites": { - "total": 44, - "passed": 44, + "total": 45, + "passed": 45, "failed": 0 } }, { - "name": "Hive Router", + "name": "Hive Gateway", "cases": { - "total": 192, - "passed": 192, - "failed": 0 + "total": 203, + "passed": 202, + "failed": 1 }, "suites": { - "total": 44, + "total": 45, "passed": 44, - "failed": 0 + "failed": 1 } }, { "name": "Apollo Router", "cases": { - "total": 192, - "passed": 187, + "total": 203, + "passed": 198, "failed": 5 }, "suites": { - "total": 44, - "passed": 41, + "total": 45, + "passed": 42, "failed": 3 } }, { "name": "Apollo Gateway", "cases": { - "total": 192, - "passed": 186, + "total": 203, + "passed": 197, "failed": 6 }, "suites": { - "total": 44, - "passed": 40, + "total": 45, + "passed": 41, "failed": 4 } }, { "name": "Cosmo Router", "cases": { - "total": 192, - "passed": 181, + "total": 203, + "passed": 192, "failed": 11 }, "suites": { - "total": 44, - "passed": 37, + "total": 45, + "passed": 38, "failed": 7 } }, { "name": "Grafbase Gateway", "cases": { - "total": 192, - "passed": 176, + "total": 203, + "passed": 187, "failed": 16 }, "suites": { - "total": 44, - "passed": 38, + "total": 45, + "passed": 39, "failed": 6 } }, { "name": "Inigo Gateway", "cases": { - "total": 192, - "passed": 92, - "failed": 100 + "total": 203, + "passed": 102, + "failed": 101 }, "suites": { - "total": 44, + "total": 45, "passed": 12, - "failed": 32 + "failed": 33 } } ] \ No newline at end of file diff --git a/website/index.html b/website/index.html index d699fdca..2b0055bc 100644 --- a/website/index.html +++ b/website/index.html @@ -242,23 +242,23 @@

class="p-4 align-middle font-medium border-l-2 border-emerald-500" > - Hive Gateway + Hive Gateway (Rust QP) 100.00% - ✓ 192 + ✓ 203 - ✓ 44 + ✓ 45 View report @@ -270,23 +270,23 @@

class="p-4 align-middle font-medium border-l-2 border-emerald-500" > - Hive Gateway (Rust QP) + Hive Router 100.00% - ✓ 192 + ✓ 203 - ✓ 44 + ✓ 45 View report @@ -298,23 +298,25 @@

class="p-4 align-middle font-medium border-l-2 border-emerald-500" > - Hive Router + Hive Gateway - 100.00% + 99.51% - ✓ 192 + ✓ 202 + ✗ 1 ✓ 44 + ✗ 1 View report @@ -333,13 +335,13 @@

Apollo Router - 97.40% + 97.54% - ✓ 187 + ✓ 198 ✗ 5 - ✓ 41 + ✓ 42 ✗ 3 @@ -363,13 +365,13 @@

Apollo Gateway - 96.88% + 97.04% - ✓ 186 + ✓ 197 ✗ 6 - ✓ 40 + ✓ 41 ✗ 4 @@ -393,13 +395,13 @@

Cosmo Router - 94.27% + 94.58% - ✓ 181 + ✓ 192 ✗ 11 - ✓ 37 + ✓ 38 ✗ 7 @@ -423,13 +425,13 @@

Grafbase Gateway - 91.67% + 92.12% - ✓ 176 + ✓ 187 ✗ 16 - ✓ 38 + ✓ 39 ✗ 6 @@ -453,14 +455,14 @@

Inigo Gateway - 47.92% + 50.25% - ✓ 92 - ✗ 100 + ✓ 102 + ✗ 101 ✓ 12 - ✗ 32 + ✗ 33