Update BaseDataService to accommodate mutations, not just queries - #9324
Update BaseDataService to accommodate mutations, not just queries#9324mcmire wants to merge 36 commits into
Conversation
7e277f0 to
603d673
Compare
| 'mutationKey' | 'mutationFn' | ||
| >, | ||
| ): Promise<TData> { | ||
| const mutationCache = this.#queryClient.getMutationCache(); |
There was a problem hiding this comment.
QueryClient doesn't have a executeMutation method itself. I looked inside of the class, however, and saw that there was a getMutationCache method and followed where that led. This is not how useMutation works, which uses MutationObserver, but I don't know if that really matters. I figured it made more sense to mimic how fetchQuery works. But I'm not very familiar with TanStack Query, so if this is not the way we should be doing things, I'm happy to change this.
There was a problem hiding this comment.
I looked into this further and while this is not how useMutation works, it is how MutationObserver works. You can see that here:
So, we aren't really diverging from TanStack Query as much as I implied before.
| const mutation = mutationCache.build(this.#queryClient, { | ||
| ...options, | ||
| mutationFn: (context) => | ||
| this.#policy.execute(() => options.mutationFn(context)), |
There was a problem hiding this comment.
Hmm, are we safe to apply the policy as-is to mutations?
Just wondering if there could be a problem with accidentally doing double the mutation with retries 🤔
There was a problem hiding this comment.
Hmm, good point. We're still making an API request, so I feel like we would still want to have some kind of retry logic. But maybe it needs to be a little different than the logic used for GET requests.
I wonder if the default retry policy for createServicePolicy is too liberal. Right now, if the function you pass to the policy throws any error, then the function will get run again.
In RpcService we only retry connection errors, JSON parse errors, HTTP server errors (5xx), timeout errors, and "connection reset" errors. I wonder if BaseDataService should configure createServicePolicy such that it does the same thing?
Then I would be less worried here, because at that point, either we never make the request, or we do make the request but the server returns a 5xx. And in that case I feel like we ought to assume that the server is well-behaved, i.e. won't attempt to write to a database if it runs into an error. (If the server is not well-behaved, it shouldn't be our fault — the engineer writing the data service should know that and account for it.)
What do you think about that idea?
There was a problem hiding this comment.
Improving the defaults to be more "targeted" to BaseDataService makes sense to me. Though I still would be a bit worried that developers configure the service policy mainly for GET requests and don't realize how it may impact a PUT 🤔
There was a problem hiding this comment.
I wouldn't expect that most developers would configure the service policy, I would expect them to go with the defaults. But that's a good point. Should we have two kinds of service policies, one for queries and another for mutations? Then if a developer does configure a service policy, it should be more obvious what it's used for, and maybe it will cause them to think about it more critically. Or maybe this is solvable via documentation: we can add an "advanced" section to the tutorial/guide on data services that talks about the service policy, and we can remind the reader to consider non-GET requests when configuring it.
There was a problem hiding this comment.
Unsure if a separate service policy or modifications to the default service policy would be preferred. We could consider a default isServiceFailure function that changes depending on the type of request for example? But maybe a separate policy for maximum flexibility is preferred 🤔
There was a problem hiding this comment.
Thinking about this some more, I've see realized that it makes more sense to have one policy instead of two. All requests to an API should share one circuit and one counter to keep track of whether the circuit should break. Queries and mutations shouldn't be treated specially. I'll see if I figure out that path tomorrow.
There was a problem hiding this comment.
Perhaps it is enough to find a way to disable retrying for mutations?
There was a problem hiding this comment.
I guess that approach could work to get this PR merged, and then we could further refine it in another PR if we need to.
There was a problem hiding this comment.
I've disabled retrying for mutations by only using the circuit breaker policy to wrap the mutationFn instead of using the circuit breaker and retry policies.
| * Additionally `retry` and `retryDelay` are not available, retries can be customized using the `servicePolicyOptions`. | ||
| * @returns The mutation results. | ||
| */ | ||
| protected async executeMutation< |
There was a problem hiding this comment.
The mutation cache is separate from the query cache, should we sync it with the UI as well?
There was a problem hiding this comment.
Ooh good point. Okay I'll make this change.
There was a problem hiding this comment.
I've made changes to createUIQueryClient to accommodate mutations.
|
Moving this PR back to draft. There's still more work to do here in order to properly support mutations. |
## Explanation <!-- Thanks for your contribution! Take a moment to answer these questions so that reviewers have the information they need to properly understand your changes: * What is the current state of things and why does it need to change? * What is the solution your changes offer and how does it work? * Are there any changes whose purpose might not obvious to those unfamiliar with the domain? * If your primary goal was to update one package but you found you had to update another one along the way, why did you do so? * If you had to upgrade a dependency, why did you do so? --> `createUIQueryClient` takes a messenger that is too broadly typed: it does not require that the actions and events that the messenger can access are actually scoped to the given data services. This was actually causing a type error in `createUIQueryClient.test.ts` — which replicates realistic usage of `createUIQueryClient` — but neither ESLint nor Jest caught it. This commit fixes the `MessengerAdapter` type so the type error goes away and adds a special test file we can run with `tsc` to ensure it doesn't pop up again. ## References <!-- Are there any issues that this pull request is tied to? Are there other links that reviewers should consult to understand these changes better? Are there client or consumer pull requests to adopt any breaking changes? For example: * Fixes #12345 * Related to #67890 --> This is blocking MetaMask#9324. https://consensyssoftware.atlassian.net/browse/WPC-1171 ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [x] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them - Extension PR: MetaMask/metamask-extension#44498 - Mobile PR: MetaMask/metamask-mobile#33450 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Breaking TypeScript contract for `createUIQueryClient` and exported messenger adapter shapes may fail consumer builds until adapters are retyped; runtime query/invalidation behavior is largely unchanged. > > **Overview** > **`createUIQueryClient`** now takes a **readonly** data-service name tuple and a **`MessengerAdapter`** scoped to those names: `call` only accepts `` `${Service}:${string}` `` actions (with `unknown[]` params instead of `Json[]`), and `subscribe` / `unsubscribe` only accept granular `` `:cacheUpdated:${hash}` `` events. Runtime checks use the same name guards; query and invalidation paths no longer cast messenger arguments through `Json`. > > **`@metamask/base-data-service`** publicly exports **`DataServiceActions`** and **`DataServiceEvents`** so consumers can type messengers against data services. > > **Testing / repo hygiene:** `@metamask/react-data-query` adds **tstyche** (`createUIQueryClient.tst.ts`), Jest coverage for adapter proxying and invalidation forwarding, and build excludes `*.tst.ts`. **`yarn.config.cjs`** centralizes **`expectTestScripts`** so workspaces with `test:types` use `test:unit` + tstyche (messenger scripts renamed to match). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit fa890ae. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
5ce00ba to
ec84edc
Compare
|
I just realized that it's probably best if I wait until we upgrade |
ec84edc to
e9cba53
Compare
|
Almost done. Working through the tests to make sure that the way we test mutations is very similar to the way we've tested queries. |
9162ced to
4c370ee
Compare
Currently, `BaseDataService` has a `fetchQuery` method which is best
used for making read-only requests ("queries" in TanStack Query
parlance), but does not work as well for requests that change state on
the server side ("mutations"). For instance, queries are cacheable, but
mutations are not.
This commit adds a separate method, `executeMutation`, which
accommodates mutations better. Its implementation is different from
`fetchQuery` as it uses the mutation cache instead of the query cache.
Also, retries are disabled.
4c370ee to
eb74071
Compare
| // NOTE: Can't use HttpError from controller-utils due to lint:tsc not | ||
| // being fully rolled out across the monorepo. |
There was a problem hiding this comment.
| // NOTE: Can't use HttpError from controller-utils due to lint:tsc not | |
| // being fully rolled out across the monorepo. |
Nit: personally I would just remove these
There was a problem hiding this comment.
Sure, makes sense. Removed here: 9539ca9
| init(): void { | ||
| this.#loadCache().catch( | ||
| this.#loadCache().catch((error) => | ||
| /* istanbul ignore next */ |
There was a problem hiding this comment.
This directive doesn't work now that it has been moved inside the arrow function
There was a problem hiding this comment.
Yeah I don't know what I was thinking here. I've fully reverted this change here: 5bae879
| }; | ||
|
|
||
| const DATA_SERVICE_MUTATION_DEFAULTS = { | ||
| retry: false, |
There was a problem hiding this comment.
How come we don't want staleTime: 0 for mutations?
There was a problem hiding this comment.
staleTime isn't an option for useMutation, it's only an option for useQuery and useInfiniteQuery. I think this is because there is no concept of "freshness" or "staleness" for mutations like there is for queries.
The UI query client deduplicated mutations by mutation key alone, which could misroute service :cacheUpdated state onto the wrong UI mutation when multiple mutations shared a key, orphan concurrent mutations, and clobber mutations that used a custom mutationFn. Introduce a UI-generated globalId that uniquely correlates one UI mutation with the service-side mutation it triggers. The UI stores the globalId on the mutation's meta, passes it as the trailing action argument, and matches incoming cache updates strictly by it. executeMutation accepts an optional globalId and echoes it back on the service-side mutation's meta so it rides along in dehydrated payloads.
hydrateMutations, deriveMutationAction, and readGlobalId lived inside createUIQueryClient and were only exercised indirectly through integration tests. Extract them into hydrateMutations.ts and add a dedicated unit test that covers every deriveMutationAction branch, including the previously-untested pending and idle (continue) cases. Remove the integration tests from createUIQueryClient.test.ts that only existed to reach the derive-action logic, keeping one that verifies the full round-trip synchronization between clients.
The globalId used to correlate UI and service mutations was minted inside the defaultMutationOptions override, which runs whenever a mutation's options are defaulted rather than once per mutation. This caused two bugs: - Calling mutate() more than once on the same observer reused the memoized globalId (since mutationFn was already set), so a :cacheUpdated payload could be applied to the wrong cache entry. - Re-defaulting an observer's options (as useMutation does on every render) minted a fresh globalId and, for a pending mutation, replaced the live mutation's meta, detaching it from its in-flight service-side events. Move globalId minting into a mutationCache.build override, which runs exactly once per Mutation instance, and read the id from context.meta in the installed mutationFn instead of a captured variable. Guard the built mutation's setOptions so later option swaps preserve its globalId.
| } | ||
| }); | ||
|
|
||
| const mutationCache = client.getMutationCache(); |
There was a problem hiding this comment.
To be honest, this is getting a little messy and I am curious whether it would make sense to subclass QueryClient rather than patch it like this. I've done my best to follow the existing conventions here but we may want to investigate a cleanup in a future PR.
| @@ -1,7 +1,14 @@ | |||
| import { QueryKey } from '@metamask/base-data-service'; | |||
| /** | |||
There was a problem hiding this comment.
This comment was already present, I just moved it up so it's more clear it's applicable to the whole file.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit fe79ca0. Configure here.
The mutation cache listener hydrated every non-'removed' :cacheUpdated
event, including 'added'. A data service emits 'added' when it first
builds a mutation, while it is still 'idle' with no result, so hydrating
it overwrote (and wiped the result of) a UI mutation that had already
settled. Sync only 'updated' events, which are the ones that carry a
meaningful mutation state.
Also restore the two-argument form of the 'globalId' assert in the
data-service mutationFn. It had regressed to assert('...message...'),
which passes the message as the (always-truthy) condition, so it never
threw and never narrowed 'globalId' to a string.
|
@FrederikBolding When you get back, I've made some changes to this PR in response to feedback from you, Jongsun, and Cursor. It's ready for review again. |

Explanation
Currently,
BaseDataServicehas afetchQuerymethod which is best used for making read-only requests ("queries" in TanStack Query parlance), but does not work as well for requests that change state on the server side ("mutations"). For instance, it makes sense for queries to be cached and retried, but not so much for mutations.This commit adds a separate method,
executeMutation, which accommodates mutations better. Besides the differences mentioned above, it also uses the mutation cache instead of the query cache.References
Closes https://consensyssoftware.atlassian.net/browse/WPC-1118.
Manual testing
I've created #10176, which converts
AuthenticatedUserStorageService.setAssetsWatchlistto useexecuteMutationunder the hood, and MetaMask/metamask-mobile#36083, which loads those changes into the mobile app. You can check out the mobile branch and run through the manual testing steps there to confirm thatexecuteMutationstill exhibits the same basic behavior asfetchQuery.Checklist
Note
Medium Risk
Introduces state-changing server requests and cross-client mutation sync; mistakes in
globalIdhandling or mutation routing could cause wrong UI state, though mutations deliberately skip query-style retries.Overview
Adds a mutation path alongside existing query support in
@metamask/base-data-serviceand wires it through@metamask/react-data-query.BaseDataServiceexposes protectedexecuteMutation(plus exportedMutationKey), runs writes through the mutation cache with no retries (circuit breaker only), optional Superstruct validation viaprocessMutationResponse, and an optionalglobalIdin mutationmetafor correlating the service client with the UI client.:cacheUpdatedpayloads now includeobjectType("query"|"mutation"), mutation cache subscriptions/dehydration, anddestroy()clears mutation GC timers.createUIQueryClientproxies mutations viamutationKey→ messenger actions (trailingglobalId), assigns per-mutation UUIDs, syncs service state withhydrateMutationsonupdatedevents only (ignoringadded/removedthat would clobber UI state), and exportsuseMutationwith retries off by default.Reviewed by Cursor Bugbot for commit ac8209b. Bugbot is set up for automated code reviews on this repo. Configure here.