Skip to content

chore: replace fetchPersistedQuery with Yoga plugin - #7622

Closed
MounirDhahri wants to merge 1 commit into
mainfrom
chore/yoga-persisted-operations
Closed

MounirDhahri wants to merge 1 commit into
mainfrom
chore/yoga-persisted-operations

Conversation

@MounirDhahri

@MounirDhahri MounirDhahri commented Apr 23, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Replace the custom fetchPersistedQuery middleware with @graphql-yoga/plugin-persisted-operations. Persisted queries still come from src/data/complete.queryMap.json; arbitrary (non-persisted) queries are still allowed, same as today.

⚠️ Behavior change

An unknown documentID used to return HTTP 404 with a plain-text body. Now it returns HTTP 200 with a GraphQL errors array. Clients that check HTTP status for a persisted-query miss would need to check the response body instead.

Test plan

  • yarn type-check
  • yarn test
  • Sanity-check: valid documentID runs the query, unknown documentID returns a GraphQL error, requests with no documentID still work

🤖 Generated with Claude Code

Comment thread src/index.ts
Base automatically changed from feat/migrate-to-graphql-yoga to main May 5, 2026 09:46
@graphql-yoga/plugin-persisted-operations plays inside Yoga's pipeline
instead of running as a pre-parser express middleware, which plays
nicely with the parser/validation cache and keeps the pipeline in one
place.

Behavior change: a missing persisted operation previously returned an
HTTP 404 from the express middleware. The plugin instead returns a
GraphQL error (HTTP 200 with `errors`). Clients that branch on HTTP
status for persisted-query misses would need to check the body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@MounirDhahri
MounirDhahri force-pushed the chore/yoga-persisted-operations branch from d28bffc to 9cd1a22 Compare July 7, 2026 13:49
@github-actions github-actions Bot deleted a comment from claude Bot Jul 7, 2026
@MounirDhahri
MounirDhahri marked this pull request as draft July 7, 2026 13:52
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Code Review

Summary

This PR replaces the hand-rolled fetchPersistedQuery Express middleware with the official @graphql-yoga/plugin-persisted-operations plugin (usePersistedOperations). Persisted queries are now resolved inside the Yoga pipeline via the onParams hook (keyed on documentID against complete.queryMap.json) rather than by mutating req.body.query before Yoga runs. allowArbitraryOperations: true preserves the permissive behavior of accepting ad-hoc queries.

Moving to the maintained plugin is a good direction and removes bespoke code. There is one behavioral regression worth addressing before merge.

Issues Found

🔴 Blocking / 🟡 Important — isMutation detection breaks for persisted queries

src/index.ts:312 computes the mutation flag from the raw Express body:

```ts
isMutation: !!req.body?.query?.includes("mutation"),
```

Previously, fetchPersistedQuery ran as Express middleware before Yoga and set req.body.query = query for any request carrying a documentID. So by the time the Yoga context factory ran, req.body.query contained the resolved query text and isMutation was correctly true for persisted mutations.

The new plugin resolves the operation inside Yoga's onParams hook — it updates Yoga's internal params, but it does not write back to Express req.body.query. For a persisted request the client sends only { documentID: "..." } with no query field, so req.body?.query is now undefined and isMutation evaluates to false for every persisted query, including mutations.

This matters because isMutation is threaded into the loaders and forces a cache bypass:

```ts
// src/lib/loaders/api/loader_without_authentication_factory.ts:117
const skipCache =
CACHE_DISABLED ||
(apiOptions.method && ["PUT","POST","DELETE"].includes(apiOptions.method)) ||
apiOptions.isMutation
```

The backend write itself still skips cache via the method check, but any GET loader invoked while resolving a persisted mutation will now read from / write to Memcached instead of bypassing it — which is exactly the stale-read-during-mutation case isMutation exists to prevent. This is not hypothetical: complete.queryMap.json contains many persisted mutations (SendConversationMessageMutation, ConfirmBidMutation, UpdateConversationMutation, …).

The context factory already receives the resolved params (and stores it as _params), and params.query is populated by the plugin's onParams hook before context is built. So the fix is to prefer the resolved params query, e.g.:

```ts
isMutation: !!(params?.query ?? req.body?.query)?.includes("mutation"),
```

Please verify with a persisted-mutation request that isMutation is true end-to-end.

Areas Reviewed

  • Behavioral change — unknown documentID response. The old middleware responded with a plaintext HTTP 404 (Unable to serve persisted query with ID …) and logged via error(...). usePersistedOperations instead rejects with a PersistedQueryNotFound GraphQL error (JSON envelope). If any client (Eigen, Force, etc.) branches on the plaintext 404 body/shape, this is a contract change. Likely acceptable, but worth confirming — see question below.

  • /batch route now resolves persisted queries (probably a fix). The old fetchPersistedQuery was only mounted on the /v2 chain, not on /batch. Because the plugin now lives inside Yoga, batched sub-requests carrying a documentID get resolved too. This looks like a genuine improvement, but flagging it as an intentional-vs-incidental behavior change.

  • Error logging fidelity. errorFormatPlugin (src/index.ts:253-260) falls back to req.body.query when building the error handler. For persisted-query errors this is now undefined, so Sentry/error context loses the query text it previously had (the middleware used to populate req.body.query). Minor, but the same params.query fallback would restore it.

  • Security / Architecture: No new concerns. allowArbitraryOperations: true intentionally preserves existing permissiveness; the plugin runs first in the plugin array so resolution happens before validation, matching the prior ordering.

Questions for Author

  1. Is the change in the unknown-documentID response (plaintext 404 → GraphQL PersistedQueryNotFound error) intended and confirmed safe for all clients?
  2. Was the new /batch persisted-query resolution intentional, and is it covered by any manual/integration testing?
  3. Is there test coverage exercising a persisted mutation through the new plugin (to catch the isMutation regression above)?

Comment thread src/index.ts
Comment on lines +174 to +186
const persistedOperationsPlugin = usePersistedOperations({
allowArbitraryOperations: true,
extractPersistedOperationId: (params) =>
(params as { documentID?: string }).documentID ?? null,
getPersistedOperation: (key) => {
const query = (persistedQueryMap as Record<string, string>)[key]
if (query) {
info(`Serving persisted query with ID ${key}`)
return query
}
return null
},
})

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.

This plugin resolves the persisted query into Yoga's internal params.query, but (unlike the old fetchPersistedQuery middleware) it does not write back to Express req.body.query.

The context factory below still derives the mutation flag from the raw body:

isMutation: !!req.body?.query?.includes("mutation"),  // src/index.ts:312

For a persisted request the client sends only { documentID } with no query, so req.body?.query is now undefined → isMutation is false for every persisted query, including the many persisted mutations in complete.queryMap.json. That flips skipCache off for GET loaders invoked during a mutation (see loader_without_authentication_factory.ts:117), allowing stale cache reads mid-mutation.

Suggest deriving it from the resolved params (already available as the params arg / _params):

isMutation: !!(params?.query ?? req.body?.query)?.includes("mutation"),

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant