Skip to content

Commit dd11598

Browse files
feat: Implement BaseDataService (#8039)
## Explanation This PR implements `BaseDataService` and a function to wrap `QueryClient` to proxy requests accordingly. The `BaseDataService`, similarly to the `BaseController` provides the framework for building a service that can be registered and accessed via the messenger system, but also provides guarantees about per-request deduping, retries, caching, invalidation, state-while-revalidate etc via `@tanstack/query-core`. The `BaseDataService` provides two utilities for this: `fetchQuery` and `fetchInfiniteQuery`, which is similar but one is separated for special pagination behaviour. Each service has its own cache for the APIs that it exposes that must also be synchronized with the UI processes. To facilitate this synchronization, the `BaseDataService` also automatically provides a `cacheUpdate` event. The overall goal of the PR is to provide a base layer that can keep as much compatibility as possible with native TanStack Query while also simultaneously allowing us to have one source of truth per data service. The synchronization is achieved via a special `QueryClient` created by `createUIQueryClient`, which wraps functionality such as cache invalidation, provides the default proxied fetch behaviour and subscribes to cache updates from data services that it is observing (e.g. has active queries for). ## References https://consensyssoftware.atlassian.net/browse/WPC-445 ## 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 - [ ] 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) - [ ] 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 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Introduces new shared data-service/query infrastructure (caching, retries/circuit breaking, invalidation, cross-process cache hydration) that can affect data freshness and event synchronization if misused, though changes are isolated to new packages with test coverage. > > **Overview** > Adds an initial implementation of `@metamask/base-data-service`, introducing `BaseDataService` built on `@tanstack/query-core` with `fetchQuery`/`fetchInfiniteQuery`, service-policy-wrapped execution (retries/circuit breaking), a registered `:invalidateQueries` action, and automatic `:cacheUpdated`/granular `:cacheUpdated:${hash}` events with dehydrated cache state. > > Adds `@metamask/react-data-query` utilities to consume these services from UI code: `createUIQueryClient` proxies TanStack queries through a messenger, subscribes/unsubscribes to per-query cache update events to hydrate/remove cached entries, and forwards `invalidateQueries` to the underlying service; also adds typed `useQuery`/`useInfiniteQuery` wrappers. > > Updates package metadata/build references, adds dependencies (TanStack v4, controller-utils, messenger, nock, etc.), and adjusts Yarn constraints to allow the TanStack v4 range and React peer deps without requiring devDependency installs. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0ae98c7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 8d74d4b commit dd11598

22 files changed

Lines changed: 1939 additions & 43 deletions

packages/base-data-service/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Initial release ([#8039](https://github.com/MetaMask/core/pull/8039))
13+
1014
[Unreleased]: https://github.com/MetaMask/core/

packages/base-data-service/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,20 @@
4747
"test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose",
4848
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
4949
},
50+
"dependencies": {
51+
"@metamask/controller-utils": "^11.19.0",
52+
"@metamask/messenger": "^0.3.0",
53+
"@metamask/utils": "^11.9.0",
54+
"@tanstack/query-core": "^4.43.0",
55+
"fast-deep-equal": "^3.1.3"
56+
},
5057
"devDependencies": {
5158
"@metamask/auto-changelog": "^3.4.4",
5259
"@ts-bridge/cli": "^0.6.4",
5360
"@types/jest": "^29.5.14",
5461
"deepmerge": "^4.2.2",
5562
"jest": "^29.7.0",
63+
"nock": "^13.3.1",
5664
"ts-jest": "^29.2.5",
5765
"typedoc": "^0.25.13",
5866
"typedoc-plugin-missing-exports": "^2.0.0",
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import { BrokenCircuitError } from '@metamask/controller-utils';
2+
import { Messenger } from '@metamask/messenger';
3+
import { hashQueryKey } from '@tanstack/query-core';
4+
import { cleanAll } from 'nock';
5+
6+
import { ExampleDataService, serviceName } from '../tests/ExampleDataService';
7+
import {
8+
mockAssets,
9+
mockTransactionsPage1,
10+
mockTransactionsPage2,
11+
mockTransactionsPage3,
12+
TRANSACTIONS_PAGE_2_CURSOR,
13+
TRANSACTIONS_PAGE_3_CURSOR,
14+
} from '../tests/mocks';
15+
16+
const TEST_ADDRESS = '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520';
17+
18+
const MOCK_ASSETS = [
19+
'eip155:1/slip44:60',
20+
'bip122:000000000019d6689c085ae165831e93/slip44:0',
21+
'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f',
22+
];
23+
24+
describe('BaseDataService', () => {
25+
beforeEach(() => {
26+
mockAssets();
27+
mockTransactionsPage1();
28+
mockTransactionsPage2();
29+
mockTransactionsPage3();
30+
});
31+
32+
it('handles basic queries', async () => {
33+
const messenger = new Messenger({ namespace: serviceName });
34+
const service = new ExampleDataService(messenger);
35+
36+
expect(await service.getAssets(MOCK_ASSETS)).toStrictEqual([
37+
{
38+
assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f',
39+
decimals: 18,
40+
name: 'Dai Stablecoin',
41+
symbol: 'DAI',
42+
},
43+
{
44+
assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0',
45+
decimals: 8,
46+
name: 'Bitcoin',
47+
symbol: 'BTC',
48+
},
49+
{
50+
assetId: 'eip155:1/slip44:60',
51+
decimals: 18,
52+
name: 'Ethereum',
53+
symbol: 'ETH',
54+
},
55+
]);
56+
});
57+
58+
it('handles paginated queries', async () => {
59+
const messenger = new Messenger({ namespace: serviceName });
60+
const service = new ExampleDataService(messenger);
61+
62+
const page1 = await service.getActivity(TEST_ADDRESS);
63+
64+
expect(page1.data).toHaveLength(3);
65+
66+
const page2 = await service.getActivity(TEST_ADDRESS, {
67+
after: page1.pageInfo.endCursor,
68+
});
69+
70+
expect(page2.data).toHaveLength(3);
71+
72+
expect(page2.data).not.toStrictEqual(page1.data);
73+
});
74+
75+
it('handles paginated queries starting at a specific page', async () => {
76+
const messenger = new Messenger({ namespace: serviceName });
77+
const service = new ExampleDataService(messenger);
78+
79+
const page2 = await service.getActivity(TEST_ADDRESS, {
80+
after: TRANSACTIONS_PAGE_2_CURSOR,
81+
});
82+
83+
expect(page2.data).toHaveLength(3);
84+
85+
const page3 = await service.getActivity(TEST_ADDRESS, {
86+
after: page2.pageInfo.endCursor,
87+
});
88+
89+
expect(page3.data).toHaveLength(3);
90+
91+
expect(page3.data).not.toStrictEqual(page2.data);
92+
});
93+
94+
it('handles backwards queries starting at a specific page', async () => {
95+
const messenger = new Messenger({ namespace: serviceName });
96+
const service = new ExampleDataService(messenger);
97+
98+
const page3 = await service.getActivity(TEST_ADDRESS, {
99+
after: TRANSACTIONS_PAGE_3_CURSOR,
100+
});
101+
102+
expect(page3.data).toHaveLength(3);
103+
104+
const page2 = await service.getActivity(TEST_ADDRESS, {
105+
before: page3.pageInfo.startCursor,
106+
});
107+
108+
expect(page2.data).toHaveLength(3);
109+
expect(page2.data).not.toStrictEqual(page3.data);
110+
});
111+
112+
it('emits `:cacheUpdated` events when cache is updated', async () => {
113+
const messenger = new Messenger({ namespace: serviceName });
114+
const service = new ExampleDataService(messenger);
115+
116+
const publishSpy = jest.spyOn(messenger, 'publish');
117+
118+
await service.getAssets(MOCK_ASSETS);
119+
120+
const queryKey = ['ExampleDataService:getAssets', MOCK_ASSETS];
121+
122+
const hash = hashQueryKey(queryKey);
123+
124+
expect(publishSpy).toHaveBeenNthCalledWith(
125+
6,
126+
`ExampleDataService:cacheUpdated:${hash}`,
127+
{
128+
type: 'updated',
129+
state: {
130+
mutations: [],
131+
queries: [
132+
expect.objectContaining({
133+
state: expect.objectContaining({
134+
status: 'success',
135+
data: [
136+
{
137+
assetId:
138+
'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f',
139+
decimals: 18,
140+
name: 'Dai Stablecoin',
141+
symbol: 'DAI',
142+
},
143+
{
144+
assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0',
145+
decimals: 8,
146+
name: 'Bitcoin',
147+
symbol: 'BTC',
148+
},
149+
{
150+
assetId: 'eip155:1/slip44:60',
151+
decimals: 18,
152+
name: 'Ethereum',
153+
symbol: 'ETH',
154+
},
155+
],
156+
}),
157+
}),
158+
],
159+
},
160+
},
161+
);
162+
});
163+
164+
it('emits `:cacheUpdated` events when cache entry is removed', async () => {
165+
const messenger = new Messenger({ namespace: serviceName });
166+
const service = new ExampleDataService(messenger);
167+
168+
const publishSpy = jest.spyOn(messenger, 'publish');
169+
170+
await service.getAssets(MOCK_ASSETS);
171+
172+
// Wait for GC
173+
await new Promise((resolve) => setTimeout(resolve, 0));
174+
175+
const queryKey = ['ExampleDataService:getAssets', MOCK_ASSETS];
176+
177+
const hash = hashQueryKey(queryKey);
178+
179+
expect(publishSpy).toHaveBeenNthCalledWith(
180+
8,
181+
`ExampleDataService:cacheUpdated:${hash}`,
182+
{
183+
type: 'removed',
184+
state: null,
185+
},
186+
);
187+
});
188+
189+
it('does not emit events after being destroyed', async () => {
190+
const messenger = new Messenger({ namespace: serviceName });
191+
const service = new ExampleDataService(messenger);
192+
const publishSpy = jest.spyOn(messenger, 'publish');
193+
194+
service.destroy();
195+
196+
await service.getAssets(MOCK_ASSETS);
197+
198+
expect(publishSpy).toHaveBeenCalledTimes(0);
199+
});
200+
201+
it('invalidates queries when requested', async () => {
202+
const messenger = new Messenger({ namespace: serviceName });
203+
const service = new ExampleDataService(messenger);
204+
const publishSpy = jest.spyOn(messenger, 'publish');
205+
206+
await service.getAssets(MOCK_ASSETS);
207+
208+
expect(publishSpy).toHaveBeenCalledTimes(6);
209+
210+
const queryKey = ['ExampleDataService:getAssets', MOCK_ASSETS];
211+
await service.invalidateQueries({ queryKey });
212+
213+
expect(publishSpy).toHaveBeenCalledTimes(8);
214+
});
215+
216+
describe('service policy', () => {
217+
beforeEach(() => {
218+
cleanAll();
219+
});
220+
221+
it('retries failed queries using the service policy', async () => {
222+
const messenger = new Messenger({ namespace: serviceName });
223+
const service = new ExampleDataService(messenger);
224+
225+
mockAssets({ status: 500 });
226+
mockAssets({ status: 500 });
227+
mockAssets();
228+
229+
const result = await service.getAssets(MOCK_ASSETS);
230+
231+
expect(result).toStrictEqual([
232+
{
233+
assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f',
234+
decimals: 18,
235+
name: 'Dai Stablecoin',
236+
symbol: 'DAI',
237+
},
238+
{
239+
assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0',
240+
decimals: 8,
241+
name: 'Bitcoin',
242+
symbol: 'BTC',
243+
},
244+
{
245+
assetId: 'eip155:1/slip44:60',
246+
decimals: 18,
247+
name: 'Ethereum',
248+
symbol: 'ETH',
249+
},
250+
]);
251+
});
252+
253+
it('throws after exhausting service policy retries', async () => {
254+
const messenger = new Messenger({ namespace: serviceName });
255+
const service = new ExampleDataService(messenger);
256+
257+
mockAssets({ status: 500, body: { error: 'internal server error' } });
258+
mockAssets({ status: 500, body: { error: 'internal server error' } });
259+
mockAssets({ status: 500, body: { error: 'internal server error' } });
260+
261+
await expect(service.getAssets(MOCK_ASSETS)).rejects.toThrow(
262+
'Query failed with status code: 500.',
263+
);
264+
});
265+
266+
it('breaks the circuit after consecutive failures', async () => {
267+
const messenger = new Messenger({ namespace: serviceName });
268+
const service = new ExampleDataService(messenger);
269+
270+
mockAssets({ status: 500, body: { error: 'internal server error' } });
271+
mockAssets({ status: 500, body: { error: 'internal server error' } });
272+
mockAssets({ status: 500, body: { error: 'internal server error' } });
273+
274+
await expect(service.getAssets(MOCK_ASSETS)).rejects.toThrow(
275+
'Query failed with status code: 500.',
276+
);
277+
278+
await expect(service.getAssets(MOCK_ASSETS)).rejects.toThrow(
279+
BrokenCircuitError,
280+
);
281+
});
282+
});
283+
});

0 commit comments

Comments
 (0)