From 603d673d49bd2fc5ee39379da8998df9b24ba8d0 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 30 Jun 2026 15:44:59 -0600 Subject: [PATCH 1/2] Update BaseDataService to accommodate mutations, not just queries 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. --- packages/base-data-service/CHANGELOG.md | 4 +++ .../src/BaseDataService.test.ts | 28 +++++++++++++++ .../base-data-service/src/BaseDataService.ts | 32 +++++++++++++++++ .../tests/ExampleDataService.ts | 34 +++++++++++++++++++ packages/base-data-service/tests/mocks.ts | 20 +++++++++++ 5 files changed, 118 insertions(+) diff --git a/packages/base-data-service/CHANGELOG.md b/packages/base-data-service/CHANGELOG.md index 1ed92c0f87..e227f0914d 100644 --- a/packages/base-data-service/CHANGELOG.md +++ b/packages/base-data-service/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `executeMutation` to `BaseDataService` to allow for making state-mutating requests ([#9324](https://github.com/MetaMask/core/pull/9324)) + ### Changed - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 519e4460e8..4768c937c7 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -5,6 +5,7 @@ import { cleanAll } from 'nock'; import { ExampleDataService, serviceName } from '../tests/ExampleDataService'; import { + mockAddFollowerRequest, mockAssets, mockTransactionsPage1, mockTransactionsPage2, @@ -23,6 +24,7 @@ const MOCK_ASSETS = [ describe('BaseDataService', () => { beforeEach(() => { + mockAddFollowerRequest(); mockAssets(); mockTransactionsPage1(); mockTransactionsPage2(); @@ -161,6 +163,22 @@ describe('BaseDataService', () => { ); }); + it('handles mutations', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + expect(await service.addFollower('1')).toStrictEqual({ + followers: [ + { + profileId: '550e8400-e29b-41d4-a716-446655440000', + address: '0x1234567890abcdef1234567890abcdef12345678', + name: 'TraderAlice', + imageUrl: 'https://example.com/avatar.png', + }, + ], + }); + }); + it('emits `:cacheUpdated` events when cache entry is removed', async () => { const messenger = new Messenger({ namespace: serviceName }); const service = new ExampleDataService(messenger); @@ -186,6 +204,16 @@ describe('BaseDataService', () => { ); }); + it('does not emit `:cacheUpdated` when a mutation is executed', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + const publishSpy = jest.spyOn(messenger, 'publish'); + + await service.addFollower('1'); + + expect(publishSpy).not.toHaveBeenCalled(); + }); + it('does not emit events after being destroyed', async () => { const messenger = new Messenger({ namespace: serviceName }); const service = new ExampleDataService(messenger); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index fdd2896e2b..d6abf659e5 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -18,6 +18,7 @@ import { InfiniteData, InvalidateOptions, InvalidateQueryFilters, + MutationOptions, OmitKeyof, QueryClient, QueryClientConfig, @@ -250,6 +251,37 @@ export class BaseDataService< return result.pages[pageIndex]; } + /** + * Execute a mutation (a request that is expected to change the state of a server). + * Unlike `fetchQuery`, the request will not be cached. + * + * @param options - The options defining the mutation. Keep in mind that `mutationKey` and `mutationFn` are required when using data services. + * Additionally `retry` and `retryDelay` are not available, retries can be customized using the `servicePolicyOptions`. + * @returns The mutation results. + */ + protected async executeMutation< + TData extends Json, + TError = unknown, + TVariables = void, + TContext = unknown, + >( + options: WithRequired< + OmitKeyof< + MutationOptions, + 'retry' | 'retryDelay' + >, + 'mutationKey' | 'mutationFn' + >, + ): Promise { + const mutationCache = this.#queryClient.getMutationCache(); + const mutation = mutationCache.build(this.#queryClient, { + ...options, + mutationFn: (context) => + this.#policy.execute(() => options.mutationFn(context)), + }); + return await mutation.execute(); + } + /** * Invalidate queries serviced by this data service. * diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts index 8398f65a52..b9553c1e6d 100644 --- a/packages/base-data-service/tests/ExampleDataService.ts +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -44,6 +44,15 @@ export type GetActivityResponse = { }; }; +export type AddFollowerResponse = { + followed: { + profileId: string; + address: string; + name: string; + imageUrl?: string | null; + }[]; +}; + export type PageParam = | { before: string; @@ -60,6 +69,8 @@ export class ExampleDataService extends BaseDataService< readonly #tokensBaseUrl = 'https://tokens.api.cx.metamask.io'; + readonly #socialBaseUrl = 'https://social.api.cx.metamask.io'; + constructor(messenger: ExampleMessenger) { super({ name: serviceName, @@ -139,6 +150,29 @@ export class ExampleDataService extends BaseDataService< ); } + async addFollower(followerId: string): Promise { + return this.executeMutation({ + mutationKey: [`${this.name}:addFollower`, followerId], + mutationFn: async () => { + const url = new URL(`${this.#socialBaseUrl}/api/v1/users/me/follows`); + + const response = await fetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ followerId }), + }); + + if (!response.ok) { + throw new Error( + `Mutation failed with status code: ${response.status}.`, + ); + } + + return response.json(); + }, + }); + } + destroy(): void { super.destroy(); } diff --git a/packages/base-data-service/tests/mocks.ts b/packages/base-data-service/tests/mocks.ts index 82341e0672..22c46153b8 100644 --- a/packages/base-data-service/tests/mocks.ts +++ b/packages/base-data-service/tests/mocks.ts @@ -5,6 +5,26 @@ type MockReply = { body?: nock.Body; }; +export function mockAddFollowerRequest(mockReply?: MockReply): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: { + followers: [ + { + profileId: '550e8400-e29b-41d4-a716-446655440000', + address: '0x1234567890abcdef1234567890abcdef12345678', + name: 'TraderAlice', + imageUrl: 'https://example.com/avatar.png', + }, + ], + }, + }; + + return nock('https://social.api.cx.metamask.io:443') + .put('/api/v1/users/me/follows', { followerId: '1' }) + .reply(reply.status, reply.body); +} + export function mockAssets(mockReply?: MockReply): nock.Scope { const reply = mockReply ?? { status: 200, From e88e14a4101476eb7bb393980e99f74d54baa630 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 1 Jul 2026 07:27:56 -0600 Subject: [PATCH 2/2] Make test more accurate --- packages/base-data-service/src/BaseDataService.test.ts | 2 +- packages/base-data-service/tests/mocks.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 4768c937c7..f0611e2eae 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -168,7 +168,7 @@ describe('BaseDataService', () => { const service = new ExampleDataService(messenger); expect(await service.addFollower('1')).toStrictEqual({ - followers: [ + followed: [ { profileId: '550e8400-e29b-41d4-a716-446655440000', address: '0x1234567890abcdef1234567890abcdef12345678', diff --git a/packages/base-data-service/tests/mocks.ts b/packages/base-data-service/tests/mocks.ts index 22c46153b8..7ea329eb81 100644 --- a/packages/base-data-service/tests/mocks.ts +++ b/packages/base-data-service/tests/mocks.ts @@ -9,7 +9,7 @@ export function mockAddFollowerRequest(mockReply?: MockReply): nock.Scope { const reply = mockReply ?? { status: 200, body: { - followers: [ + followed: [ { profileId: '550e8400-e29b-41d4-a716-446655440000', address: '0x1234567890abcdef1234567890abcdef12345678',