This answer is not neccesarily introducing more ways of doing things but I will elaborate some best practices that I have found for my schemas and how they relate to projects in the community.
Option 2 is not viable with static queries
As I have already discussed in the comment think about what your app really needs. If you want to lookup a dynamic number of fields by ID Option 2 is not going to be helpful when you want to use static queries. You would have to be concatinating strings to create dynamic queries roughly like so:
const query = `{ ${ids.map(id => `product${id}: product { ...Frag }\n`).join('')} }`;
This is a bad idea and the linked article talks about why and also your GraphQL endpoint is probably not made for it. We could as well send multiple queries one by one for each product but then we lose one of GraphQLs key advantages over REST.
But: We usually keep the field for querying single entities because viewing a single item is usually a use case of our app and we can very quickly respond to that query. See the last section of my answer on queries.
The other two options are the same basically
Option 1 and Option 3 should just be chosen consistently across your project. The connection might not really be helpful when querying for IDs but it can prevent returning way to many items and seamlessly integrate with Relay.
You also need to consider how you then connect the IDs on the frontend with the values again. Do you return the items in the same order as queried (as know for example from Dataloader) or do you query the IDs of the entities and the order does not matter.
So if all your queries that return multiple items return connections then I would do it here as well. The danger is that queries will get to complex and this leads me to the last topic.
Generic filters considered harmful
In general it is recommended to only expose queries that are really required by your frontend(s) as this Twitter thread elaborates and Lee Byron (one of the creators) adds that you should also only expose queries that you can fulfill efficiently. In contrast Apollo recommends to not couple clients and server implementations and instead but abstract schemas. I think you will have to find a balance.
I think that exposing a filter object that is very specific is almost as good as creating a new field each time but it might not scale that well. You will quickly need to build complex queries and a query builder library might be helpful. On the other hand if you already have that in place - like in your example products might already be heavily filterable - it can be very easy to add.
Some project in the wild offer extensive filters like Hasura, Prisma and Gatsby. So if you want to build more generic filters you could take inspiration from these projects.
products(filter: { id: { in: [1, 2, 3] } }). - Herkuin? Do you name the parameter something likeProductFilterInput? Appreciate the help! - Tim S