diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 8fc0677af2c..ecc6b508ef8 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add request caching infrastructure with TTL, deduplication, and abort support ([#7536](https://github.com/MetaMask/core/pull/7536)) + ### Changed - Bump `@metamask/controller-utils` from `^11.16.0` to `^11.17.0` ([#7534](https://github.com/MetaMask/core/pull/7534)) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 66143d6a508..c2197bba703 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -9,6 +9,7 @@ import type { import type { RampsControllerMessenger } from './RampsController'; import { RampsController } from './RampsController'; import type { RampsServiceGetGeolocationAction } from './RampsService-method-action-types'; +import { RequestStatus, createCacheKey } from './RequestCache'; describe('RampsController', () => { describe('constructor', () => { @@ -17,6 +18,7 @@ describe('RampsController', () => { expect(controller.state).toMatchInlineSnapshot(` Object { "geolocation": null, + "requests": Object {}, } `); }); @@ -30,7 +32,10 @@ describe('RampsController', () => { await withController( { options: { state: givenState } }, ({ controller }) => { - expect(controller.state).toStrictEqual(givenState); + expect(controller.state).toStrictEqual({ + geolocation: 'US', + requests: {}, + }); }, ); }); @@ -38,12 +43,35 @@ describe('RampsController', () => { it('fills in missing initial state with defaults', async () => { await withController({ options: { state: {} } }, ({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` - Object { - "geolocation": null, - } - `); + Object { + "geolocation": null, + "requests": Object {}, + } + `); }); }); + + it('always resets requests cache on initialization', async () => { + const givenState = { + geolocation: 'US', + requests: { + someKey: { + status: RequestStatus.SUCCESS, + data: 'cached', + error: null, + timestamp: Date.now(), + lastFetchedAt: Date.now(), + }, + }, + }; + + await withController( + { options: { state: givenState } }, + ({ controller }) => { + expect(controller.state.requests).toStrictEqual({}); + }, + ); + }); }); describe('metadata', () => { @@ -58,6 +86,7 @@ describe('RampsController', () => { ).toMatchInlineSnapshot(` Object { "geolocation": null, + "requests": Object {}, } `); }); @@ -106,6 +135,7 @@ describe('RampsController', () => { ).toMatchInlineSnapshot(` Object { "geolocation": null, + "requests": Object {}, } `); }); @@ -125,6 +155,352 @@ describe('RampsController', () => { expect(controller.state.geolocation).toBe('US'); }); }); + + it('stores request state in cache', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getGeolocation', + async () => 'US', + ); + + await controller.updateGeolocation(); + + const cacheKey = createCacheKey('updateGeolocation', []); + const requestState = controller.state.requests[cacheKey]; + + expect(requestState).toBeDefined(); + expect(requestState?.status).toBe(RequestStatus.SUCCESS); + expect(requestState?.data).toBe('US'); + expect(requestState?.error).toBeNull(); + }); + }); + + it('returns cached result on subsequent calls within TTL', async () => { + await withController(async ({ controller, rootMessenger }) => { + let callCount = 0; + rootMessenger.registerActionHandler( + 'RampsService:getGeolocation', + async () => { + callCount += 1; + return 'US'; + }, + ); + + await controller.updateGeolocation(); + await controller.updateGeolocation(); + + expect(callCount).toBe(1); + }); + }); + + it('makes a new request when forceRefresh is true', async () => { + await withController(async ({ controller, rootMessenger }) => { + let callCount = 0; + rootMessenger.registerActionHandler( + 'RampsService:getGeolocation', + async () => { + callCount += 1; + return 'US'; + }, + ); + + await controller.updateGeolocation(); + await controller.updateGeolocation({ forceRefresh: true }); + + expect(callCount).toBe(2); + }); + }); + }); + + describe('executeRequest', () => { + it('deduplicates concurrent requests with the same cache key', async () => { + await withController(async ({ controller }) => { + let callCount = 0; + const fetcher = async (): Promise => { + callCount += 1; + await new Promise((resolve) => setTimeout(resolve, 10)); + return 'result'; + }; + + const [result1, result2] = await Promise.all([ + controller.executeRequest('test-key', fetcher), + controller.executeRequest('test-key', fetcher), + ]); + + expect(callCount).toBe(1); + expect(result1).toBe('result'); + expect(result2).toBe('result'); + }); + }); + + it('stores error state when request fails', async () => { + await withController(async ({ controller }) => { + const fetcher = async (): Promise => { + throw new Error('Test error'); + }; + + await expect( + controller.executeRequest('error-key', fetcher), + ).rejects.toThrow('Test error'); + + const requestState = controller.state.requests['error-key']; + expect(requestState?.status).toBe(RequestStatus.ERROR); + expect(requestState?.error).toBe('Test error'); + }); + }); + + it('stores fallback error message when error has no message', async () => { + await withController(async ({ controller }) => { + const fetcher = async (): Promise => { + const error = new Error(); + Object.defineProperty(error, 'message', { value: undefined }); + throw error; + }; + + await expect( + controller.executeRequest('error-key-no-message', fetcher), + ).rejects.toThrow(Error); + + const requestState = controller.state.requests['error-key-no-message']; + expect(requestState?.status).toBe(RequestStatus.ERROR); + expect(requestState?.error).toBe('Unknown error'); + }); + }); + + it('sets loading state while request is in progress', async () => { + await withController(async ({ controller }) => { + let resolvePromise: (value: string) => void; + const fetcher = async (): Promise => { + return new Promise((resolve) => { + resolvePromise = resolve; + }); + }; + + const requestPromise = controller.executeRequest( + 'loading-key', + fetcher, + ); + + expect(controller.state.requests['loading-key']?.status).toBe( + RequestStatus.LOADING, + ); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + resolvePromise!('done'); + await requestPromise; + + expect(controller.state.requests['loading-key']?.status).toBe( + RequestStatus.SUCCESS, + ); + }); + }); + }); + + describe('abortRequest', () => { + it('aborts a pending request', async () => { + await withController(async ({ controller }) => { + let wasAborted = false; + const fetcher = async (signal: AbortSignal): Promise => { + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + wasAborted = true; + reject(new Error('Aborted')); + }); + }); + }; + + const requestPromise = controller.executeRequest('abort-key', fetcher); + const didAbort = controller.abortRequest('abort-key'); + + expect(didAbort).toBe(true); + await expect(requestPromise).rejects.toThrow('Aborted'); + expect(wasAborted).toBe(true); + }); + }); + + it('returns false if no pending request exists', async () => { + await withController(({ controller }) => { + const didAbort = controller.abortRequest('non-existent-key'); + expect(didAbort).toBe(false); + }); + }); + + it('clears LOADING state from requests cache when aborted', async () => { + await withController(async ({ controller }) => { + const fetcher = async (signal: AbortSignal): Promise => { + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + reject(new Error('Aborted')); + }); + }); + }; + + const requestPromise = controller.executeRequest('abort-key', fetcher); + + expect(controller.state.requests['abort-key']?.status).toBe( + RequestStatus.LOADING, + ); + + controller.abortRequest('abort-key'); + + expect(controller.state.requests['abort-key']).toBeUndefined(); + + await expect(requestPromise).rejects.toThrow('Aborted'); + }); + }); + + it('throws if fetch completes after abort signal is triggered', async () => { + await withController(async ({ controller }) => { + const fetcher = async (signal: AbortSignal): Promise => { + // Simulate: abort is called, but fetcher still returns successfully + signal.dispatchEvent(new Event('abort')); + Object.defineProperty(signal, 'aborted', { value: true }); + return 'completed-after-abort'; + }; + + const requestPromise = controller.executeRequest( + 'abort-after-success-key', + fetcher, + ); + + await expect(requestPromise).rejects.toThrow('Request was aborted'); + }); + }); + + it('does not delete newer pending request when aborted request settles', async () => { + await withController(async ({ controller }) => { + let requestASettled = false; + let requestBCallCount = 0; + + // Request A: will be aborted but takes time to settle + const fetcherA = async (signal: AbortSignal): Promise => { + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + // Simulate async cleanup delay before rejecting + setTimeout(() => { + requestASettled = true; + reject(new Error('Request A aborted')); + }, 50); + }); + }); + }; + + // Request B: normal request that should deduplicate correctly + const fetcherB = async (): Promise => { + requestBCallCount += 1; + await new Promise((resolve) => setTimeout(resolve, 10)); + return 'result-b'; + }; + + // Start request A + const promiseA = controller.executeRequest('race-key', fetcherA); + + // Abort request A (removes from pendingRequests, triggers abort) + controller.abortRequest('race-key'); + + // Start request B with the same key before request A settles + expect(requestASettled).toBe(false); + const promiseB = controller.executeRequest('race-key', fetcherB); + + // Start request C with same key - should deduplicate with B + const promiseC = controller.executeRequest('race-key', fetcherB); + + // Wait for request A to finish settling (its finally block runs) + await expect(promiseA).rejects.toThrow('Request A aborted'); + expect(requestASettled).toBe(true); + + // Requests B and C should still work correctly (deduplication intact) + const [resultB, resultC] = await Promise.all([promiseB, promiseC]); + + expect(resultB).toBe('result-b'); + expect(resultC).toBe('result-b'); + expect(requestBCallCount).toBe(1); + }); + }); + }); + + describe('cache eviction', () => { + it('evicts oldest entries when cache exceeds max size', async () => { + await withController( + { options: { requestCacheMaxSize: 3 } }, + async ({ controller }) => { + await controller.executeRequest('key1', async () => 'data1'); + await new Promise((resolve) => setTimeout(resolve, 5)); + await controller.executeRequest('key2', async () => 'data2'); + await new Promise((resolve) => setTimeout(resolve, 5)); + await controller.executeRequest('key3', async () => 'data3'); + await new Promise((resolve) => setTimeout(resolve, 5)); + await controller.executeRequest('key4', async () => 'data4'); + + const keys = Object.keys(controller.state.requests); + expect(keys).toHaveLength(3); + expect(keys).not.toContain('key1'); + expect(keys).toContain('key2'); + expect(keys).toContain('key3'); + expect(keys).toContain('key4'); + }, + ); + }); + + it('handles entries with missing timestamps during eviction', async () => { + await withController( + { options: { requestCacheMaxSize: 2 } }, + async ({ controller }) => { + // Manually inject cache entries with missing timestamps + // This shouldn't happen in normal usage but tests the defensive fallback + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (controller as any).update((state: any) => { + state.requests['no-timestamp-1'] = { + status: RequestStatus.SUCCESS, + data: 'old-data-1', + error: null, + }; + state.requests['no-timestamp-2'] = { + status: RequestStatus.SUCCESS, + data: 'old-data-2', + error: null, + }; + state.requests['with-timestamp'] = { + status: RequestStatus.SUCCESS, + data: 'newer-data', + error: null, + timestamp: Date.now(), + lastFetchedAt: Date.now(), + }; + }); + + // Adding a fourth entry should trigger eviction of 2 entries + await controller.executeRequest('key4', async () => 'data4'); + + const keys = Object.keys(controller.state.requests); + expect(keys).toHaveLength(2); + // Entries without timestamps should be evicted first (treated as timestamp 0) + expect(keys).not.toContain('no-timestamp-1'); + expect(keys).not.toContain('no-timestamp-2'); + expect(keys).toContain('with-timestamp'); + expect(keys).toContain('key4'); + }, + ); + }); + }); + + describe('getRequestState', () => { + it('returns the cached request state', async () => { + await withController(async ({ controller }) => { + await controller.executeRequest('state-key', async () => 'data'); + + const state = controller.getRequestState('state-key'); + expect(state?.status).toBe(RequestStatus.SUCCESS); + expect(state?.data).toBe('data'); + }); + }); + + it('returns undefined for non-existent cache key', async () => { + await withController(({ controller }) => { + const state = controller.getRequestState('non-existent'); + expect(state).toBeUndefined(); + }); + }); }); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 8b6ab579ddd..9b72f9dfdbb 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -5,8 +5,24 @@ import type { } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; +import type { Json } from '@metamask/utils'; import type { RampsServiceGetGeolocationAction } from './RampsService-method-action-types'; +import type { + RequestCache as RequestCacheType, + RequestState, + ExecuteRequestOptions, + PendingRequest, +} from './RequestCache'; +import { + DEFAULT_REQUEST_CACHE_TTL, + DEFAULT_REQUEST_CACHE_MAX_SIZE, + createCacheKey, + isCacheExpired, + createLoadingState, + createSuccessState, + createErrorState, +} from './RequestCache'; // === GENERAL === @@ -27,6 +43,11 @@ export type RampsControllerState = { * The user's country code determined by geolocation. */ geolocation: string | null; + /** + * Cache of request states, keyed by cache key. + * This stores loading, success, and error states for API requests. + */ + requests: RequestCacheType; }; /** @@ -39,6 +60,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, + requests: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: false, + usedInUi: true, + }, } satisfies StateMetadata; /** @@ -52,6 +79,7 @@ const rampsControllerMetadata = { export function getDefaultRampsControllerState(): RampsControllerState { return { geolocation: null, + requests: {}, }; } @@ -103,6 +131,20 @@ export type RampsControllerMessenger = Messenger< RampsControllerEvents | AllowedEvents >; +/** + * Configuration options for the RampsController. + */ +export type RampsControllerOptions = { + /** The messenger suited for this controller. */ + messenger: RampsControllerMessenger; + /** The desired state with which to initialize this controller. */ + state?: Partial; + /** Time to live for cached requests in milliseconds. Defaults to 15 minutes. */ + requestCacheTTL?: number; + /** Maximum number of entries in the request cache. Defaults to 250. */ + requestCacheMaxSize?: number; +}; + // === CONTROLLER DEFINITION === /** @@ -113,6 +155,22 @@ export class RampsController extends BaseController< RampsControllerState, RampsControllerMessenger > { + /** + * Default TTL for cached requests. + */ + readonly #requestCacheTTL: number; + + /** + * Maximum number of entries in the request cache. + */ + readonly #requestCacheMaxSize: number; + + /** + * Map of pending requests for deduplication. + * Key is the cache key, value is the pending request with abort controller. + */ + readonly #pendingRequests: Map = new Map(); + /** * Constructs a new {@link RampsController}. * @@ -120,14 +178,15 @@ export class RampsController extends BaseController< * @param args.messenger - The messenger suited for this controller. * @param args.state - The desired state with which to initialize this * controller. Missing properties will be filled in with defaults. + * @param args.requestCacheTTL - Time to live for cached requests in milliseconds. + * @param args.requestCacheMaxSize - Maximum number of entries in the request cache. */ constructor({ messenger, state = {}, - }: { - messenger: RampsControllerMessenger; - state?: Partial; - }) { + requestCacheTTL = DEFAULT_REQUEST_CACHE_TTL, + requestCacheMaxSize = DEFAULT_REQUEST_CACHE_MAX_SIZE, + }: RampsControllerOptions) { super({ messenger, metadata: rampsControllerMetadata, @@ -135,22 +194,203 @@ export class RampsController extends BaseController< state: { ...getDefaultRampsControllerState(), ...state, + // Always reset requests cache on initialization (non-persisted) + requests: {}, }, }); + + this.#requestCacheTTL = requestCacheTTL; + this.#requestCacheMaxSize = requestCacheMaxSize; + } + + /** + * Executes a request with caching and deduplication. + * + * If a request with the same cache key is already in flight, returns the + * existing promise. If valid cached data exists, returns it without making + * a new request. + * + * @param cacheKey - Unique identifier for this request. + * @param fetcher - Function that performs the actual fetch. Receives an AbortSignal. + * @param options - Options for cache behavior. + * @returns The result of the request. + */ + async executeRequest( + cacheKey: string, + fetcher: (signal: AbortSignal) => Promise, + options?: ExecuteRequestOptions, + ): Promise { + const ttl = options?.ttl ?? this.#requestCacheTTL; + + // Check for existing pending request - join it instead of making a duplicate + const pending = this.#pendingRequests.get(cacheKey); + if (pending) { + return pending.promise as Promise; + } + + // Check cache validity (unless force refresh) + if (!options?.forceRefresh) { + const cached = this.state.requests[cacheKey]; + if (cached && !isCacheExpired(cached, ttl)) { + return cached.data as TResult; + } + } + + // Create abort controller for this request + const abortController = new AbortController(); + const lastFetchedAt = Date.now(); + + // Update state to loading + this.#updateRequestState(cacheKey, createLoadingState()); + + // Create the fetch promise + const promise = (async (): Promise => { + try { + const data = await fetcher(abortController.signal); + + // Don't update state if aborted + if (abortController.signal.aborted) { + throw new Error('Request was aborted'); + } + + this.#updateRequestState( + cacheKey, + createSuccessState(data as Json, lastFetchedAt), + ); + return data; + } catch (error) { + // Don't update state if aborted + if (abortController.signal.aborted) { + throw error; + } + + const errorMessage = (error as Error)?.message; + + this.#updateRequestState( + cacheKey, + createErrorState(errorMessage ?? 'Unknown error', lastFetchedAt), + ); + throw error; + } finally { + // Only delete if this is still our entry (not replaced by a new request) + const currentPending = this.#pendingRequests.get(cacheKey); + if (currentPending?.abortController === abortController) { + this.#pendingRequests.delete(cacheKey); + } + } + })(); + + // Store pending request for deduplication + this.#pendingRequests.set(cacheKey, { promise, abortController }); + + return promise; + } + + /** + * Aborts a pending request if one exists. + * + * @param cacheKey - The cache key of the request to abort. + * @returns True if a request was aborted. + */ + abortRequest(cacheKey: string): boolean { + const pending = this.#pendingRequests.get(cacheKey); + if (pending) { + pending.abortController.abort(); + this.#pendingRequests.delete(cacheKey); + this.#removeRequestState(cacheKey); + return true; + } + return false; + } + + /** + * Removes a request state from the cache. + * + * @param cacheKey - The cache key to remove. + */ + #removeRequestState(cacheKey: string): void { + this.update((state) => { + const requests = state.requests as unknown as Record< + string, + RequestState | undefined + >; + delete requests[cacheKey]; + }); + } + + /** + * Gets the state of a specific cached request. + * + * @param cacheKey - The cache key to look up. + * @returns The request state, or undefined if not cached. + */ + getRequestState(cacheKey: string): RequestState | undefined { + return this.state.requests[cacheKey]; + } + + /** + * Updates the state for a specific request. + * + * @param cacheKey - The cache key. + * @param requestState - The new state for the request. + */ + #updateRequestState(cacheKey: string, requestState: RequestState): void { + const maxSize = this.#requestCacheMaxSize; + + this.update((state) => { + const requests = state.requests as unknown as Record< + string, + RequestState | undefined + >; + requests[cacheKey] = requestState; + + // Evict oldest entries if cache exceeds max size + const keys = Object.keys(requests); + + if (keys.length > maxSize) { + // Sort by timestamp (oldest first) + const sortedKeys = keys.sort((a, b) => { + const aTime = requests[a]?.timestamp ?? 0; + const bTime = requests[b]?.timestamp ?? 0; + return aTime - bTime; + }); + + // Remove oldest entries until we're under the limit + const entriesToRemove = keys.length - maxSize; + for (let i = 0; i < entriesToRemove; i++) { + const keyToRemove = sortedKeys[i]; + if (keyToRemove) { + delete requests[keyToRemove]; + } + } + } + }); } /** * Updates the user's geolocation. * This method calls the RampsService to get the geolocation * and stores the result in state. + * + * @param options - Options for cache behavior. + * @returns The geolocation string. */ - async updateGeolocation(): Promise { - const geolocation = await this.messenger.call( - 'RampsService:getGeolocation', + async updateGeolocation(options?: ExecuteRequestOptions): Promise { + const cacheKey = createCacheKey('updateGeolocation', []); + + const geolocation = await this.executeRequest( + cacheKey, + async () => { + const result = await this.messenger.call('RampsService:getGeolocation'); + return result; + }, + options, ); this.update((state) => { state.geolocation = geolocation; }); + + return geolocation; } } diff --git a/packages/ramps-controller/src/RequestCache.test.ts b/packages/ramps-controller/src/RequestCache.test.ts new file mode 100644 index 00000000000..4752ca9fb12 --- /dev/null +++ b/packages/ramps-controller/src/RequestCache.test.ts @@ -0,0 +1,130 @@ +import { + RequestStatus, + createCacheKey, + isCacheExpired, + createLoadingState, + createSuccessState, + createErrorState, + DEFAULT_REQUEST_CACHE_TTL, +} from './RequestCache'; + +describe('RequestCache', () => { + describe('createCacheKey', () => { + it('creates a cache key from method and empty params', () => { + const key = createCacheKey('updateGeolocation', []); + expect(key).toBe('updateGeolocation:[]'); + }); + + it('creates a cache key from method and params', () => { + const key = createCacheKey('getCryptoCurrencies', ['US', 'USD']); + expect(key).toBe('getCryptoCurrencies:["US","USD"]'); + }); + + it('creates different keys for different params', () => { + const key1 = createCacheKey('method', ['a']); + const key2 = createCacheKey('method', ['b']); + expect(key1).not.toBe(key2); + }); + + it('creates different keys for different methods', () => { + const key1 = createCacheKey('methodA', []); + const key2 = createCacheKey('methodB', []); + expect(key1).not.toBe(key2); + }); + + it('handles complex params', () => { + const key = createCacheKey('search', [ + { chainId: 1, symbol: 'ETH' }, + ['option1', 'option2'], + ]); + expect(key).toBe( + 'search:[{"chainId":1,"symbol":"ETH"},["option1","option2"]]', + ); + }); + }); + + describe('isCacheExpired', () => { + it('returns true for loading state', () => { + const state = createLoadingState(); + expect(isCacheExpired(state)).toBe(true); + }); + + it('returns true for error state', () => { + const state = createErrorState('error', Date.now()); + expect(isCacheExpired(state)).toBe(true); + }); + + it('returns false for fresh success state', () => { + const state = createSuccessState('data', Date.now()); + expect(isCacheExpired(state)).toBe(false); + }); + + it('returns true for expired success state', () => { + const oldTimestamp = Date.now() - DEFAULT_REQUEST_CACHE_TTL - 1000; + const state = { + status: RequestStatus.SUCCESS, + data: 'data', + error: null, + timestamp: oldTimestamp, + lastFetchedAt: oldTimestamp, + }; + expect(isCacheExpired(state)).toBe(true); + }); + + it('respects custom TTL', () => { + const customTTL = 1000; // 1 second + const recentTimestamp = Date.now() - 500; // 500ms ago + const state = { + status: RequestStatus.SUCCESS, + data: 'data', + error: null, + timestamp: recentTimestamp, + lastFetchedAt: recentTimestamp, + }; + expect(isCacheExpired(state, customTTL)).toBe(false); + + const oldTimestamp = Date.now() - 2000; // 2 seconds ago + const expiredState = { + status: RequestStatus.SUCCESS, + data: 'data', + error: null, + timestamp: oldTimestamp, + lastFetchedAt: oldTimestamp, + }; + expect(isCacheExpired(expiredState, customTTL)).toBe(true); + }); + }); + + describe('createLoadingState', () => { + it('creates a loading state', () => { + const state = createLoadingState(); + expect(state.status).toBe(RequestStatus.LOADING); + expect(state.data).toBeNull(); + expect(state.error).toBeNull(); + expect(state.timestamp).toBeGreaterThan(0); + expect(state.lastFetchedAt).toBeGreaterThan(0); + }); + }); + + describe('createSuccessState', () => { + it('creates a success state with data', () => { + const now = Date.now(); + const state = createSuccessState({ value: 42 }, now); + expect(state.status).toBe(RequestStatus.SUCCESS); + expect(state.data).toStrictEqual({ value: 42 }); + expect(state.error).toBeNull(); + expect(state.lastFetchedAt).toBe(now); + }); + }); + + describe('createErrorState', () => { + it('creates an error state with message', () => { + const now = Date.now(); + const state = createErrorState('Something went wrong', now); + expect(state.status).toBe(RequestStatus.ERROR); + expect(state.data).toBeNull(); + expect(state.error).toBe('Something went wrong'); + expect(state.lastFetchedAt).toBe(now); + }); + }); +}); diff --git a/packages/ramps-controller/src/RequestCache.ts b/packages/ramps-controller/src/RequestCache.ts new file mode 100644 index 00000000000..7abcea71727 --- /dev/null +++ b/packages/ramps-controller/src/RequestCache.ts @@ -0,0 +1,146 @@ +import type { Json } from '@metamask/utils'; + +/** + * Status of a cached request. + */ +export enum RequestStatus { + IDLE = 'idle', + LOADING = 'loading', + SUCCESS = 'success', + ERROR = 'error', +} + +/** + * State of a single cached request. + * All properties must be JSON-serializable to satisfy StateConstraint. + */ +export type RequestState = { + /** Current status of the request */ + status: `${RequestStatus}`; + /** The data returned by the request, if successful */ + data: Json | null; + /** Error message if the request failed */ + error: string | null; + /** Timestamp when the request completed (for TTL calculation) */ + timestamp: number; + /** Timestamp when the fetch started */ + lastFetchedAt: number; +}; + +/** + * Cache of request states, keyed by cache key. + */ +export type RequestCache = Record; + +/** + * Default TTL for cached requests in milliseconds (15 minutes). + */ +export const DEFAULT_REQUEST_CACHE_TTL = 15 * 60 * 1000; + +/** + * Default maximum number of entries in the request cache. + */ +export const DEFAULT_REQUEST_CACHE_MAX_SIZE = 250; + +/** + * Creates a cache key from a method name and parameters. + * + * @param method - The method name. + * @param params - The parameters passed to the method. + * @returns A unique cache key string. + */ +export function createCacheKey(method: string, params: unknown[]): string { + return `${method}:${JSON.stringify(params)}`; +} + +/** + * Checks if a cached request has expired based on TTL. + * + * @param requestState - The cached request state. + * @param ttl - Time to live in milliseconds. + * @returns True if the cache entry has expired. + */ +export function isCacheExpired( + requestState: RequestState, + ttl: number = DEFAULT_REQUEST_CACHE_TTL, +): boolean { + if (requestState.status !== RequestStatus.SUCCESS) { + return true; + } + const now = Date.now(); + return now - requestState.timestamp > ttl; +} + +/** + * Creates an initial loading state for a request. + * + * @returns A new RequestState in loading status. + */ +export function createLoadingState(): RequestState { + const now = Date.now(); + return { + status: RequestStatus.LOADING, + data: null, + error: null, + timestamp: now, + lastFetchedAt: now, + }; +} + +/** + * Creates a success state for a request. + * + * @param data - The data returned by the request. + * @param lastFetchedAt - When the fetch started. + * @returns A new RequestState in success status. + */ +export function createSuccessState( + data: Json, + lastFetchedAt: number, +): RequestState { + return { + status: RequestStatus.SUCCESS, + data, + error: null, + timestamp: Date.now(), + lastFetchedAt, + }; +} + +/** + * Creates an error state for a request. + * + * @param error - The error message. + * @param lastFetchedAt - When the fetch started. + * @returns A new RequestState in error status. + */ +export function createErrorState( + error: string, + lastFetchedAt: number, +): RequestState { + return { + status: RequestStatus.ERROR, + data: null, + error, + timestamp: Date.now(), + lastFetchedAt, + }; +} + +/** + * Options for executing a cached request. + */ +export type ExecuteRequestOptions = { + /** Force a refresh even if cached data exists */ + forceRefresh?: boolean; + /** Custom TTL for this request in milliseconds */ + ttl?: number; +}; + +/** + * Represents a pending request with its promise and abort controller. + */ +export type PendingRequest = { + promise: Promise; + abortController: AbortController; +}; diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 2d10271ec4b..78527ba470b 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -5,6 +5,7 @@ export type { RampsControllerMessenger, RampsControllerState, RampsControllerStateChangeEvent, + RampsControllerOptions, } from './RampsController'; export { RampsController, @@ -17,3 +18,19 @@ export type { } from './RampsService'; export { RampsService, RampsEnvironment } from './RampsService'; export type { RampsServiceGetGeolocationAction } from './RampsService-method-action-types'; +export type { + RequestCache, + RequestState, + ExecuteRequestOptions, + PendingRequest, +} from './RequestCache'; +export { + RequestStatus, + DEFAULT_REQUEST_CACHE_TTL, + DEFAULT_REQUEST_CACHE_MAX_SIZE, + createCacheKey, + isCacheExpired, + createLoadingState, + createSuccessState, + createErrorState, +} from './RequestCache';