From 896f3cc7b5ec5f5c90e94ec797b9349001c61240 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Mon, 30 Mar 2026 14:24:51 -0600 Subject: [PATCH 1/4] Migrate SampleGasPriceService to BaseDataService With the release of `@metamask/base-data-service`, the `SampleGasPricesService` class needs to be updated so that new data services that other engineers create follow the standards we want them to follow. --- packages/sample-controllers/CHANGELOG.md | 25 ++ packages/sample-controllers/package.json | 5 +- packages/sample-controllers/src/index.ts | 3 + .../sample-gas-prices-service.test.ts | 215 +-------------- .../sample-gas-prices-service.ts | 247 +++++++----------- .../sample-controllers/tsconfig.build.json | 1 + packages/sample-controllers/tsconfig.json | 1 + yarn.lock | 3 + 8 files changed, 143 insertions(+), 357 deletions(-) diff --git a/packages/sample-controllers/CHANGELOG.md b/packages/sample-controllers/CHANGELOG.md index ebb1a566d3a..63dd7994a78 100644 --- a/packages/sample-controllers/CHANGELOG.md +++ b/packages/sample-controllers/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add actions and events for accessing and interacting with the new query cache for `SampleGasPricesService` ([#8343](https://github.com/MetaMask/core/pull/8343)) + - New actions and events are: + - `SampleGasPricesServiceInvalidateQueriesAction` + - `SampleGasPricesServiceCacheUpdatedEvent` + - `SampleGasPricesServiceGranularCacheUpdatedEvent` + - Additionally, the actions are available within `SampleGasPricesServiceActions` and the events are available within `SampleGasPricesServiceEvents` +- Add optional `queryClientConfig` constructor argument which can be used to configure the underlying TanStack Query client ([#8343](https://github.com/MetaMask/core/pull/8343)) +- Add `destroy` method to `BaseDataService` ([#8343](https://github.com/MetaMask/core/pull/8343)) + +### Changed + +- **BREAKING:** `SampleGasPricesService` now inherits from `BaseDataService` from `@metamask/base-data-service` ([#8343](https://github.com/MetaMask/core/pull/8343)) +- Update `SampleGasPricesService.fetchGasPrices` (and messenger action) so requests to API will be cached and/or deduplicated ([#8343](https://github.com/MetaMask/core/pull/8343)) +- Add new dependencies ([#8343](https://github.com/MetaMask/core/pull/8343)) + - Add `@metamask/base-data-service` ^0.1.1 + - Add `@tanstack/query-core` ^4.43.0 + - Add `@metamask/superstruct` ^3.2.1 + +### Removed + +- **BREAKING:** Remove `onRetry`, `onBreak`, and `onDegraded` ([#8343](https://github.com/MetaMask/core/pull/8343)) + - These methods really should be part of `BaseDataService`; we don't need to recommend that engineers implement them themselves. + ## [4.0.4] ### Changed diff --git a/packages/sample-controllers/package.json b/packages/sample-controllers/package.json index d2d29884fe9..89be1a54039 100644 --- a/packages/sample-controllers/package.json +++ b/packages/sample-controllers/package.json @@ -49,9 +49,12 @@ }, "dependencies": { "@metamask/base-controller": "^9.0.1", + "@metamask/base-data-service": "^0.1.1", "@metamask/messenger": "^1.0.0", "@metamask/network-controller": "^30.0.1", - "@metamask/utils": "^11.9.0" + "@metamask/superstruct": "^3.1.0", + "@metamask/utils": "^11.9.0", + "@tanstack/query-core": "^4.43.0" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", diff --git a/packages/sample-controllers/src/index.ts b/packages/sample-controllers/src/index.ts index 4f9d66888e4..81777561bcf 100644 --- a/packages/sample-controllers/src/index.ts +++ b/packages/sample-controllers/src/index.ts @@ -1,5 +1,8 @@ export type { + SampleGasPricesServiceInvalidateQueriesAction, SampleGasPricesServiceActions, + SampleGasPricesServiceCacheUpdatedEvent, + SampleGasPricesServiceGranularCacheUpdatedEvent, SampleGasPricesServiceEvents, SampleGasPricesServiceMessenger, } from './sample-gas-prices-service/sample-gas-prices-service'; diff --git a/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.test.ts b/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.test.ts index beb84755722..6a3ce4b867a 100644 --- a/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.test.ts +++ b/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.test.ts @@ -1,4 +1,3 @@ -import { HttpError } from '@metamask/controller-utils'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace, @@ -11,14 +10,6 @@ import type { SampleGasPricesServiceMessenger } from './sample-gas-prices-servic import { SampleGasPricesService } from './sample-gas-prices-service'; describe('SampleGasPricesService', () => { - beforeEach(() => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - describe('SampleGasPricesService:fetchGasPrices', () => { it('returns the low, average, and high gas prices from the API', async () => { nock('https://api.example.com') @@ -31,7 +22,7 @@ describe('SampleGasPricesService', () => { high: 15, }, }); - const { rootMessenger } = getService(); + const { rootMessenger } = createService(); const gasPricesResponse = await rootMessenger.call( 'SampleGasPricesService:fetchGasPrices', @@ -62,202 +53,13 @@ describe('SampleGasPricesService', () => { .get('/gas-prices') .query({ chainId: 'eip155:1' }) .reply(200, JSON.stringify(response)); - const { rootMessenger } = getService(); + const { rootMessenger } = createService(); await expect( rootMessenger.call('SampleGasPricesService:fetchGasPrices', '0x1'), ).rejects.toThrow('Malformed response received from gas prices API'); }, ); - - it('calls onDegraded listeners if the request takes longer than 5 seconds to resolve', async () => { - nock('https://api.example.com') - .get('/gas-prices') - .query({ chainId: 'eip155:1' }) - .reply(200, () => { - jest.advanceTimersByTime(6000); - return { - data: { - low: 5, - average: 10, - high: 15, - }, - }; - }); - const { service, rootMessenger } = getService(); - const onDegradedListener = jest.fn(); - service.onDegraded(onDegradedListener); - - await rootMessenger.call('SampleGasPricesService:fetchGasPrices', '0x1'); - - expect(onDegradedListener).toHaveBeenCalled(); - }); - - it('allows the degradedThreshold to be changed', async () => { - nock('https://api.example.com') - .get('/gas-prices') - .query({ chainId: 'eip155:1' }) - .reply(200, () => { - jest.advanceTimersByTime(1000); - return { - data: { - low: 5, - average: 10, - high: 15, - }, - }; - }); - const { service, rootMessenger } = getService({ - options: { - policyOptions: { degradedThreshold: 500 }, - }, - }); - const onDegradedListener = jest.fn(); - service.onDegraded(onDegradedListener); - - await rootMessenger.call('SampleGasPricesService:fetchGasPrices', '0x1'); - - expect(onDegradedListener).toHaveBeenCalled(); - }); - - it('attempts a request that responds with non-200 up to 4 times, throwing if it never succeeds', async () => { - nock('https://api.example.com') - .get('/gas-prices') - .query({ chainId: 'eip155:1' }) - .times(4) - .reply(500); - const { service, rootMessenger } = getService(); - service.onRetry(() => { - jest.advanceTimersToNextTimerAsync().catch(console.error); - }); - - await expect( - rootMessenger.call('SampleGasPricesService:fetchGasPrices', '0x1'), - ).rejects.toThrow( - "Fetching 'https://api.example.com/gas-prices?chainId=eip155%3A1' failed with status '500'", - ); - }); - - it('calls onDegraded listeners when the maximum number of retries is exceeded', async () => { - nock('https://api.example.com') - .get('/gas-prices') - .query({ chainId: 'eip155:1' }) - .times(4) - .reply(500); - const { service, rootMessenger } = getService(); - service.onRetry(() => { - jest.advanceTimersToNextTimerAsync().catch(console.error); - }); - const onDegradedListener = jest.fn(); - service.onDegraded(onDegradedListener); - - await expect( - rootMessenger.call('SampleGasPricesService:fetchGasPrices', '0x1'), - ).rejects.toThrow( - "Fetching 'https://api.example.com/gas-prices?chainId=eip155%3A1' failed with status '500'", - ); - expect(onDegradedListener).toHaveBeenCalled(); - }); - - it('intercepts requests and throws a circuit break error after the 4th failed attempt, running onBreak listeners', async () => { - nock('https://api.example.com') - .get('/gas-prices') - .query({ chainId: 'eip155:1' }) - .times(12) - .reply(500); - const { service, rootMessenger } = getService(); - service.onRetry(() => { - jest.advanceTimersToNextTimerAsync().catch(console.error); - }); - const onBreakListener = jest.fn(); - service.onBreak(onBreakListener); - - // Should make 4 requests - await expect( - rootMessenger.call('SampleGasPricesService:fetchGasPrices', '0x1'), - ).rejects.toThrow( - "Fetching 'https://api.example.com/gas-prices?chainId=eip155%3A1' failed with status '500'", - ); - // Should make 4 requests - await expect( - rootMessenger.call('SampleGasPricesService:fetchGasPrices', '0x1'), - ).rejects.toThrow( - "Fetching 'https://api.example.com/gas-prices?chainId=eip155%3A1' failed with status '500'", - ); - // Should make 4 requests - await expect( - rootMessenger.call('SampleGasPricesService:fetchGasPrices', '0x1'), - ).rejects.toThrow( - "Fetching 'https://api.example.com/gas-prices?chainId=eip155%3A1' failed with status '500'", - ); - // Should not make an additional request (we only mocked 12 requests - // above) - await expect( - rootMessenger.call('SampleGasPricesService:fetchGasPrices', '0x1'), - ).rejects.toThrow( - 'Execution prevented because the circuit breaker is open', - ); - expect(onBreakListener).toHaveBeenCalledWith({ - error: new HttpError( - 500, - "Fetching 'https://api.example.com/gas-prices?chainId=eip155%3A1' failed with status '500'", - ), - }); - }); - - it('resumes requests after the circuit break duration passes, returning the API response if the request ultimately succeeds', async () => { - const circuitBreakDuration = 5_000; - nock('https://api.example.com') - .get('/gas-prices') - .query({ chainId: 'eip155:1' }) - .times(12) - .reply(500) - .get('/gas-prices') - .query({ chainId: 'eip155:1' }) - .reply(200, { - data: { - low: 5, - average: 10, - high: 15, - }, - }); - const { service, rootMessenger } = getService({ - options: { - policyOptions: { circuitBreakDuration }, - }, - }); - service.onRetry(() => { - jest.advanceTimersToNextTimerAsync().catch(console.error); - }); - - await expect( - rootMessenger.call('SampleGasPricesService:fetchGasPrices', '0x1'), - ).rejects.toThrow( - "Fetching 'https://api.example.com/gas-prices?chainId=eip155%3A1' failed with status '500'", - ); - await expect( - rootMessenger.call('SampleGasPricesService:fetchGasPrices', '0x1'), - ).rejects.toThrow( - "Fetching 'https://api.example.com/gas-prices?chainId=eip155%3A1' failed with status '500'", - ); - await expect( - rootMessenger.call('SampleGasPricesService:fetchGasPrices', '0x1'), - ).rejects.toThrow( - "Fetching 'https://api.example.com/gas-prices?chainId=eip155%3A1' failed with status '500'", - ); - await expect( - rootMessenger.call('SampleGasPricesService:fetchGasPrices', '0x1'), - ).rejects.toThrow( - 'Execution prevented because the circuit breaker is open', - ); - await jest.advanceTimersByTimeAsync(circuitBreakDuration); - const gasPricesResponse = await service.fetchGasPrices('0x1'); - expect(gasPricesResponse).toStrictEqual({ - low: 5, - average: 10, - high: 15, - }); - }); }); describe('fetchGasPrices', () => { @@ -272,7 +74,7 @@ describe('SampleGasPricesService', () => { high: 15, }, }); - const { service } = getService(); + const { service } = createService(); const gasPricesResponse = await service.fetchGasPrices('0x1'); @@ -301,7 +103,7 @@ type RootMessenger = Messenger< * * @returns The root messenger. */ -function getRootMessenger(): RootMessenger { +function createRootMessenger(): RootMessenger { return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); } @@ -312,7 +114,7 @@ function getRootMessenger(): RootMessenger { * events required by the controller's messenger. * @returns The service-specific messenger. */ -function getMessenger( +function createServiceMessenger( rootMessenger: RootMessenger, ): SampleGasPricesServiceMessenger { return new Messenger({ @@ -330,7 +132,7 @@ function getMessenger( * `messenger`). * @returns The new service, root messenger, and service messenger. */ -function getService({ +function createService({ options = {}, }: { options?: Partial[0]>; @@ -339,10 +141,9 @@ function getService({ rootMessenger: RootMessenger; messenger: SampleGasPricesServiceMessenger; } { - const rootMessenger = getRootMessenger(); - const messenger = getMessenger(rootMessenger); + const rootMessenger = createRootMessenger(); + const messenger = createServiceMessenger(rootMessenger); const service = new SampleGasPricesService({ - fetch, messenger, ...options, }); diff --git a/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.ts b/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.ts index 40a0b834aa0..87e01b85907 100644 --- a/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.ts +++ b/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.ts @@ -1,16 +1,16 @@ +import { BaseDataService } from '@metamask/base-data-service'; import type { - CreateServicePolicyOptions, - ServicePolicy, -} from '@metamask/controller-utils'; -import { - createServicePolicy, - fromHex, - HttpError, -} from '@metamask/controller-utils'; + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, + DataServiceInvalidateQueriesAction, +} from '@metamask/base-data-service'; +import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; +import { HttpError, fromHex } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; -import { hasProperty, isPlainObject } from '@metamask/utils'; +import type { Infer } from '@metamask/superstruct'; +import { is, number, type } from '@metamask/superstruct'; import type { Hex } from '@metamask/utils'; -import type { IDisposable } from 'cockatiel'; +import type { QueryClientConfig } from '@tanstack/query-core'; import type { SampleGasPricesServiceMethodActions } from './sample-gas-prices-service-method-action-types'; @@ -20,26 +20,53 @@ import type { SampleGasPricesServiceMethodActions } from './sample-gas-prices-se * The name of the {@link SampleGasPricesService}, used to namespace the * service's actions and events. */ -export const serviceName = 'SampleGasPricesService'; +export const SERVICE_NAME = 'SampleGasPricesService'; // === MESSENGER === +/** + * All of the methods within {@link SampleGasPricesService} that are exposed via + * the messenger. + */ const MESSENGER_EXPOSED_METHODS = ['fetchGasPrices'] as const; +/** + * Invalidates cached queries for {@link SampleGasPricesService}. + */ +export type SampleGasPricesServiceInvalidateQueriesAction = + DataServiceInvalidateQueriesAction; + /** * Actions that {@link SampleGasPricesService} exposes to other consumers. */ -export type SampleGasPricesServiceActions = SampleGasPricesServiceMethodActions; +export type SampleGasPricesServiceActions = + | SampleGasPricesServiceMethodActions + | SampleGasPricesServiceInvalidateQueriesAction; /** - * Actions from other messengers that {@link SampleGasPricesMessenger} calls. + * Actions from other messengers that {@link SampleGasPricesService} calls. */ type AllowedActions = never; +/** + * Published when {@link SampleGasPricesService}'s cache is updated. + */ +export type SampleGasPricesServiceCacheUpdatedEvent = + DataServiceCacheUpdatedEvent; + +/** + * Published when a key within {@link SampleGasPricesService}'s cache is + * updated. + */ +export type SampleGasPricesServiceGranularCacheUpdatedEvent = + DataServiceGranularCacheUpdatedEvent; + /** * Events that {@link SampleGasPricesService} exposes to other consumers. */ -export type SampleGasPricesServiceEvents = never; +export type SampleGasPricesServiceEvents = + | SampleGasPricesServiceCacheUpdatedEvent + | SampleGasPricesServiceGranularCacheUpdatedEvent; /** * Events from other messengers that {@link SampleGasPricesService} subscribes @@ -52,23 +79,33 @@ type AllowedEvents = never; * {@link SampleGasPricesService}. */ export type SampleGasPricesServiceMessenger = Messenger< - typeof serviceName, + typeof SERVICE_NAME, SampleGasPricesServiceActions | AllowedActions, SampleGasPricesServiceEvents | AllowedEvents >; // === SERVICE DEFINITION === +/** + * Struct to validate what the API endpoint returns. + */ +const GasPricesResponseStruct = type({ + data: type({ + low: number(), + average: number(), + high: number(), + }), +}); + /** * What the API endpoint returns. */ -type GasPricesResponse = { - data: { - low: number; - average: number; - high: number; - }; -}; +type GasPricesResponse = Infer; + +/** + * The base URL of the API that the service represents. + */ +const BASE_URL = 'https://api.example.com'; /** * This service object is responsible for fetching gas prices via an API. @@ -76,10 +113,10 @@ type GasPricesResponse = { * @example * * ``` ts + * import type { MessengerActions, MessengerEvents } from '@metamask/messenger'; * import { Messenger } from '@metamask/messenger'; * import type { - * SampleGasPricesServiceActions, - * SampleGasPricesServiceEvents, + * SampleGasPricesServiceMessenger, * } from '@metamask/sample-controllers'; * * const rootMessenger = new Messenger< @@ -89,8 +126,8 @@ type GasPricesResponse = { * >({ namespace: 'Root' }); * const gasPricesServiceMessenger = new Messenger< * 'SampleGasPricesService', - * SampleGasPricesServiceActions, - * SampleGasPricesServiceEvents, + * MessengerActions, + * MessengerEvents, * typeof rootMessenger, * >({ * namespace: 'SampleGasPricesService', @@ -99,7 +136,6 @@ type GasPricesResponse = { * // Instantiate the service to register its actions on the messenger * new SampleGasPricesService({ * messenger: gasPricesServiceMessenger, - * fetch, * }); * * // Later... @@ -111,115 +147,42 @@ type GasPricesResponse = { * // ... Do something with the gas prices ... * ``` */ -export class SampleGasPricesService { - /** - * The name of the service. - */ - readonly name: typeof serviceName; - - /** - * The messenger suited for this service. - */ - readonly #messenger: ConstructorParameters< - typeof SampleGasPricesService - >[0]['messenger']; - - /** - * A function that can be used to make an HTTP request. - */ - readonly #fetch: ConstructorParameters< - typeof SampleGasPricesService - >[0]['fetch']; - - /** - * The policy that wraps the request. - * - * @see {@link createServicePolicy} - */ - readonly #policy: ServicePolicy; - +export class SampleGasPricesService extends BaseDataService< + typeof SERVICE_NAME, + SampleGasPricesServiceMessenger +> { /** * Constructs a new SampleGasPricesService object. * * @param args - The constructor arguments. * @param args.messenger - The messenger suited for this service. - * @param args.fetch - A function that can be used to make an HTTP request. If - * your JavaScript environment supports `fetch` natively, you'll probably want - * to pass that; otherwise you can pass an equivalent (such as `fetch` via - * `node-fetch`). + * @param args.queryClientConfig - Configuration for the underlying TanStack + * Query client. * @param args.policyOptions - Options to pass to `createServicePolicy`, which * is used to wrap each request. See {@link CreateServicePolicyOptions}. */ constructor({ messenger, - fetch: fetchFunction, + queryClientConfig = {}, policyOptions = {}, }: { messenger: SampleGasPricesServiceMessenger; - fetch: typeof fetch; + queryClientConfig?: QueryClientConfig; policyOptions?: CreateServicePolicyOptions; }) { - this.name = serviceName; - this.#messenger = messenger; - this.#fetch = fetchFunction; - this.#policy = createServicePolicy(policyOptions); + super({ + name: SERVICE_NAME, + messenger, + queryClientConfig, + policyOptions, + }); - this.#messenger.registerMethodActionHandlers( + this.messenger.registerMethodActionHandlers( this, MESSENGER_EXPOSED_METHODS, ); } - /** - * Registers a handler that will be called after a request returns a non-500 - * response, causing a retry. Primarily useful in tests where timers are being - * mocked. - * - * @param listener - The handler to be called. - * @returns An object that can be used to unregister the handler. See - * {@link CockatielEvent}. - * @see {@link createServicePolicy} - */ - onRetry(listener: Parameters[0]): IDisposable { - return this.#policy.onRetry(listener); - } - - /** - * Registers a handler that will be called after a set number of retry rounds - * prove that requests to the API endpoint consistently return a 5xx response. - * - * @param listener - The handler to be called. - * @returns An object that can be used to unregister the handler. See - * {@link CockatielEvent}. - * @see {@link createServicePolicy} - */ - onBreak(listener: Parameters[0]): IDisposable { - return this.#policy.onBreak(listener); - } - - /** - * Registers a handler that will be called under one of two circumstances: - * - * 1. After a set number of retries prove that requests to the API - * consistently result in one of the following failures: - * 1. A connection initiation error - * 2. A connection reset error - * 3. A timeout error - * 4. A non-JSON response - * 5. A 502, 503, or 504 response - * 2. After a successful request is made to the API, but the response takes - * longer than a set duration to return. - * - * @param listener - The handler to be called. - * @returns An object that can be used to unregister the handler. See - * {@link CockatielEvent}. - */ - onDegraded( - listener: Parameters[0], - ): IDisposable { - return this.#policy.onDegraded(listener); - } - /** * Makes a request to the API in order to retrieve gas prices for a particular * chain. @@ -228,43 +191,29 @@ export class SampleGasPricesService { * @returns The gas prices for the given chain. */ async fetchGasPrices(chainId: Hex): Promise { - const response = await this.#policy.execute(async () => { - const url = new URL('https://api.example.com/gas-prices'); - url.searchParams.append( - 'chainId', - `eip155:${fromHex(chainId).toString()}`, - ); - const localResponse = await this.#fetch(url); - if (!localResponse.ok) { - throw new HttpError( - localResponse.status, - `Fetching '${url.toString()}' failed with status '${localResponse.status}'`, - ); - } - return localResponse; + const url = new URL('/gas-prices', BASE_URL); + url.searchParams.append('chainId', `eip155:${fromHex(chainId).toString()}`); + + const jsonResponse = await this.fetchQuery({ + queryKey: [`${this.name}:fetchGasPrices`, chainId], + queryFn: async () => { + const response = await fetch(url); + + if (!response.ok) { + throw new HttpError( + response.status, + `Fetching '${url.toString()}' failed with status '${response.status}'`, + ); + } + + return response.json(); + }, }); - const jsonResponse = await response.json(); - if ( - isPlainObject(jsonResponse) && - hasProperty(jsonResponse, 'data') && - isPlainObject(jsonResponse.data) && - hasProperty(jsonResponse.data, 'low') && - hasProperty(jsonResponse.data, 'average') && - hasProperty(jsonResponse.data, 'high') - ) { - const { - data: { low, average, high }, - } = jsonResponse; - if ( - typeof low === 'number' && - typeof average === 'number' && - typeof high === 'number' - ) { - return { low, average, high }; - } + if (!is(jsonResponse, GasPricesResponseStruct)) { + throw new Error('Malformed response received from gas prices API'); } - throw new Error('Malformed response received from gas prices API'); + return jsonResponse.data; } } diff --git a/packages/sample-controllers/tsconfig.build.json b/packages/sample-controllers/tsconfig.build.json index d71ced03932..cb781ec80f0 100644 --- a/packages/sample-controllers/tsconfig.build.json +++ b/packages/sample-controllers/tsconfig.build.json @@ -7,6 +7,7 @@ }, "references": [ { "path": "../../packages/base-controller/tsconfig.build.json" }, + { "path": "../../packages/base-data-service/tsconfig.build.json" }, { "path": "../../packages/messenger/tsconfig.build.json" }, { "path": "../../packages/network-controller/tsconfig.build.json" } ], diff --git a/packages/sample-controllers/tsconfig.json b/packages/sample-controllers/tsconfig.json index 65b458897f2..d7990c4116b 100644 --- a/packages/sample-controllers/tsconfig.json +++ b/packages/sample-controllers/tsconfig.json @@ -5,6 +5,7 @@ }, "references": [ { "path": "../../packages/base-controller" }, + { "path": "../../packages/base-data-service" }, { "path": "../../packages/controller-utils" }, { "path": "../../packages/messenger" }, { "path": "../../packages/network-controller" } diff --git a/yarn.lock b/yarn.lock index f2a8cce6114..bb89a2e55d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5008,10 +5008,13 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.1" + "@metamask/base-data-service": "npm:^0.1.1" "@metamask/controller-utils": "npm:^11.19.0" "@metamask/messenger": "npm:^1.0.0" "@metamask/network-controller": "npm:^30.0.1" + "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.9.0" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" From 52639fb770f0520b4b05916b846e741f52828e11 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Mon, 30 Mar 2026 16:11:42 -0600 Subject: [PATCH 2/4] Update changelog --- packages/sample-controllers/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sample-controllers/CHANGELOG.md b/packages/sample-controllers/CHANGELOG.md index 63dd7994a78..986d0a191fe 100644 --- a/packages/sample-controllers/CHANGELOG.md +++ b/packages/sample-controllers/CHANGELOG.md @@ -30,7 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - **BREAKING:** Remove `onRetry`, `onBreak`, and `onDegraded` ([#8343](https://github.com/MetaMask/core/pull/8343)) - - These methods really should be part of `BaseDataService`; we don't need to recommend that engineers implement them themselves. + - You are free to implement these methods in your "real" service class if you need them, but we no longer require you to do so. ## [4.0.4] From bb9a7bc6e1751b406097846e6166c8798788de57 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Mon, 30 Mar 2026 16:13:00 -0600 Subject: [PATCH 3/4] Use lowercase for the service name --- .../sample-gas-prices-service.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.ts b/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.ts index 87e01b85907..955c1a144cb 100644 --- a/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.ts +++ b/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.ts @@ -20,7 +20,7 @@ import type { SampleGasPricesServiceMethodActions } from './sample-gas-prices-se * The name of the {@link SampleGasPricesService}, used to namespace the * service's actions and events. */ -export const SERVICE_NAME = 'SampleGasPricesService'; +export const serviceName = 'SampleGasPricesService'; // === MESSENGER === @@ -34,7 +34,7 @@ const MESSENGER_EXPOSED_METHODS = ['fetchGasPrices'] as const; * Invalidates cached queries for {@link SampleGasPricesService}. */ export type SampleGasPricesServiceInvalidateQueriesAction = - DataServiceInvalidateQueriesAction; + DataServiceInvalidateQueriesAction; /** * Actions that {@link SampleGasPricesService} exposes to other consumers. @@ -52,14 +52,14 @@ type AllowedActions = never; * Published when {@link SampleGasPricesService}'s cache is updated. */ export type SampleGasPricesServiceCacheUpdatedEvent = - DataServiceCacheUpdatedEvent; + DataServiceCacheUpdatedEvent; /** * Published when a key within {@link SampleGasPricesService}'s cache is * updated. */ export type SampleGasPricesServiceGranularCacheUpdatedEvent = - DataServiceGranularCacheUpdatedEvent; + DataServiceGranularCacheUpdatedEvent; /** * Events that {@link SampleGasPricesService} exposes to other consumers. @@ -79,7 +79,7 @@ type AllowedEvents = never; * {@link SampleGasPricesService}. */ export type SampleGasPricesServiceMessenger = Messenger< - typeof SERVICE_NAME, + typeof serviceName, SampleGasPricesServiceActions | AllowedActions, SampleGasPricesServiceEvents | AllowedEvents >; @@ -148,7 +148,7 @@ const BASE_URL = 'https://api.example.com'; * ``` */ export class SampleGasPricesService extends BaseDataService< - typeof SERVICE_NAME, + typeof serviceName, SampleGasPricesServiceMessenger > { /** @@ -171,7 +171,7 @@ export class SampleGasPricesService extends BaseDataService< policyOptions?: CreateServicePolicyOptions; }) { super({ - name: SERVICE_NAME, + name: serviceName, messenger, queryClientConfig, policyOptions, From 9c341e1ce88cd3626840f037dbd6c9de975b82a5 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Mon, 30 Mar 2026 16:39:31 -0600 Subject: [PATCH 4/4] Achieve coverage --- .../sample-gas-prices-service.test.ts | 14 ++++++++++++++ .../sample-gas-prices-service.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.test.ts b/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.test.ts index 6a3ce4b867a..d360f5b36d4 100644 --- a/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.test.ts +++ b/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.test.ts @@ -1,3 +1,4 @@ +import { DEFAULT_MAX_RETRIES } from '@metamask/controller-utils'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace, @@ -36,6 +37,19 @@ describe('SampleGasPricesService', () => { }); }); + it('throws if the API returns a non-200 status', async () => { + nock('https://api.example.com') + .get('/gas-prices') + .query({ chainId: 'eip155:1' }) + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + const { rootMessenger } = createService(); + + await expect( + rootMessenger.call('SampleGasPricesService:fetchGasPrices', '0x1'), + ).rejects.toThrow("Gas prices API failed with status '500'"); + }); + it.each([ 'not an object', { missing: 'data' }, diff --git a/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.ts b/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.ts index 955c1a144cb..591f26e3625 100644 --- a/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.ts +++ b/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.ts @@ -202,7 +202,7 @@ export class SampleGasPricesService extends BaseDataService< if (!response.ok) { throw new HttpError( response.status, - `Fetching '${url.toString()}' failed with status '${response.status}'`, + `Gas prices API failed with status '${response.status}'`, ); }