From b20b6d33da214f06fd83f6c2aaee63ac757159ee Mon Sep 17 00:00:00 2001 From: Andrew Taran Date: Tue, 8 Sep 2026 12:30:03 +0200 Subject: [PATCH 1/2] feat: move cache utils into shared lib --- packages/snap-networks-utils/CHANGELOG.md | 5 + packages/snap-networks-utils/src/index.ts | 20 + .../src/utils/cache/InMemoryCache.test.ts | 449 +++++++++ .../src/utils/cache/InMemoryCache.ts | 181 ++++ .../src/utils/cache/StateCache.test.ts | 880 ++++++++++++++++++ .../src/utils/cache/StateCache.ts | 285 ++++++ .../src/utils/cache/types.ts | 109 +++ .../src/utils/cache/useCache.test.ts | 291 ++++++ .../src/utils/cache/useCache.ts | 117 +++ .../src/utils/cache/useCacheUntil.test.ts | 382 ++++++++ .../src/utils/cache/useCacheUntil.ts | 136 +++ 11 files changed, 2855 insertions(+) create mode 100644 packages/snap-networks-utils/src/utils/cache/InMemoryCache.test.ts create mode 100644 packages/snap-networks-utils/src/utils/cache/InMemoryCache.ts create mode 100644 packages/snap-networks-utils/src/utils/cache/StateCache.test.ts create mode 100644 packages/snap-networks-utils/src/utils/cache/StateCache.ts create mode 100644 packages/snap-networks-utils/src/utils/cache/types.ts create mode 100644 packages/snap-networks-utils/src/utils/cache/useCache.test.ts create mode 100644 packages/snap-networks-utils/src/utils/cache/useCache.ts create mode 100644 packages/snap-networks-utils/src/utils/cache/useCacheUntil.test.ts create mode 100644 packages/snap-networks-utils/src/utils/cache/useCacheUntil.ts diff --git a/packages/snap-networks-utils/CHANGELOG.md b/packages/snap-networks-utils/CHANGELOG.md index 57c5a84b..141915da 100644 --- a/packages/snap-networks-utils/CHANGELOG.md +++ b/packages/snap-networks-utils/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add shared caching utilities for network snaps ([#287](https://github.com/MetaMask/internal-snaps/pull/287)) + - `ICache`, `CacheEntry`, and `TimestampMilliseconds` for describing a generic cache + - `InMemoryCache`, a TTL-backed in-memory cache + - `StateCache`, a cache backed by a snap state manager + - `useCache` and `useCacheUntil` for wrapping functions with fixed-TTL and dynamic-expiry caching - Add shared proof-of-ownership message parsing utilities, batch request/response structs, and batch request/response types. ([#268](https://github.com/MetaMask/internal-snaps/pull/268)) - Add a `UuidStruct` Superstruct for validating UUID v4 strings. ([#243](https://github.com/MetaMask/internal-snaps/pull/243)) - Add helpers `serialize`, `deserialize`, and `Serializable` for round-tripping `BigNumber`, `bigint`, `Uint8Array`, and `undefined` through snap state ([#197](https://github.com/MetaMask/internal-snaps/pull/197)) diff --git a/packages/snap-networks-utils/src/index.ts b/packages/snap-networks-utils/src/index.ts index 77ed424d..6b8fac84 100644 --- a/packages/snap-networks-utils/src/index.ts +++ b/packages/snap-networks-utils/src/index.ts @@ -70,6 +70,26 @@ export { normalizeError, } from './utils/errors'; export { InFlightCoalescer } from './utils/dedupe/InFlightCoalescer'; +export { InMemoryCache } from './utils/cache/InMemoryCache'; +export { StateCache } from './utils/cache/StateCache'; +export { useCache } from './utils/cache/useCache'; +export { useCacheUntil } from './utils/cache/useCacheUntil'; +export type { CacheOptions } from './utils/cache/useCache'; +export type { + CacheUntilOptions, + ResultWithExpiry, +} from './utils/cache/useCacheUntil'; +export type { + ICache, + CacheEntry, + TimestampMilliseconds, +} from './utils/cache/types'; +export type { + CacheStateManager, + CacheStore, + CachePrefix, + StateValue, +} from './utils/cache/StateCache'; export type { CreateSnapErrorHandlingOptions, CreateTrackErrorOptions, diff --git a/packages/snap-networks-utils/src/utils/cache/InMemoryCache.test.ts b/packages/snap-networks-utils/src/utils/cache/InMemoryCache.test.ts new file mode 100644 index 00000000..563c6ec3 --- /dev/null +++ b/packages/snap-networks-utils/src/utils/cache/InMemoryCache.test.ts @@ -0,0 +1,449 @@ +import { Logger, LogLevel } from '../logger/Logger'; +import { InMemoryCache } from './InMemoryCache'; + +describe('InMemoryCache', () => { + let logger: Logger; + + const JAN_1_2024 = 1704067200000; + + beforeEach(() => { + logger = new Logger({ level: LogLevel.SILENT }); + }); + + describe('get', () => { + it('returns the cached value if present and not expired', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + + expect(await cache.get('key')).toBe('value'); + }); + + it('returns undefined if the key is not present', async () => { + const cache = new InMemoryCache(logger); + + expect(await cache.get('key')).toBeUndefined(); + }); + + it('returns undefined and removes the entry if it is expired', async () => { + const cache = new InMemoryCache(logger); + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(JAN_1_2024); + + await cache.set('key', 'value', 1000); + mockDateNow.mockReturnValue(JAN_1_2024 + 1001); + + expect(await cache.get('key')).toBeUndefined(); + expect(await cache.size()).toBe(0); + + mockDateNow.mockRestore(); + }); + }); + + describe('set', () => { + it('stores the value with the default ttl if none is provided', async () => { + const cache = new InMemoryCache(logger); + const mockDateNow = jest.spyOn(Date, 'now').mockReturnValue(JAN_1_2024); + + await cache.set('key', 'value'); + + expect(await cache.peek('key')).toBe('value'); + expect(await cache.keys()).toHaveLength(1); + + mockDateNow.mockRestore(); + }); + + it('stores the value with the provided ttl', async () => { + const cache = new InMemoryCache(logger); + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(JAN_1_2024); + + await cache.set('key', 'value', 1000); + + mockDateNow.mockReturnValue(JAN_1_2024 + 999); + expect(await cache.get('key')).toBe('value'); + + mockDateNow.mockReturnValue(JAN_1_2024 + 1000); + expect(await cache.get('key')).toBe('value'); + + mockDateNow.mockReturnValue(JAN_1_2024 + 1001); + expect(await cache.get('key')).toBeUndefined(); + + mockDateNow.mockRestore(); + }); + + it('clamps the expiry to the maximum safe integer', async () => { + const cache = new InMemoryCache(logger); + + await cache.set('key', 'value', Number.MAX_SAFE_INTEGER); + + expect(await cache.get('key')).toBe('value'); + }); + + it('supports a ttl of 0', async () => { + const cache = new InMemoryCache(logger); + const mockDateNow = jest.spyOn(Date, 'now').mockReturnValue(JAN_1_2024); + + await cache.set('key', 'value', 0); + + expect(await cache.get('key')).toBe('value'); + + mockDateNow.mockReturnValue(JAN_1_2024 + 1); + expect(await cache.get('key')).toBeUndefined(); + + mockDateNow.mockRestore(); + }); + + it('throws an error if the ttl is not a number', async () => { + const cache = new InMemoryCache(logger); + + await expect( + cache.set('key', 'value', 'not a number' as unknown as number), + ).rejects.toThrow('TTL must be a number'); + }); + + it('throws an error if the ttl is negative', async () => { + const cache = new InMemoryCache(logger); + + await expect(cache.set('key', 'value', -1)).rejects.toThrow( + 'TTL must be positive', + ); + }); + + it('throws an error if the ttl is too large', async () => { + const cache = new InMemoryCache(logger); + + await expect( + cache.set('key', 'value', Number.MAX_SAFE_INTEGER + 1), + ).rejects.toThrow('TTL must be less than 2^53 - 1'); + }); + }); + + describe('delete', () => { + it('returns true if the key was present', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + + expect(await cache.delete('key')).toBe(true); + expect(await cache.get('key')).toBeUndefined(); + }); + + it('returns false if the key was not present', async () => { + const cache = new InMemoryCache(logger); + + expect(await cache.delete('key')).toBe(false); + }); + + it('returns false if the mdelete result does not include the key', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + jest.spyOn(cache, 'mdelete').mockResolvedValue({}); + + expect(await cache.delete('key')).toBe(false); + }); + }); + + describe('clear', () => { + it('removes all entries', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + await cache.set('otherKey', 'otherValue'); + + await cache.clear(); + + expect(await cache.size()).toBe(0); + expect(await cache.get('key')).toBeUndefined(); + expect(await cache.get('otherKey')).toBeUndefined(); + }); + }); + + describe('has', () => { + it('returns true if the key is present and not expired', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + + expect(await cache.has('key')).toBe(true); + }); + + it('returns false if the key is not present', async () => { + const cache = new InMemoryCache(logger); + + expect(await cache.has('key')).toBe(false); + }); + + it('returns false and removes the entry if it is expired', async () => { + const cache = new InMemoryCache(logger); + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(JAN_1_2024); + + await cache.set('key', 'value', 1000); + mockDateNow.mockReturnValue(JAN_1_2024 + 1001); + + expect(await cache.has('key')).toBe(false); + expect(await cache.size()).toBe(0); + + mockDateNow.mockRestore(); + }); + }); + + describe('keys', () => { + it('returns all keys in the cache', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + await cache.set('otherKey', 'otherValue'); + + expect(await cache.keys()).toStrictEqual(['key', 'otherKey']); + }); + + it('removes expired entries before returning the keys', async () => { + const cache = new InMemoryCache(logger); + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(JAN_1_2024) + .mockReturnValueOnce(JAN_1_2024); + + await cache.set('key', 'value', 1000); + await cache.set('otherKey', 'otherValue', 5000); + + mockDateNow.mockReturnValue(JAN_1_2024 + 1001); + + expect(await cache.keys()).toStrictEqual(['otherKey']); + + mockDateNow.mockRestore(); + }); + + it('returns an empty array if the cache is empty', async () => { + const cache = new InMemoryCache(logger); + + expect(await cache.keys()).toStrictEqual([]); + }); + }); + + describe('size', () => { + it('returns the number of items in the cache', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + await cache.set('otherKey', 'otherValue'); + + expect(await cache.size()).toBe(2); + }); + + it('removes expired entries before returning the size', async () => { + const cache = new InMemoryCache(logger); + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(JAN_1_2024) + .mockReturnValueOnce(JAN_1_2024); + + await cache.set('key', 'value', 1000); + await cache.set('otherKey', 'otherValue', 5000); + + mockDateNow.mockReturnValue(JAN_1_2024 + 1001); + + expect(await cache.size()).toBe(1); + + mockDateNow.mockRestore(); + }); + + it('returns 0 if the cache is empty', async () => { + const cache = new InMemoryCache(logger); + + expect(await cache.size()).toBe(0); + }); + }); + + describe('peek', () => { + it('returns the value without removing the entry', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + + expect(await cache.peek('key')).toBe('value'); + expect(await cache.size()).toBe(1); + }); + + it('returns undefined if the key is not present', async () => { + const cache = new InMemoryCache(logger); + + expect(await cache.peek('key')).toBeUndefined(); + }); + + it('returns undefined and removes the entry if it is expired', async () => { + const cache = new InMemoryCache(logger); + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(JAN_1_2024); + + await cache.set('key', 'value', 1000); + mockDateNow.mockReturnValue(JAN_1_2024 + 1001); + + expect(await cache.peek('key')).toBeUndefined(); + expect(await cache.size()).toBe(0); + + mockDateNow.mockRestore(); + }); + }); + + describe('mget', () => { + it('returns the values for the given keys', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + await cache.set('otherKey', 'otherValue'); + + expect(await cache.mget(['key', 'otherKey'])).toStrictEqual({ + key: 'value', + otherKey: 'otherValue', + }); + }); + + it('returns undefined for keys that are not present', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + + expect(await cache.mget(['key', 'otherKey'])).toStrictEqual({ + key: 'value', + otherKey: undefined, + }); + }); + + it('removes expired entries before reading', async () => { + const cache = new InMemoryCache(logger); + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(JAN_1_2024); + + await cache.set('key', 'value', 1000); + mockDateNow.mockReturnValue(JAN_1_2024 + 1001); + + expect(await cache.mget(['key'])).toStrictEqual({ key: undefined }); + expect(await cache.size()).toBe(0); + + mockDateNow.mockRestore(); + }); + }); + + describe('mset', () => { + it('no-ops if no entries are provided', async () => { + const cache = new InMemoryCache(logger); + + await cache.mset([]); + + expect(await cache.size()).toBe(0); + }); + + it('defers to set if there is only one entry', async () => { + const cache = new InMemoryCache(logger); + const setSpy = jest.spyOn(cache, 'set'); + + await cache.mset([{ key: 'key', value: 'value', ttlMilliseconds: 1000 }]); + + expect(setSpy).toHaveBeenCalledWith('key', 'value', 1000); + expect(await cache.get('key')).toBe('value'); + }); + + it('defers to set with an undefined ttl if there is only one entry without ttl', async () => { + const cache = new InMemoryCache(logger); + const setSpy = jest.spyOn(cache, 'set'); + + await cache.mset([{ key: 'key', value: 'value' }]); + + expect(setSpy).toHaveBeenCalledWith('key', 'value', undefined); + expect(await cache.get('key')).toBe('value'); + }); + + it('stores multiple entries', async () => { + const cache = new InMemoryCache(logger); + + await cache.mset([ + { key: 'key', value: 'value' }, + { key: 'otherKey', value: 'otherValue' }, + ]); + + expect(await cache.mget(['key', 'otherKey'])).toStrictEqual({ + key: 'value', + otherKey: 'otherValue', + }); + }); + + it('does not store undefined values', async () => { + const cache = new InMemoryCache(logger); + + await cache.mset([ + { key: 'key', value: 'value' }, + { key: 'undefinedKey', value: undefined }, + ]); + + expect(await cache.mget(['key', 'undefinedKey'])).toStrictEqual({ + key: 'value', + undefinedKey: undefined, + }); + expect(await cache.size()).toBe(1); + }); + + it('stores null values', async () => { + const cache = new InMemoryCache(logger); + + await cache.mset([{ key: 'key', value: null }]); + + expect(await cache.mget(['key'])).toStrictEqual({ key: null }); + }); + + it('stores entries with the provided ttl', async () => { + const cache = new InMemoryCache(logger); + const mockDateNow = jest.spyOn(Date, 'now').mockReturnValue(JAN_1_2024); + + await cache.mset([ + { key: 'key', value: 'value', ttlMilliseconds: 1000 }, + { key: 'otherKey', value: 'otherValue' }, + ]); + + mockDateNow.mockReturnValue(JAN_1_2024 + 1001); + + expect(await cache.mget(['key', 'otherKey'])).toStrictEqual({ + key: undefined, + otherKey: 'otherValue', + }); + + mockDateNow.mockRestore(); + }); + + it('throws an error if any ttl is invalid', async () => { + const cache = new InMemoryCache(logger); + + await expect( + cache.mset([ + { key: 'key', value: 'value' }, + { + key: 'otherKey', + value: 'otherValue', + ttlMilliseconds: 'not a number' as unknown as number, + }, + ]), + ).rejects.toThrow('TTL must be a number'); + }); + }); + + describe('mdelete', () => { + it('deletes the given keys and reports which ones were removed', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + await cache.set('otherKey', 'otherValue'); + + const result = await cache.mdelete(['key', 'otherKey', 'missingKey']); + + expect(result).toStrictEqual({ + key: true, + otherKey: true, + missingKey: false, + }); + expect(await cache.size()).toBe(0); + }); + + it('returns an empty object if no keys are provided', async () => { + const cache = new InMemoryCache(logger); + + expect(await cache.mdelete([])).toStrictEqual({}); + }); + }); +}); diff --git a/packages/snap-networks-utils/src/utils/cache/InMemoryCache.ts b/packages/snap-networks-utils/src/utils/cache/InMemoryCache.ts new file mode 100644 index 00000000..a4cf059e --- /dev/null +++ b/packages/snap-networks-utils/src/utils/cache/InMemoryCache.ts @@ -0,0 +1,181 @@ +import { assert } from '@metamask/utils'; + +import type { Logger } from '../logger/Logger'; +import type { Serializable } from '../serialization/types'; +import type { CacheEntry, ICache } from './types'; + +/** + * A simple in-memory cache implementation supporting TTL (Time To Live) functionality. + * + * WARNINGS: + * - This cache is not persistent and will be lost when the process is restarted. + */ +export class InMemoryCache implements ICache { + readonly #cache: Map = new Map(); + + readonly #logger: Logger; + + constructor(logger: Logger) { + this.#logger = logger; + } + + #validateTtlOrThrow(ttlMilliseconds?: number): void { + if (ttlMilliseconds === undefined) { + return; + } + + if (typeof ttlMilliseconds !== 'number') { + throw new Error('TTL must be a number'); + } + + if (ttlMilliseconds < 0) { + throw new Error('TTL must be positive'); + } + + if (ttlMilliseconds > Number.MAX_SAFE_INTEGER) { + throw new Error('TTL must be less than 2^53 - 1'); + } + } + + #isExpired(cacheEntry: CacheEntry): boolean { + return cacheEntry.expiresAt < Date.now(); + } + + async #cleanupExpiredEntries(): Promise { + const expiredKeys: string[] = []; + for (const [key, entry] of this.#cache.entries()) { + if (this.#isExpired(entry)) { + expiredKeys.push(key); + } + } + await this.mdelete(expiredKeys); + } + + async get(key: string): Promise { + const result = await this.mget([key]); + return result[key]; + } + + async set( + key: string, + value: Serializable, + ttlMilliseconds = Number.MAX_SAFE_INTEGER, + ): Promise { + this.#validateTtlOrThrow(ttlMilliseconds); + + this.#cache.set(key, { + value, + expiresAt: Math.min( + Date.now() + ttlMilliseconds, + Number.MAX_SAFE_INTEGER, + ), + }); + } + + async delete(key: string): Promise { + const result = await this.mdelete([key]); + return result[key] ?? false; + } + + async clear(): Promise { + this.#cache.clear(); + } + + async has(key: string): Promise { + const cacheEntry = this.#cache.get(key); + if (!cacheEntry) { + return false; + } + + if (this.#isExpired(cacheEntry)) { + this.#cache.delete(key); + return false; + } + + return true; + } + + async keys(): Promise { + await this.#cleanupExpiredEntries(); + return Array.from(this.#cache.keys()); + } + + async size(): Promise { + await this.#cleanupExpiredEntries(); + return this.#cache.size; + } + + async peek(key: string): Promise { + const cacheEntry = this.#cache.get(key); + if (!cacheEntry) { + return undefined; + } + + if (this.#isExpired(cacheEntry)) { + this.#cache.delete(key); + return undefined; + } + + return cacheEntry.value; + } + + async mget( + keys: string[], + ): Promise> { + await this.#cleanupExpiredEntries(); + + const result: Record = {}; + + for (const key of keys) { + const cacheEntry = this.#cache.get(key); + if (!cacheEntry) { + this.#logger.info(`[InMemoryCache] ❌ Cache miss for key "${key}"`); + result[key] = undefined; + continue; + } + + this.#logger.info(`[InMemoryCache] 🎉 Cache hit for key "${key}"`); + result[key] = cacheEntry.value; + } + + return result; + } + + async mset( + entries: { key: string; value: Serializable; ttlMilliseconds?: number }[], + ): Promise { + if (entries.length === 0) { + return; + } + + if (entries.length === 1) { + assert(entries[0]); // Enforce type narrowing as TS cannot infer that entries[0] is defined + const { key, value, ttlMilliseconds } = entries[0]; + await this.set(key, value, ttlMilliseconds); + return; + } + + entries.forEach(({ ttlMilliseconds }) => { + this.#validateTtlOrThrow(ttlMilliseconds); + }); + + entries.forEach(({ key, value, ttlMilliseconds }) => { + if (value === undefined) { + return; + } + this.#cache.set(key, { + value, + expiresAt: Math.min( + Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), + Number.MAX_SAFE_INTEGER, + ), + }); + }); + } + + async mdelete(keys: string[]): Promise> { + return Object.fromEntries( + keys.map((key) => [key, this.#cache.delete(key)]), + ); + } +} diff --git a/packages/snap-networks-utils/src/utils/cache/StateCache.test.ts b/packages/snap-networks-utils/src/utils/cache/StateCache.test.ts new file mode 100644 index 00000000..4c7884d5 --- /dev/null +++ b/packages/snap-networks-utils/src/utils/cache/StateCache.test.ts @@ -0,0 +1,880 @@ +/* eslint-disable jest/prefer-strict-equal */ + +import { get, set, unset } from 'lodash'; + +import { Logger, LogLevel } from '../logger/Logger'; +import type { Serializable } from '../serialization/types'; +import type { CacheStateManager } from './StateCache'; +import type { StateValue } from './StateCache'; +import { StateCache } from './StateCache'; + +/** + * A simple implementation of a state manager that relies on an in-memory state, + * used for testing purposes. + */ +class InMemoryState implements CacheStateManager { + #state: StateValue; + + constructor(initialState: StateValue) { + this.#state = initialState; + } + + async get(): Promise { + return this.#state; + } + + async getKey( + key: string, + ): Promise { + return get(this.#state, key) as TKey | undefined; + } + + async setKey(key: string, value: Serializable): Promise { + set(this.#state, key, value); // Use lodash to set the value using a json path + } + + async update( + callback: (state: StateValue) => StateValue, + ): Promise { + return (this.#state = callback(this.#state)); + } + + async deleteKey(key: string): Promise { + // Using lodash's unset to leverage the json path capabilities + unset(this.#state, key); + } +} + +describe('StateCache', () => { + let logger: Logger; + + const createStateCache = ( + state: CacheStateManager, + prefix?: `__cache__${string}`, + ): StateCache => new StateCache(state, logger, prefix); + + beforeEach(() => { + logger = new Logger({ level: LogLevel.SILENT }); + }); + + describe('constructor', () => { + it('uses the default prefix if not specified', () => { + const cache = new StateCache(new InMemoryState({}), logger); + + expect(cache.prefix).toBe('__cache__default'); + }); + + it('uses the specified prefix if provided', () => { + const cache = createStateCache( + new InMemoryState({}), + '__cache__my-prefix', + ); + + expect(cache.prefix).toBe('__cache__my-prefix'); + }); + }); + + describe('get', () => { + it('returns undefined if the cache is not initialized', async () => { + const stateWithNoCache = new InMemoryState({ + name: 'John', // State has some data that is not related to the cache + // __cache__default: {} // State has not been initialized with cached data + }); + const cache = createStateCache(stateWithNoCache); + + const value = await cache.get('someKey'); + + expect(value).toBeUndefined(); + }); + + it('returns undefined if the cache is initialized but the key is not present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const value = await cache.get('someOtherKey'); + + expect(value).toBeUndefined(); + }); + + it('returns the cached value if the cache is initialized and the key is present and not expired', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, // Expires in a long time + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const value = await cache.get('someKey'); + + expect(value).toBe('someValue'); + }); + + it('returns undefined if the cache is initialized and the key is present but expired', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const value = await cache.get('someKey'); + + expect(value).toBeUndefined(); + }); + + it('deletes expired cache entries upon retrieval', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = createStateCache(stateWithCache); + + await cache.get('someKey'); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: {}, + }); + }); + }); + + describe('set', () => { + it('initializes the cache if it is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await cache.set('someKey', 'someValue'); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + + it('sets the cache entry with no expiration if no ttl is provided', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = createStateCache(stateWithCache); + + await cache.set('someKey', 'someValue'); + const stateValue = await stateWithCache.get(); + + const value = await cache.get('someKey'); + + expect(value).toBe('someValue'); + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + + it('overwrites the cache entry if it is present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + await cache.set('someKey', 'someOtherValue'); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + + it('sets the cache entry with the provided ttl', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = createStateCache(stateWithCache); + jest.spyOn(Date, 'now').mockReturnValueOnce(1704067200000); // January 1, 2024 + + await cache.set('someKey', 'someValue', 1000); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067201000, // January 1, 2024 + 1 second + }, + }, + }); + }); + + it('supports a ttl of 0', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = createStateCache(stateWithCache); + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValue(1704067200000); // January 1, 2024 + + await cache.set('someKey', 'someValue', 0); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 (+ 0 seconds) + }, + }, + }); + + // Change the mock to return a time after the expiration + mockDateNow.mockReturnValue(1704067200001); // January 1, 2024 + 1 millisecond + + const value = await cache.get('someKey'); // Should expire immediately + expect(value).toBeUndefined(); + + mockDateNow.mockRestore(); + }); + + it('throws an error if the ttl is not a number', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = createStateCache(stateWithCache); + + await expect( + cache.set('someKey', 'someValue', 'not a number' as unknown as number), + ).rejects.toThrow('TTL must be a number'); + }); + + it('throws an error if the ttl is negative', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = createStateCache(stateWithCache); + + await expect(cache.set('someKey', 'someValue', -1)).rejects.toThrow( + 'TTL must be positive', + ); + }); + + it('throws an error if the ttl is too large', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = createStateCache(stateWithCache); + + await expect( + cache.set('someKey', 'someValue', Number.MAX_SAFE_INTEGER + 1), + ).rejects.toThrow('TTL must be less than 2^53 - 1'); + }); + }); + + describe('delete', () => { + it('deletes the cache entry and returns true if the entry was present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.delete('someKey'); + expect(result).toBe(true); + + const value = await cache.get('someKey'); + + expect(value).toBeUndefined(); + }); + + it('leaves the cache unchanged and returns false if the entry was not present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.delete('someOtherKey'); // Try to + const someKeyValue = await cache.get('someKey'); + const someOtherKeyValue = await cache.get('someOtherKey'); + + expect(result).toBe(false); + expect(someKeyValue).toBe('someValue'); + expect(someOtherKeyValue).toBeUndefined(); + }); + + it('returns false if the mdelete result does not include the key', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + jest.spyOn(cache, 'mdelete').mockResolvedValue({}); + + expect(await cache.delete('someKey')).toBe(false); + }); + }); + + describe('clear', () => { + it('empties the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + await cache.clear(); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: {}, + }); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await cache.clear(); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: {}, + }); + }); + }); + + describe('has', () => { + it('returns true if the key is present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.has('someKey'); + + expect(result).toBe(true); + }); + + it('returns false if the key is not present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.has('someOtherKey'); + expect(result).toBe(false); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.has('someKey'); + expect(result).toBe(false); + }); + }); + + describe('keys', () => { + it('returns all keys in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.keys(); + + expect(result).toStrictEqual(['someKey', 'someOtherKey']); + }); + + it('returns an empty array if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.keys(); + + expect(result).toStrictEqual([]); + }); + }); + + describe('size', () => { + it('returns the number of items in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.size(); + + expect(result).toBe(2); + }); + + it('returns 0 if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.size(); + + expect(result).toBe(0); + }); + }); + + describe('peek', () => { + it('returns the value of an unexpired key if it is present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.peek('someKey'); + + expect(result).toBe('someValue'); + }); + + it('returns the value of an expired key if it is present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.peek('someKey'); + + expect(result).toBe('someValue'); + }); + + it('returns undefined if the key is not present in the cache', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.peek('someOtherKey'); + + expect(result).toBeUndefined(); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.peek('someKey'); + expect(result).toBeUndefined(); + }); + }); + + describe('mget', () => { + it('returns the values of the keys if they are present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({ + someKey: 'someValue', + someOtherKey: 'someOtherValue', + }); + }); + + it('returns undefined for keys that are not present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toEqual({ + someKey: 'someValue', + someOtherKey: undefined, + }); + }); + + it('returns undefined for keys that are expired', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = createStateCache(stateWithCache); + + // Mock Date.now to return a time after the expiration + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValue(1704067200001); // January 1, 2024 + 1 millisecond + + const result = await cache.mget(['someKey']); + + expect(result).toEqual({ + someKey: undefined, + }); + + mockDateNow.mockRestore(); + }); + + it('returns undefined for keys that map to undefined entries', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: undefined, + }, + } as unknown as StateValue); + const cache = createStateCache(stateWithCache); + + const result = await cache.mget(['someKey']); + + expect(result).toEqual({ + someKey: undefined, + }); + }); + + it('returns an empty object if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({}); + }); + + it('deletes expired cache entries upon retrieval', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + // Mock Date.now to return a time after the expiration + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValue(1704067200001); // January 1, 2024 + 1 millisecond + + await cache.mget(['someKey']); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + + mockDateNow.mockRestore(); + }); + }); + + describe('mset', () => { + it('sets the values of the keys if they are present in the cache', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await cache.mset([ + { key: 'someKey', value: 'someValue' }, + { key: 'someOtherKey', value: 'someOtherValue' }, + ]); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({ + someKey: 'someValue', + someOtherKey: 'someOtherValue', + }); + }); + + it('does not store undefined values in the cache', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await cache.mset([ + { key: 'someKey', value: 'someValue' }, + { key: 'undefinedKey', value: undefined }, + ]); + + const result = await cache.mget(['someKey', 'undefinedKey']); + + expect(result).toEqual({ + someKey: 'someValue', + undefinedKey: undefined, + }); + + // Verify the undefined value was not stored in the cache + const stateValue = await stateWithCache.get(); + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + + it('stores null values in the cache', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await cache.mset([{ key: 'someKey', value: null }]); + + const result = await cache.mget(['someKey']); + + expect(result).toStrictEqual({ + someKey: null, + }); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await cache.mset([{ key: 'someKey', value: 'someValue' }]); + + const result = await cache.mget(['someKey']); + + expect(result).toStrictEqual({ + someKey: 'someValue', + }); + }); + + it('throws an error if the ttl is invalid', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await expect( + cache.mset([ + { + key: 'someKey', + value: 'someValue', + ttlMilliseconds: 'not a number' as unknown as number, + }, + ]), + ).rejects.toThrow('TTL must be a number'); + }); + + it('does not affect other keys in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey0: { + value: 'someValue0', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someKey1: { + value: 'someValue1', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + await cache.mset([ + { key: 'someKey0', value: 'someValue0Overwritten' }, + { key: 'someKey2', value: 'someValue2' }, + ]); + + const result = await cache.mget(['someKey0', 'someKey1', 'someKey2']); + + expect(result).toStrictEqual({ + someKey0: 'someValue0Overwritten', + someKey1: 'someValue1', + someKey2: 'someValue2', + }); + }); + + it('no-ops if no entries are provided', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + const updateSpy = jest.spyOn(stateWithCache, 'update'); + + await cache.mset([]); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('defers to set if there is only one entry', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + const setSpy = jest.spyOn(cache, 'set'); + + const singleEntry = { + key: 'someKey', + value: 'someValue', + ttlMilliseconds: 1000, + }; + await cache.mset([singleEntry]); + + expect(setSpy).toHaveBeenCalledWith( + singleEntry.key, + singleEntry.value, + singleEntry.ttlMilliseconds, + ); + }); + }); + + describe('mdelete', () => { + it('deletes the keys from the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + await cache.mdelete(['someKey', 'someOtherKey']); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toEqual({ + someKey: undefined, + someOtherKey: undefined, + }); + }); + + it('returns an object where the values are true if the keys were deleted and false if they were not present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.mdelete(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({ + someKey: true, + someOtherKey: false, + }); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.mdelete(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({ + someKey: false, + someOtherKey: false, + }); + }); + }); +}); diff --git a/packages/snap-networks-utils/src/utils/cache/StateCache.ts b/packages/snap-networks-utils/src/utils/cache/StateCache.ts new file mode 100644 index 00000000..b3d6458c --- /dev/null +++ b/packages/snap-networks-utils/src/utils/cache/StateCache.ts @@ -0,0 +1,285 @@ +import { assert } from '@metamask/utils'; + +import type { Logger } from '../logger/Logger'; +import type { Serializable } from '../serialization/types'; +import type { CacheEntry, ICache } from './types'; + +/** + * The minimal subset of a state manager that {@link StateCache} relies on. + * + * Any implementation whose `getKey`, `setKey` and `update` methods match these signatures + * (such as the `IStateManager` implementations in the network snaps) satisfies this interface structurally. + */ +export type CacheStateManager< + TStateValue extends Record, +> = { + getKey(key: string): Promise; + setKey(key: string, value: Serializable): Promise; + update( + updaterFunction: (state: TStateValue) => TStateValue, + ): Promise; +}; + +/** + * The whole cache store. + */ +export type CacheStore = Record | undefined; + +/** + * A prefix for the cache "location" in the state. Enforced to start with `__cache__` to avoid collisions with other state values. + */ +export type CachePrefix = `__cache__${string}`; + +/** + * Describes the shape of the whole state inside which the cache is stored. + */ +export type StateValue = { + [x: string]: Serializable; +} & { + [K in CachePrefix]?: CacheStore; +}; + +/** + * A cache that wraps any implementation of a state manager to store the cache. + * + * It is intended to be used with the snap's `State` class, but can be used with any other implementation of the state manager interface. For instance it can be used with an in-memory state implementation for testing purposes. + * + * By default, it stores its data in the `__cache__default` property of the state, but you can specify any other prefix you want, provided it starts with `__cache__` to avoid collisions with other state values. + * This is useful if you want to have multiple independent caches in the same state. + * + * ``` + * { + * ..., // other state values + * __cache__default: { + * key1: value1, + * key2: value2, + * }, + * __cache__my-prefix: { + * key3: value3, + * key4: value4, + * }, + * } + * ``` + * + * @example + * ```ts + * const state = new State({}); // Here we use the real snap's state + * const rootLogger = new Logger({ level: LogLevel.INFO }); + * const cache = new StateCache(state, rootLogger, '__cache__my-prefix'); + * + * // state looks like this: + * // { + * // ..., // other state values + * // no __cache__my-prefix yet + * // } + * + * await cache.set('key1', 'value1'); + * + * // state looks like this: + * // { + * // ..., // other state values + * // __cache__my-prefix: { + * // key1: value1, + * // }, + * // } + * ``` + */ +export class StateCache implements ICache { + readonly #state: CacheStateManager; + + readonly #logger: Logger; + + public readonly prefix: CachePrefix; + + constructor( + state: CacheStateManager, + logger: Logger, + prefix: CachePrefix = '__cache__default', + ) { + this.#state = state; + this.#logger = logger; + this.prefix = prefix; + } + + async get(key: string): Promise { + const result = await this.mget([key]); + return result[key]; + } + + async set( + key: string, + value: Serializable, + ttlMilliseconds = Number.MAX_SAFE_INTEGER, + ): Promise { + this.#validateTtlOrThrow(ttlMilliseconds); + + await this.#state.setKey(`${this.prefix}.${key}`, { + value, + expiresAt: Math.min( + Date.now() + ttlMilliseconds, + Number.MAX_SAFE_INTEGER, + ), + }); + } + + #validateTtlOrThrow(ttlMilliseconds?: number): void { + if (ttlMilliseconds === undefined) { + return; + } + + if (typeof ttlMilliseconds !== 'number') { + throw new Error('TTL must be a number'); + } + + if (ttlMilliseconds < 0) { + throw new Error('TTL must be positive'); + } + + if (ttlMilliseconds > Number.MAX_SAFE_INTEGER) { + throw new Error('TTL must be less than 2^53 - 1'); + } + } + + async delete(key: string): Promise { + const result = await this.mdelete([key]); + return result[key] ?? false; + } + + async clear(): Promise { + await this.#state.setKey(this.prefix, {}); + } + + async has(key: string): Promise { + const result = await this.get(key); + return result !== undefined; + } + + async keys(): Promise { + const cacheStore = await this.#state.getKey(this.prefix); + + return Object.keys(cacheStore ?? {}); + } + + async size(): Promise { + const cacheStore = await this.#state.getKey(this.prefix); + + return Object.keys(cacheStore ?? {}).length; + } + + async peek(key: string): Promise { + const cacheStore = await this.#state.getKey(this.prefix); + const cacheEntry = cacheStore?.[key]; + + return cacheEntry?.value; + } + + async mget( + keys: string[], + ): Promise> { + const cacheStore = await this.#state.getKey(this.prefix); + + // If cache is not initialized, return empty object + if (!cacheStore) { + return {}; + } + + const keysAndValues = Object.entries(cacheStore).filter(([key]) => + keys.includes(key), + ); + + const expiredKeys = keysAndValues.filter( + ([_unused, cacheEntry]) => + cacheEntry && cacheEntry.expiresAt < Date.now(), + ); + + await this.mdelete(expiredKeys.map(([key]) => key)); + + const result: Record = {}; + + // First, handle keys that exist in the cache + keysAndValues.forEach(([key, cacheEntry]) => { + if (cacheEntry === undefined) { + this.#logger.info(`[StateCache] ❌ Cache miss for key "${key}"`); + result[key] = undefined; + return; + } + + if (cacheEntry.expiresAt < Date.now()) { + this.#logger.info(`[StateCache] ⌛ Cache expired for key "${key}"`); + result[key] = undefined; + } else { + this.#logger.info(`[StateCache] 🎉 Cache hit for key "${key}"`); + result[key] = cacheEntry.value; + } + }); + + // Then, handle keys that don't exist in the cache + keys.forEach((key) => { + if (!Object.hasOwn(result, key)) { + this.#logger.info(`[StateCache] ❌ Cache miss for key "${key}"`); + result[key] = undefined; + } + }); + + return result; + } + + async mset( + entries: { key: string; value: Serializable; ttlMilliseconds?: number }[], + ): Promise { + if (entries.length === 0) { + return; + } + + if (entries.length === 1) { + assert(entries[0]); // Enforce type narrowing as TS cannot infer that entries[0] is defined + const { key, value, ttlMilliseconds } = entries[0]; + await this.set(key, value, ttlMilliseconds); + return; + } + + entries.forEach(({ ttlMilliseconds }) => { + this.#validateTtlOrThrow(ttlMilliseconds); + }); + + // Using `state.update` is preferred for bulk `set`s, because it's more efficient and atomic. + await this.#state.update((stateValue) => { + const cacheStore = stateValue[this.prefix] ?? {}; + entries.forEach(({ key, value, ttlMilliseconds }) => { + if (value === undefined) { + return; + } + cacheStore[key] = { + value, + expiresAt: Math.min( + Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), + Number.MAX_SAFE_INTEGER, + ), + }; + }); + stateValue[this.prefix] = cacheStore; + return stateValue; + }); + } + + async mdelete(keys: string[]): Promise> { + const result: Record = {}; + + // Using `state.update` is preferred for bulk `delete`s, because it's more efficient and atomic. + await this.#state.update((stateValue) => { + const cacheStore = stateValue[this.prefix] ?? {}; + keys.forEach((key) => { + if (cacheStore[key] === undefined) { + result[key] = false; + } else { + delete cacheStore[key]; + result[key] = true; + } + }); + stateValue[this.prefix] = cacheStore; + return stateValue; + }); + + return result; + } +} diff --git a/packages/snap-networks-utils/src/utils/cache/types.ts b/packages/snap-networks-utils/src/utils/cache/types.ts new file mode 100644 index 00000000..82a3b758 --- /dev/null +++ b/packages/snap-networks-utils/src/utils/cache/types.ts @@ -0,0 +1,109 @@ +import type { Serializable } from '../serialization/types'; + +export type TimestampMilliseconds = number; + +/** + * A single cache entry. + */ +export type CacheEntry = { + value: Serializable; + expiresAt: TimestampMilliseconds; +}; + +/** + * Interface for a generic cache implementation. + * + * @template TValue - The type of values stored in the cache + */ +export type ICache = { + /** + * Retrieves a value from the cache by key. + * + * @param key - The key to retrieve + * @returns The value if found, undefined if not found + */ + get(key: string): Promise; + + /** + * Stores a value in the cache with an optional TTL. + * - If a value is undefined, it will not be stored in the cache. + * - If a value is null, it will be stored in the cache. + * + * @param key - The key to store the value under + * @param value - The value to store + * @param ttlMilliseconds - Optional time-to-live in milliseconds. If not provided, the value will not expire. + * @throws Error if any entry's ttlMilliseconds is not a number, is negative, or is greater than 2^53 - 1 + */ + set(key: string, value: TValue, ttlMilliseconds?: number): Promise; + + /** + * Removes a value from the cache. + * + * @param key - The key to remove + * @returns true if the key was found and removed, false otherwise + */ + delete(key: string): Promise; + + /** + * Removes all values from the cache. + */ + clear(): Promise; + + /** + * Checks if a key exists in the cache. + * + * @param key - The key to check + * @returns true if the key exists, false otherwise + */ + has(key: string): Promise; + + /** + * Returns all keys currently in the cache. + * + * @returns Array of keys + */ + keys(): Promise; + + /** + * Returns the number of items in the cache. + * + * @returns The number of items + */ + size(): Promise; + + /** + * Retrieves a value from the cache without affecting its TTL or last accessed time. + * + * @param key - The key to peek at + * @returns The value if found, undefined if not found + */ + peek(key: string): Promise; + + /** + * Retrieves multiple values from the cache in a single operation. + * + * @param keys - Array of keys to retrieve + * @returns Object mapping keys to their values (or undefined if not found) + */ + mget(keys: string[]): Promise>; + + /** + * Stores multiple values in the cache in a single operation. + * - If a value is undefined, it will not be stored in the cache. + * - If a value is null, it will be stored in the cache. + * + * @param entries - Array of entries to store, each with key, value, and optional TTL (if not provided, the value will not expire) + * @throws Error if any entry's ttlMilliseconds is not a number, is negative, or is greater than 2^53 - 1 + */ + mset( + entries: { key: string; value: TValue; ttlMilliseconds?: number }[], + ): Promise; + + /** + * Removes multiple values from the cache. + * + * @param keys - Array of keys to remove + * @returns An object mapping each key to a boolean indicating whether it was found and removed + */ + mdelete(keys: string[]): Promise>; +}; diff --git a/packages/snap-networks-utils/src/utils/cache/useCache.test.ts b/packages/snap-networks-utils/src/utils/cache/useCache.test.ts new file mode 100644 index 00000000..13eee88c --- /dev/null +++ b/packages/snap-networks-utils/src/utils/cache/useCache.test.ts @@ -0,0 +1,291 @@ +import type { Serializable } from '../serialization/types'; +import type { ICache } from './types'; +import { useCache } from './useCache'; +import type { CacheOptions } from './useCache'; + +describe('useCache', () => { + // Spy to check if the original function was executed or not + let actualExecutionSpy: jest.Mock; + + // Mock cache + let cache: ICache; + + // Common cache options + let cacheOptions: CacheOptions; + + // Original test functions + let testFunction: () => Promise; + let testFunctionWithArgs: (arg1: string, arg2: number) => Promise; + let testFunctionWithComplexArgs: (obj: { + name: string; + age: number; + }) => Promise; + + // Cached versions + let cachedTestFunction: () => Promise; + let cachedTestFunctionWithArgs: ( + arg1: string, + arg2: number, + ) => Promise; + let cachedTestFunctionWithComplexArgs: (obj: { + name: string; + age: number; + }) => Promise; + + beforeEach(() => { + // Reset mocks for each test + actualExecutionSpy = jest.fn().mockResolvedValue('test'); + + // Create a mock cache + cache = { + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockResolvedValue(undefined), + } as unknown as ICache; + + // Define common cache options + cacheOptions = { + ttlMilliseconds: 1000, + functionName: 'testFunction', + }; + + // Define original functions + testFunction = async (): Promise => actualExecutionSpy(); + testFunctionWithArgs = async ( + arg1: string, + arg2: number, + ): Promise => actualExecutionSpy(arg1, arg2); + testFunctionWithComplexArgs = async (obj: { + name: string; + age: number; + }): Promise => actualExecutionSpy(obj); + + // Create cached versions + cachedTestFunction = useCache(testFunction, cache, { + ...cacheOptions, + functionName: 'testFunction', + }); + + cachedTestFunctionWithArgs = useCache(testFunctionWithArgs, cache, { + ...cacheOptions, + functionName: 'testFunctionWithArgs', + }); + + cachedTestFunctionWithComplexArgs = useCache( + testFunctionWithComplexArgs, + cache, + { + ...cacheOptions, + functionName: 'testFunctionWithComplexArgs', + }, + ); + }); + + describe('when the data is not cached', () => { + it('should cache the result of a function', async () => { + // No cached data + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(cache.get).toHaveBeenCalledTimes(1); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 1000); + }); + }); + + describe('when the data is cached', () => { + it('should return the cached result', async () => { + // Init the cache with some data + jest.spyOn(cache, 'get').mockResolvedValue('test'); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(cache.get).toHaveBeenCalledTimes(1); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('should skip the cache and refresh the result if refreshCache is enabled', async () => { + jest.spyOn(cache, 'get').mockResolvedValue('cached-test'); + + const refreshCachedFunction = useCache(testFunction, cache, { + ...cacheOptions, + refreshCache: true, + }); + + const result = await refreshCachedFunction(); + + expect(result).toBe('test'); + expect(cache.get).not.toHaveBeenCalled(); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 1000); + }); + }); + + describe('error handling', () => { + it('should propagate errors from the original function', async () => { + const error = new Error('Test error'); + actualExecutionSpy.mockRejectedValueOnce(error); + + await expect(cachedTestFunction()).rejects.toThrow('Test error'); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('should handle cache get errors gracefully', async () => { + jest.spyOn(cache, 'get').mockRejectedValueOnce(new Error('Cache error')); + actualExecutionSpy.mockResolvedValueOnce('test'); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 1000); + }); + + it('should handle cache set errors gracefully', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + jest + .spyOn(cache, 'set') + .mockRejectedValueOnce(new Error('Cache set error')); + actualExecutionSpy.mockResolvedValueOnce('test'); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + }); + + it('should log cache errors using the provided logger', async () => { + const errorLogger = { + error: jest.fn(), + }; + + jest + .spyOn(cache, 'get') + .mockRejectedValueOnce(new Error('Cache get error')); + jest + .spyOn(cache, 'set') + .mockRejectedValueOnce(new Error('Cache set error')); + actualExecutionSpy.mockResolvedValueOnce('test'); + + const loggedCachedFunction = useCache(testFunction, cache, { + ...cacheOptions, + logger: errorLogger as never, + }); + + const result = await loggedCachedFunction(); + + expect(result).toBe('test'); + expect(errorLogger.error).toHaveBeenCalledTimes(2); + }); + }); + + describe('different argument types', () => { + it('should handle primitive arguments correctly', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + jest.spyOn(cache, 'set').mockResolvedValueOnce(undefined); + actualExecutionSpy.mockResolvedValueOnce('test with args'); + + const result = await cachedTestFunctionWithArgs('hello', 42); + + expect(result).toBe('test with args'); + expect(cache.get).toHaveBeenCalledWith('testFunctionWithArgs:"hello":42'); + expect(actualExecutionSpy).toHaveBeenCalledWith('hello', 42); + }); + + it('should handle complex object arguments correctly', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + jest.spyOn(cache, 'set').mockResolvedValueOnce(undefined); + const testObj = { name: 'John', age: 30 }; + actualExecutionSpy.mockResolvedValueOnce('test with complex args'); + + const result = await cachedTestFunctionWithComplexArgs(testObj); + + expect(result).toBe('test with complex args'); + expect(cache.get).toHaveBeenCalledWith( + 'testFunctionWithComplexArgs:{"name":"John","age":30}', + ); + expect(actualExecutionSpy).toHaveBeenCalledWith(testObj); + }); + }); + + describe('custom generateCacheKey', () => { + it('should use a custom key generator if provided', async () => { + const customKeyGenerator = jest.fn().mockReturnValue('custom-key'); + + const customCachedFunction = useCache(testFunction, cache, { + ...cacheOptions, + generateCacheKey: customKeyGenerator, + }); + + await customCachedFunction(); + + expect(customKeyGenerator).toHaveBeenCalledTimes(1); + expect(cache.get).toHaveBeenCalledWith('custom-key'); + }); + }); + + describe('anonymous functions', () => { + it('should handle anonymous functions with a default name', async () => { + // Anonymous function with no name + const anonymousFunction = async (): Promise => + actualExecutionSpy(); + Object.defineProperty(anonymousFunction, 'name', { value: null }); + + const cachedAnonymousFunction = useCache(anonymousFunction, cache, { + ttlMilliseconds: 1000, + }); + + await cachedAnonymousFunction(); + + expect(cache.get).toHaveBeenCalledWith('anonymousFunction:'); + }); + }); + + describe('function name override', () => { + it('should use the provided function name if given', async () => { + const cachedWithCustomName = useCache(testFunction, cache, { + ttlMilliseconds: 1000, + functionName: 'customFunctionName', + }); + + await cachedWithCustomName(); + + expect(cache.get).toHaveBeenCalledWith('customFunctionName:'); + }); + }); + + describe('falsy but valid cache values', () => { + it('should handle falsy but valid cache values (false, 0, empty string)', async () => { + // Test with false + jest.spyOn(cache, 'get').mockResolvedValue(false); + let result: unknown = await cachedTestFunction(); + expect(result).toBe(false); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + + // Test with 0 + jest.spyOn(cache, 'get').mockResolvedValue(0); + result = await cachedTestFunction(); + expect(result).toBe(0); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + + // Test with empty string + jest.spyOn(cache, 'get').mockResolvedValue(''); + result = await cachedTestFunction(); + expect(result).toBe(''); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + }); + + it('should execute the function when cache returns undefined', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + actualExecutionSpy.mockResolvedValueOnce('test'); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/snap-networks-utils/src/utils/cache/useCache.ts b/packages/snap-networks-utils/src/utils/cache/useCache.ts new file mode 100644 index 00000000..fd879b1e --- /dev/null +++ b/packages/snap-networks-utils/src/utils/cache/useCache.ts @@ -0,0 +1,117 @@ +/* eslint-disable no-void */ + +import { Logger, LogLevel } from '../logger/Logger'; +import { serialize } from '../serialization/serialization'; +import type { Serializable } from '../serialization/types'; +import type { ICache } from './types'; + +/** + * A logger that discards all messages, used when no logger is provided. + */ +const silentLogger = new Logger({ level: LogLevel.SILENT }); + +/** + * Options for configuring the caching behavior of a function. + */ +export type CacheOptions = { + /** + * The time to live for the cache in milliseconds. + */ + ttlMilliseconds: number; + /** + * Set this if you want to use a custom function name for the cache key. + */ + functionName?: string; + /** + * Optional function to generate the cache key for the function call. + * Defaults to a function that generates the key based on function name and JSON stringified args separated by colons. + */ + generateCacheKey?: (functionName: string, args: Serializable[]) => string; + /** + * Whether to refresh the cache. + * Defaults to false. + */ + refreshCache?: boolean; + /** + * Optional logger for cache errors. Defaults to a silent logger. + */ + logger?: Logger; +}; + +/** + * Default function to generate the cache key for a function call. + * + * @param functionName - The name of the function. + * @param args - The arguments of the function call. + * @returns The cache key. + */ +const defaultGenerateCacheKey = ( + functionName: string, + args: Serializable[], +): string => + `${functionName}:${args.map((arg) => JSON.stringify(serialize(arg))).join(':')}`; + +/** + * Wraps a function with caching behavior. + * + * @template TArgs - Tuple type representing the arguments of the function. + * @template TResult - The return type of the function, must be Serializable. + * @param fn - The asynchronous function to wrap. Must return a Promise. + * @param cache - The cache instance to use. + * @param options - The caching options. + * @param options.ttlMilliseconds - The time to live for the cache in milliseconds. + * @param options.refreshCache - Whether to refresh the cache. + * @param options.functionName - The name of the function. + * @param options.generateCacheKey - Optional function to generate the cache key. + * @param options.logger - Optional logger for cache errors. + * @returns A new asynchronous function with caching behavior. + */ +export const useCache = < + TArgs extends Serializable[], + TResult extends Serializable, +>( + fn: (...args: TArgs) => Promise, + cache: ICache, + { + ttlMilliseconds, + functionName, + generateCacheKey, + refreshCache = false, + logger = silentLogger, + }: CacheOptions, +): ((...args: TArgs) => Promise) => { + // Use provided key generator or default, adapting the default to use the function's name + const _generateCacheKey = generateCacheKey ?? defaultGenerateCacheKey; + + // Get the function name for the default key generator, handle anonymous functions + const _functionName = functionName ?? fn.name ?? 'anonymousFunction'; + + return async (...args: TArgs): Promise => { + const cacheKey = _generateCacheKey(_functionName, args); + // Check if the data is cached + if (!refreshCache) { + try { + const cached = await cache.get(cacheKey); + // Check explicitly for undefined, as null or other falsy values might be valid cache results + if (cached !== undefined) { + // Type assertion because cache stores Serializable, but we expect TResult + return cached as TResult; + } + } catch (error) { + // Log cache get errors but proceed to execute the function + logger.error(`Cache get error for key "${cacheKey}":`, error); + } + } + + // Execute the original function + const result = await fn(...args); + + // Cache the result, handle potential errors silently + // We don't await this, allowing it to happen in the background + void cache.set(cacheKey, result, ttlMilliseconds).catch((error) => { + logger.error(`Cache set error for key "${cacheKey}":`, error); + }); + + return result; + }; +}; diff --git a/packages/snap-networks-utils/src/utils/cache/useCacheUntil.test.ts b/packages/snap-networks-utils/src/utils/cache/useCacheUntil.test.ts new file mode 100644 index 00000000..9fa1c24f --- /dev/null +++ b/packages/snap-networks-utils/src/utils/cache/useCacheUntil.test.ts @@ -0,0 +1,382 @@ +import type { Serializable } from '../serialization/types'; +import type { ICache } from './types'; +import { useCacheUntil } from './useCacheUntil'; +import type { CacheUntilOptions, ResultWithExpiry } from './useCacheUntil'; + +describe('useCacheUntil', () => { + // Spy to check if the original function was executed or not + let actualExecutionSpy: jest.Mock; + + // Mock cache + let cache: ICache; + + // Common cache options + let cacheOptions: CacheUntilOptions; + + // Original test function that returns result with expiry + let testFunction: () => Promise>; + let testFunctionWithArgs: (arg1: string) => Promise>; + + // Cached versions + let cachedTestFunction: () => Promise; + let cachedTestFunctionWithArgs: (arg1: string) => Promise; + + // Mock current time + const mockNow = 1700000000000; // Fixed timestamp for testing + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(mockNow); + + // Reset mocks for each test + actualExecutionSpy = jest.fn().mockResolvedValue({ + result: 'test', + expiresAt: mockNow + 60000, // Expires in 60 seconds + }); + + // Create a mock cache + cache = { + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockResolvedValue(undefined), + } as unknown as ICache; + + // Define common cache options + cacheOptions = { + functionName: 'testFunction', + }; + + // Define original functions + testFunction = async (): Promise> => + actualExecutionSpy(); + testFunctionWithArgs = async ( + arg1: string, + ): Promise> => actualExecutionSpy(arg1); + + // Create cached versions + cachedTestFunction = useCacheUntil(testFunction, cache, { + ...cacheOptions, + functionName: 'testFunction', + }); + + cachedTestFunctionWithArgs = useCacheUntil(testFunctionWithArgs, cache, { + ...cacheOptions, + functionName: 'testFunctionWithArgs', + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('when the data is not cached', () => { + it('caches the result with TTL calculated from expiresAt', async () => { + // No cached data + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + // TTL should be expiresAt - now = 60000 + expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 60000); + }); + + it('uses zero TTL when expiresAt is in the past', async () => { + actualExecutionSpy.mockResolvedValue({ + result: 'test', + expiresAt: mockNow - 1000, // Already expired + }); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + // TTL should be 0 when expiresAt is in the past + expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 0); + }); + }); + + describe('when the data is cached and not expired', () => { + it('returns the cached result without calling the function', async () => { + // First call to populate the cache and expiry map + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + await cachedTestFunction(); + + // Reset mocks + actualExecutionSpy.mockClear(); + jest.spyOn(cache, 'get').mockResolvedValue('cached-test'); + + // Second call within expiry period + const result = await cachedTestFunction(); + + expect(result).toBe('cached-test'); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + expect(cache.set).toHaveBeenCalledTimes(1); // Only from first call + }); + + it('hydrates a still-valid entry from the cache after the wrapper is recreated', async () => { + // Simulate a previous run: an entry is persisted in the cache, but the + // wrapper (and its in-memory expiry map) has just been recreated. + jest.spyOn(cache, 'get').mockResolvedValue('persisted-test'); + + const result = await cachedTestFunction(); + + expect(result).toBe('persisted-test'); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('skips the cache and refreshes the result if refreshCache is enabled', async () => { + jest.spyOn(cache, 'get').mockResolvedValue('cached-test'); + + const refreshCachedFunction = useCacheUntil(testFunction, cache, { + ...cacheOptions, + refreshCache: true, + }); + + const result = await refreshCachedFunction(); + + expect(result).toBe('test'); + expect(cache.get).not.toHaveBeenCalled(); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 60000); + }); + }); + + describe('when the data is cached but expired', () => { + it('fetches fresh data after expiry time has passed', async () => { + // First call to populate the cache + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + await cachedTestFunction(); + + // Advance time past the expiry + jest.setSystemTime(mockNow + 70000); // 70 seconds later + + // Reset mocks for second call + actualExecutionSpy.mockClear(); + actualExecutionSpy.mockResolvedValue({ + result: 'fresh-test', + expiresAt: mockNow + 70000 + 60000, // New expiry + }); + + const result = await cachedTestFunction(); + + expect(result).toBe('fresh-test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('cache key generation', () => { + it('generates cache key with function name and arguments', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + actualExecutionSpy.mockResolvedValue({ + result: 'test with args', + expiresAt: mockNow + 60000, + }); + + await cachedTestFunctionWithArgs('hello'); + + expect(cache.set).toHaveBeenCalledWith( + 'testFunctionWithArgs:"hello"', + 'test with args', + 60000, + ); + }); + + it('uses a custom key generator if provided', async () => { + const customKeyGenerator = jest.fn().mockReturnValue('custom-key'); + + const customCachedFunction = useCacheUntil(testFunction, cache, { + ...cacheOptions, + generateCacheKey: customKeyGenerator, + }); + + await customCachedFunction(); + + expect(customKeyGenerator).toHaveBeenCalledTimes(1); + expect(cache.set).toHaveBeenCalledWith('custom-key', 'test', 60000); + }); + }); + + describe('error handling', () => { + it('propagates errors from the original function', async () => { + const error = new Error('Test error'); + actualExecutionSpy.mockRejectedValueOnce(error); + + await expect(cachedTestFunction()).rejects.toThrow('Test error'); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('handles cache get errors gracefully', async () => { + // First call to populate expiry map + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + await cachedTestFunction(); + + // Reset for second call + actualExecutionSpy.mockClear(); + jest.spyOn(cache, 'get').mockRejectedValueOnce(new Error('Cache error')); + actualExecutionSpy.mockResolvedValue({ + result: 'test', + expiresAt: mockNow + 60000, + }); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + }); + + it('handles cache set errors gracefully', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + jest + .spyOn(cache, 'set') + .mockRejectedValueOnce(new Error('Cache set error')); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + }); + + it('logs cache errors using the provided logger', async () => { + const errorLogger = { + error: jest.fn(), + }; + + jest + .spyOn(cache, 'get') + .mockRejectedValueOnce(new Error('Cache get error')); + jest + .spyOn(cache, 'set') + .mockRejectedValueOnce(new Error('Cache set error')); + + const loggedCachedFunction = useCacheUntil(testFunction, cache, { + ...cacheOptions, + logger: errorLogger as never, + }); + + const result = await loggedCachedFunction(); + + expect(result).toBe('test'); + expect(errorLogger.error).toHaveBeenCalledTimes(2); + }); + }); + + describe('anonymous functions', () => { + it('handles anonymous functions with a default name', async () => { + const anonymousFunction = async (): Promise> => + actualExecutionSpy(); + Object.defineProperty(anonymousFunction, 'name', { value: null }); + + const cachedAnonymousFunction = useCacheUntil(anonymousFunction, cache, { + // No functionName provided + }); + + await cachedAnonymousFunction(); + + expect(cache.set).toHaveBeenCalledWith( + 'anonymousFunction:', + 'test', + 60000, + ); + }); + }); + + describe('function name override', () => { + it('uses the provided function name if given', async () => { + const cachedWithCustomName = useCacheUntil(testFunction, cache, { + functionName: 'customFunctionName', + }); + + await cachedWithCustomName(); + + expect(cache.set).toHaveBeenCalledWith( + 'customFunctionName:', + 'test', + 60000, + ); + }); + }); + + describe('falsy but valid cache values', () => { + it('handles falsy but valid cache values (false, 0, empty string)', async () => { + // First call to populate expiry map with false result + actualExecutionSpy.mockResolvedValue({ + result: false, + expiresAt: mockNow + 60000, + }); + await cachedTestFunction(); + + // Reset and set cache to return false + actualExecutionSpy.mockClear(); + jest.spyOn(cache, 'get').mockResolvedValue(false); + + const result: unknown = await cachedTestFunction(); + + expect(result).toBe(false); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + }); + + it('executes the function when cache returns undefined', async () => { + // First call to populate expiry map + await cachedTestFunction(); + + // Reset for second call with undefined cache + actualExecutionSpy.mockClear(); + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + actualExecutionSpy.mockResolvedValue({ + result: 'fresh', + expiresAt: mockNow + 60000, + }); + + const result = await cachedTestFunction(); + + expect(result).toBe('fresh'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('maintenance-aligned caching scenario', () => { + it('caches until exact maintenance time and refetches after', async () => { + const maintenanceTime = mockNow + 6 * 60 * 60 * 1000; // 6 hours from now + + actualExecutionSpy.mockResolvedValue({ + result: { energyFee: 420, transactionFee: 1000 }, + expiresAt: maintenanceTime, + }); + + // First call - should fetch and cache + await cachedTestFunction(); + + expect(cache.set).toHaveBeenCalledWith( + 'testFunction:', + { energyFee: 420, transactionFee: 1000 }, + 6 * 60 * 60 * 1000, // 6 hours TTL + ); + + // Advance time to just before maintenance + jest.setSystemTime(maintenanceTime - 1000); + actualExecutionSpy.mockClear(); + jest + .spyOn(cache, 'get') + .mockResolvedValue({ energyFee: 420, transactionFee: 1000 }); + + await cachedTestFunction(); + expect(actualExecutionSpy).not.toHaveBeenCalled(); // Still using cache + + // Advance time past maintenance + jest.setSystemTime(maintenanceTime + 1000); + actualExecutionSpy.mockResolvedValue({ + result: { energyFee: 500, transactionFee: 1200 }, // New values + expiresAt: maintenanceTime + 6 * 60 * 60 * 1000, // Next maintenance + }); + + const freshResult = await cachedTestFunction(); + + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); // Fetched fresh + expect(freshResult).toStrictEqual({ + energyFee: 500, + transactionFee: 1200, + }); + }); + }); +}); diff --git a/packages/snap-networks-utils/src/utils/cache/useCacheUntil.ts b/packages/snap-networks-utils/src/utils/cache/useCacheUntil.ts new file mode 100644 index 00000000..55ae3768 --- /dev/null +++ b/packages/snap-networks-utils/src/utils/cache/useCacheUntil.ts @@ -0,0 +1,136 @@ +import { Logger, LogLevel } from '../logger/Logger'; +import { serialize } from '../serialization/serialization'; +import type { Serializable } from '../serialization/types'; +import type { ICache } from './types'; + +/** + * A logger that discards all messages, used when no logger is provided. + */ +const silentLogger = new Logger({ level: LogLevel.SILENT }); + +/** + * Result type for functions that provide their own expiry time. + */ +export type ResultWithExpiry = { + result: TResult; + expiresAt: number; // Unix timestamp in milliseconds +}; + +/** + * Options for configuring the caching behavior of a function with dynamic expiry. + */ +export type CacheUntilOptions = { + /** + * Set this if you want to use a custom function name for the cache key. + */ + functionName?: string; + /** + * Optional function to generate the cache key for the function call. + * Defaults to a function that generates the key based on function name and JSON stringified args separated by colons. + */ + generateCacheKey?: (functionName: string, args: Serializable[]) => string; + /** + * Whether to refresh the cache. + * Defaults to false. + */ + refreshCache?: boolean; + /** + * Optional logger for cache errors. Defaults to a silent logger. + */ + logger?: Logger; +}; + +/** + * Default function to generate the cache key for a function call. + * + * @param functionName - The name of the function. + * @param args - The arguments of the function call. + * @returns The cache key. + */ +const defaultGenerateCacheKey = ( + functionName: string, + args: Serializable[], +): string => + `${functionName}:${args.map((arg) => JSON.stringify(serialize(arg))).join(':')}`; + +/** + * Wraps an async function with caching behavior where expiry is determined + * by the function result itself (dynamic TTL). + * + * Unlike `useCache` which uses a fixed TTL, this utility allows the wrapped + * function to specify when its result expires. This is useful for caching + * data that has known invalidation points (e.g., blockchain maintenance periods). + * + * @template TArgs - Tuple type representing the arguments of the function. + * @template TResult - The return type of the function, must be Serializable. + * @param fn - The asynchronous function to wrap. Must return a Promise>. + * @param cache - The cache instance to use. + * @param options - The caching options. + * @param options.refreshCache - Whether to refresh the cache. + * @param options.functionName - The name of the function. + * @param options.generateCacheKey - Optional function to generate the cache key. + * @param options.logger - Optional logger for cache errors. + * @returns A new asynchronous function with caching behavior. + */ +export const useCacheUntil = < + TArgs extends Serializable[], + TResult extends Serializable, +>( + fn: (...args: TArgs) => Promise>, + cache: ICache, + { + functionName, + generateCacheKey, + refreshCache = false, + logger = silentLogger, + }: CacheUntilOptions, +): ((...args: TArgs) => Promise) => { + // Use provided key generator or default, adapting the default to use the function's name + const _generateCacheKey = generateCacheKey ?? defaultGenerateCacheKey; + + // Get the function name for the default key generator, handle anonymous functions + const _functionName = functionName ?? fn.name ?? 'anonymousFunction'; + + // Map to track expiry timestamps for each cache key + const expiryMap = new Map(); + + return async (...args: TArgs): Promise => { + const cacheKey = _generateCacheKey(_functionName, args); + const now = Date.now(); + + // Check if cached and not expired. + // The cache owns the persisted TTL. When this wrapper is recreated after a + // Snap restart, expiryMap is empty, so consult the cache to hydrate a still + // valid entry instead of fetching it again. + const expiresAt = expiryMap.get(cacheKey); + if (!refreshCache && (expiresAt === undefined || now < expiresAt)) { + try { + const cached = await cache.get(cacheKey); + // Check explicitly for undefined, as null or other falsy values might be valid cache results + if (cached !== undefined) { + // Type assertion because cache stores Serializable, but we expect TResult + return cached as TResult; + } + } catch (error) { + // Log cache get errors but proceed to execute the function + logger.error(`Cache get error for key "${cacheKey}":`, error); + } + } + + // Execute the original function to get result and new expiry + const { result, expiresAt: newExpiresAt } = await fn(...args); + + // Calculate TTL from expiry timestamp + const ttlMilliseconds = Math.max(0, newExpiresAt - now); + + // Store result in cache with calculated TTL + await cache.set(cacheKey, result, ttlMilliseconds).catch((error) => { + logger.error(`Cache set error for key "${cacheKey}":`, error); + }); + + // Store expiry timestamp + expiryMap.set(cacheKey, newExpiresAt); + + return result; + }; +}; From 5d9b2a9919d9969e58d9d2b677195245f252f3db Mon Sep 17 00:00:00 2001 From: Andrew Taran Date: Tue, 8 Sep 2026 16:05:46 +0200 Subject: [PATCH 2/2] chore: stellar use shared cache utils --- eslint-suppressions.json | 20 - packages/stellar-wallet-snap/src/context.ts | 2 +- .../handlers/cronjob/trackTransaction.test.ts | 2 +- .../__mocks__/assets.fixtures.ts | 3 +- .../src/services/cache/InMemoryCache.ts | 180 ---- .../src/services/cache/InMemoryState.ts | 46 - .../src/services/cache/StateCache.test.ts | 807 ------------------ .../src/services/cache/StateCache.ts | 269 ------ .../src/services/cache/api.ts | 109 --- .../src/services/cache/index.ts | 5 - .../src/services/cache/useCache.test.ts | 249 ------ .../src/services/cache/useCache.ts | 109 --- .../src/services/cache/useCacheUntil.test.ts | 331 ------- .../src/services/cache/useCacheUntil.ts | 122 --- .../services/network/NetworkService.test.ts | 2 +- .../src/services/network/NetworkService.ts | 9 +- .../__mocks__/onChainAccount.fixtures.ts | 2 +- .../src/services/price/PriceService.test.ts | 2 +- .../src/services/price/PriceService.ts | 7 +- .../TransactionSynchronizeService.test.ts | 2 +- .../__mocks__/transaction.fixtures.ts | 2 +- .../__mocks__/cache.fixtures.ts | 4 +- 22 files changed, 21 insertions(+), 2263 deletions(-) delete mode 100644 packages/stellar-wallet-snap/src/services/cache/InMemoryCache.ts delete mode 100644 packages/stellar-wallet-snap/src/services/cache/InMemoryState.ts delete mode 100644 packages/stellar-wallet-snap/src/services/cache/StateCache.test.ts delete mode 100644 packages/stellar-wallet-snap/src/services/cache/StateCache.ts delete mode 100644 packages/stellar-wallet-snap/src/services/cache/api.ts delete mode 100644 packages/stellar-wallet-snap/src/services/cache/index.ts delete mode 100644 packages/stellar-wallet-snap/src/services/cache/useCache.test.ts delete mode 100644 packages/stellar-wallet-snap/src/services/cache/useCache.ts delete mode 100644 packages/stellar-wallet-snap/src/services/cache/useCacheUntil.test.ts delete mode 100644 packages/stellar-wallet-snap/src/services/cache/useCacheUntil.ts rename packages/stellar-wallet-snap/src/{services/cache => utils}/__mocks__/cache.fixtures.ts (93%) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 1250d59a..9e6248fa 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1354,26 +1354,6 @@ "count": 2 } }, - "packages/stellar-wallet-snap/src/services/cache/StateCache.test.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 1 - } - }, - "packages/stellar-wallet-snap/src/services/cache/StateCache.ts": { - "no-restricted-syntax": { - "count": 1 - } - }, - "packages/stellar-wallet-snap/src/services/cache/useCache.test.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 4 - } - }, - "packages/stellar-wallet-snap/src/services/cache/useCacheUntil.test.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 2 - } - }, "packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 4 diff --git a/packages/stellar-wallet-snap/src/context.ts b/packages/stellar-wallet-snap/src/context.ts index d29ec967..cd09e02d 100644 --- a/packages/stellar-wallet-snap/src/context.ts +++ b/packages/stellar-wallet-snap/src/context.ts @@ -1,3 +1,4 @@ +import { InMemoryCache } from '@metamask/snap-networks-utils'; import { assert, object } from '@metamask/superstruct'; import { AppConfig } from './config'; @@ -38,7 +39,6 @@ import { AssetMetadataRepository, AssetMetadataService, } from './services/asset-metadata'; -import { InMemoryCache } from './services/cache'; import { NetworkService } from './services/network'; import { OnChainAccountRepository, diff --git a/packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts b/packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts index 6e267198..566d276e 100644 --- a/packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts @@ -1,11 +1,11 @@ import { TransactionStatus } from '@metamask/keyring-api'; +import { InMemoryCache } from '@metamask/snap-networks-utils'; import { KnownCaip2ChainId } from '../../api'; import { AppConfig } from '../../config'; import { KEYRING_ACCOUNT_TYPE, METAMASK_ORIGIN } from '../../constants'; import { AccountService } from '../../services/account'; import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; -import { InMemoryCache } from '../../services/cache'; import { NetworkService, NetworkServiceException, diff --git a/packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts b/packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts index 9fcef6a6..5b80152c 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts @@ -1,9 +1,10 @@ +import { InMemoryCache } from '@metamask/snap-networks-utils'; + import type { KnownCaip19AssetIdOrSlip44Id } from '../../../api'; import { AssetType, KnownCaip2ChainId } from '../../../api'; import { NATIVE_ASSET_NAME, NATIVE_ASSET_SYMBOL } from '../../../constants'; import { getSlip44AssetId } from '../../../utils/caip'; import { logger, noOpLogger } from '../../../utils/logger'; -import { InMemoryCache } from '../../cache'; import { NetworkService } from '../../network'; import { State } from '../../state'; import type { AssetMetadataByAssetId, StellarAssetMetadata } from '../api'; diff --git a/packages/stellar-wallet-snap/src/services/cache/InMemoryCache.ts b/packages/stellar-wallet-snap/src/services/cache/InMemoryCache.ts deleted file mode 100644 index 8b1c28dc..00000000 --- a/packages/stellar-wallet-snap/src/services/cache/InMemoryCache.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { Logger, Serializable } from '@metamask/snap-networks-utils'; -import { assert } from '@metamask/utils'; - -import type { ICache, CacheEntry } from './api'; - -/** - * A simple in-memory cache implementation supporting TTL (Time To Live) functionality. - * - * WARNINGS: - * - This cache is not persistent and will be lost when the process is restarted. - */ -export class InMemoryCache implements ICache { - readonly #cache: Map = new Map(); - - readonly #logger: Logger; - - constructor(logger: Logger) { - this.#logger = logger; - } - - #validateTtlOrThrow(ttlMilliseconds?: number): void { - if (ttlMilliseconds === undefined) { - return; - } - - if (typeof ttlMilliseconds !== 'number') { - throw new Error('TTL must be a number'); - } - - if (ttlMilliseconds < 0) { - throw new Error('TTL must be positive'); - } - - if (ttlMilliseconds > Number.MAX_SAFE_INTEGER) { - throw new Error('TTL must be less than 2^53 - 1'); - } - } - - #isExpired(cacheEntry: CacheEntry): boolean { - return cacheEntry.expiresAt < Date.now(); - } - - async #cleanupExpiredEntries(): Promise { - const expiredKeys: string[] = []; - for (const [key, entry] of this.#cache.entries()) { - if (this.#isExpired(entry)) { - expiredKeys.push(key); - } - } - await this.mdelete(expiredKeys); - } - - async get(key: string): Promise { - const result = await this.mget([key]); - return result[key]; - } - - async set( - key: string, - value: Serializable, - ttlMilliseconds = Number.MAX_SAFE_INTEGER, - ): Promise { - this.#validateTtlOrThrow(ttlMilliseconds); - - this.#cache.set(key, { - value, - expiresAt: Math.min( - Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), - Number.MAX_SAFE_INTEGER, - ), - }); - } - - async delete(key: string): Promise { - const result = await this.mdelete([key]); - return result[key] ?? false; - } - - async clear(): Promise { - this.#cache.clear(); - } - - async has(key: string): Promise { - const cacheEntry = this.#cache.get(key); - if (!cacheEntry) { - return false; - } - - if (this.#isExpired(cacheEntry)) { - this.#cache.delete(key); - return false; - } - - return true; - } - - async keys(): Promise { - await this.#cleanupExpiredEntries(); - return Array.from(this.#cache.keys()); - } - - async size(): Promise { - await this.#cleanupExpiredEntries(); - return this.#cache.size; - } - - async peek(key: string): Promise { - const cacheEntry = this.#cache.get(key); - if (!cacheEntry) { - return undefined; - } - - if (this.#isExpired(cacheEntry)) { - this.#cache.delete(key); - return undefined; - } - - return cacheEntry.value; - } - - async mget( - keys: string[], - ): Promise> { - await this.#cleanupExpiredEntries(); - - const result: Record = {}; - - for (const key of keys) { - const cacheEntry = this.#cache.get(key); - if (!cacheEntry) { - this.#logger.info(`[InMemoryCache] ❌ Cache miss for key "${key}"`); - result[key] = undefined; - continue; - } - - this.#logger.info(`[InMemoryCache] 🎉 Cache hit for key "${key}"`); - result[key] = cacheEntry.value; - } - - return result; - } - - async mset( - entries: { key: string; value: Serializable; ttlMilliseconds?: number }[], - ): Promise { - if (entries.length === 0) { - return; - } - - if (entries.length === 1) { - assert(entries[0]); // Enforce type narrowing as TS cannot infer that entries[0] is defined - const { key, value, ttlMilliseconds } = entries[0]; - await this.set(key, value, ttlMilliseconds); - return; - } - - entries.forEach(({ ttlMilliseconds }) => { - this.#validateTtlOrThrow(ttlMilliseconds); - }); - - entries.forEach(({ key, value, ttlMilliseconds }) => { - if (value === undefined) { - return; - } - this.#cache.set(key, { - value, - expiresAt: Math.min( - Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), - Number.MAX_SAFE_INTEGER, - ), - }); - }); - } - - async mdelete(keys: string[]): Promise> { - return Object.fromEntries( - keys.map((key) => [key, this.#cache.delete(key)]), - ); - } -} diff --git a/packages/stellar-wallet-snap/src/services/cache/InMemoryState.ts b/packages/stellar-wallet-snap/src/services/cache/InMemoryState.ts deleted file mode 100644 index 02513d0f..00000000 --- a/packages/stellar-wallet-snap/src/services/cache/InMemoryState.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Serializable } from '@metamask/snap-networks-utils'; -import { get, set, unset } from 'lodash'; - -import type { IStateManager } from '../state/IStateManager'; - -/** - * A simple implementation of the `IStateManager` interface that relies on an in memory state that can be used for testing purposes. - */ -export class InMemoryState< - TStateValue extends Record, -> implements IStateManager { - #state: TStateValue; - - constructor(initialState: TStateValue) { - this.#state = initialState; - } - - async get(): Promise { - return this.#state; - } - - async getKey( - key: string, - ): Promise { - const value = get(this.#state, key); - - return value as TResponse | undefined; - } - - async setKey(key: string, value: Serializable): Promise { - set(this.#state, key, value); // Use lodash to set the value using a json path - } - - async update( - callback: (state: TStateValue) => TStateValue, - ): Promise { - this.#state = callback(this.#state); - - return this.#state; - } - - async deleteKey(key: string): Promise { - // Using lodash's unset to leverage the json path capabilities - unset(this.#state, key); - } -} diff --git a/packages/stellar-wallet-snap/src/services/cache/StateCache.test.ts b/packages/stellar-wallet-snap/src/services/cache/StateCache.test.ts deleted file mode 100644 index 5743cd7f..00000000 --- a/packages/stellar-wallet-snap/src/services/cache/StateCache.test.ts +++ /dev/null @@ -1,807 +0,0 @@ -/* eslint-disable jest/prefer-strict-equal */ - -import { logger } from '../../utils/logger'; -import type { IStateManager } from '../state'; -import { InMemoryState } from './InMemoryState'; -import type { StateValue } from './StateCache'; -import { StateCache } from './StateCache'; - -jest.mock('../../utils/logger'); - -describe('StateCache', () => { - const createStateCache = (state: IStateManager) => - new StateCache(state, logger); - - describe('constructor', () => { - it('uses the default prefix if not specified', () => { - const cache = new StateCache(new InMemoryState({}), logger); - - expect(cache.prefix).toBe('__cache__default'); - }); - - it('uses the specified prefix if provided', () => { - const cache = new StateCache( - new InMemoryState({}), - logger, - '__cache__my-prefix', - ); - - expect(cache.prefix).toBe('__cache__my-prefix'); - }); - }); - - describe('get', () => { - it('returns undefined if the cache is not initialized', async () => { - const stateWithNoCache = new InMemoryState({ - name: 'John', // State has some data that is not related to the cache - // __cache__default: {} // State has not been initialized with cached data - }); - const cache = new StateCache(stateWithNoCache, logger); - - const value = await cache.get('someKey'); - - expect(value).toBeUndefined(); - }); - - it('returns undefined if the cache is initialized but the key is not present', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: 1704067200000, // January 1, 2024 - }, - }, - }); - const cache = createStateCache(stateWithCache); - - const value = await cache.get('someOtherKey'); - - expect(value).toBeUndefined(); - }); - - it('returns the cached value if the cache is initialized and the key is present and not expired', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: Number.MAX_SAFE_INTEGER, // Expires in a long time - }, - }, - }); - const cache = createStateCache(stateWithCache); - - const value = await cache.get('someKey'); - - expect(value).toBe('someValue'); - }); - - it('returns undefined if the cache is initialized and the key is present but expired', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: 1704067200000, // January 1, 2024 - }, - }, - }); - const cache = createStateCache(stateWithCache); - - const value = await cache.get('someKey'); - - expect(value).toBeUndefined(); - }); - - it('deletes expired cache entries upon retrieval', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: 1704067200000, // January 1, 2024 - }, - }, - }); - const cache = createStateCache(stateWithCache); - - await cache.get('someKey'); - const stateValue = await stateWithCache.get(); - - expect(stateValue).toStrictEqual({ - __cache__default: {}, - }); - }); - }); - - describe('set', () => { - it('initializes the cache if it is not initialized', async () => { - const stateWithCache = new InMemoryState({}); - const cache = createStateCache(stateWithCache); - - await cache.set('someKey', 'someValue'); - const stateValue = await stateWithCache.get(); - - expect(stateValue).toStrictEqual({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - }, - }); - }); - - it('sets the cache entry with no expiration if no ttl is provided', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: {}, - }); - const cache = createStateCache(stateWithCache); - - await cache.set('someKey', 'someValue'); - const stateValue = await stateWithCache.get(); - - const value = await cache.get('someKey'); - - expect(value).toBe('someValue'); - expect(stateValue).toStrictEqual({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - }, - }); - }); - - it('overwrites the cache entry if it is present', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - }, - }); - const cache = createStateCache(stateWithCache); - - await cache.set('someKey', 'someOtherValue'); - const stateValue = await stateWithCache.get(); - - expect(stateValue).toStrictEqual({ - __cache__default: { - someKey: { - value: 'someOtherValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - }, - }); - }); - - it('sets the cache entry with the provided ttl', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: {}, - }); - const cache = createStateCache(stateWithCache); - jest.spyOn(Date, 'now').mockReturnValueOnce(1704067200000); // January 1, 2024 - - await cache.set('someKey', 'someValue', 1000); - const stateValue = await stateWithCache.get(); - - expect(stateValue).toStrictEqual({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: 1704067201000, // January 1, 2024 + 1 second - }, - }, - }); - }); - - it('supports a ttl of 0', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: {}, - }); - const cache = createStateCache(stateWithCache); - const mockDateNow = jest - .spyOn(Date, 'now') - .mockReturnValue(1704067200000); // January 1, 2024 - - await cache.set('someKey', 'someValue', 0); - const stateValue = await stateWithCache.get(); - - expect(stateValue).toStrictEqual({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: 1704067200000, // January 1, 2024 (+ 0 seconds) - }, - }, - }); - - // Change the mock to return a time after the expiration - mockDateNow.mockReturnValue(1704067200001); // January 1, 2024 + 1 millisecond - - const value = await cache.get('someKey'); // Should expire immediately - expect(value).toBeUndefined(); - - mockDateNow.mockRestore(); - }); - - it('throws an error if the ttl is not a number', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: {}, - }); - const cache = createStateCache(stateWithCache); - - await expect( - cache.set('someKey', 'someValue', 'not a number' as unknown as number), - ).rejects.toThrow('TTL must be a number'); - }); - - it('throws an error if the ttl is negative', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: {}, - }); - const cache = createStateCache(stateWithCache); - - await expect(cache.set('someKey', 'someValue', -1)).rejects.toThrow( - 'TTL must be positive', - ); - }); - - it('throws an error if the ttl is too large', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: {}, - }); - const cache = createStateCache(stateWithCache); - - await expect( - cache.set('someKey', 'someValue', Number.MAX_SAFE_INTEGER + 1), - ).rejects.toThrow('TTL must be less than 2^53 - 1'); - }); - }); - - describe('delete', () => { - it('deletes the cache entry and returns true if the entry was present', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - }, - }); - const cache = createStateCache(stateWithCache); - - const result = await cache.delete('someKey'); - expect(result).toBe(true); - - const value = await cache.get('someKey'); - - expect(value).toBeUndefined(); - }); - - it('leaves the cache unchanged and returns false if the entry was not present', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - }, - }); - const cache = createStateCache(stateWithCache); - - const result = await cache.delete('someOtherKey'); // Try to - const someKeyValue = await cache.get('someKey'); - const someOtherKeyValue = await cache.get('someOtherKey'); - - expect(result).toBe(false); - expect(someKeyValue).toBe('someValue'); - expect(someOtherKeyValue).toBeUndefined(); - }); - }); - - describe('clear', () => { - it('empties the cache', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: Number.MAX_SAFE_INTEGER, - createdAt: 1704067200000, // January 1, 2024 - }, - }, - }); - const cache = createStateCache(stateWithCache); - - await cache.clear(); - const stateValue = await stateWithCache.get(); - - expect(stateValue).toStrictEqual({ - __cache__default: {}, - }); - }); - - it('does not throw an error if the cache is not initialized', async () => { - const stateWithCache = new InMemoryState({}); - const cache = createStateCache(stateWithCache); - - await cache.clear(); - const stateValue = await stateWithCache.get(); - - expect(stateValue).toStrictEqual({ - __cache__default: {}, - }); - }); - }); - - describe('has', () => { - it('returns true if the key is present in the cache', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - }, - }); - const cache = createStateCache(stateWithCache); - - const result = await cache.has('someKey'); - - expect(result).toBe(true); - }); - - it('returns false if the key is not present in the cache', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - }, - }); - const cache = createStateCache(stateWithCache); - - const result = await cache.has('someOtherKey'); - expect(result).toBe(false); - }); - - it('does not throw an error if the cache is not initialized', async () => { - const stateWithCache = new InMemoryState({}); - const cache = createStateCache(stateWithCache); - - const result = await cache.has('someKey'); - expect(result).toBe(false); - }); - }); - - describe('keys', () => { - it('returns all keys in the cache', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - someOtherKey: { - value: 'someOtherValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - }, - }); - const cache = createStateCache(stateWithCache); - - const result = await cache.keys(); - - expect(result).toStrictEqual(['someKey', 'someOtherKey']); - }); - - it('returns an empty array if the cache is not initialized', async () => { - const stateWithCache = new InMemoryState({}); - const cache = createStateCache(stateWithCache); - - const result = await cache.keys(); - - expect(result).toStrictEqual([]); - }); - }); - - describe('size', () => { - it('returns the number of items in the cache', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - someOtherKey: { - value: 'someOtherValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - }, - }); - const cache = createStateCache(stateWithCache); - - const result = await cache.size(); - - expect(result).toBe(2); - }); - - it('returns 0 if the cache is not initialized', async () => { - const stateWithCache = new InMemoryState({}); - const cache = createStateCache(stateWithCache); - - const result = await cache.size(); - - expect(result).toBe(0); - }); - }); - - describe('peek', () => { - it('returns the value of an unexpired key if it is present in the cache', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - }, - }); - const cache = createStateCache(stateWithCache); - - const result = await cache.peek('someKey'); - - expect(result).toBe('someValue'); - }); - - it('returns the value of an expired key if it is present in the cache', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: 1704067200000, // January 1, 2024 - }, - }, - }); - const cache = createStateCache(stateWithCache); - - const result = await cache.peek('someKey'); - - expect(result).toBe('someValue'); - }); - - it('returns undefined if the key is not present in the cache', async () => { - const stateWithCache = new InMemoryState({}); - const cache = createStateCache(stateWithCache); - - const result = await cache.peek('someOtherKey'); - - expect(result).toBeUndefined(); - }); - - it('does not throw an error if the cache is not initialized', async () => { - const stateWithCache = new InMemoryState({}); - const cache = createStateCache(stateWithCache); - - const result = await cache.peek('someKey'); - expect(result).toBeUndefined(); - }); - }); - - describe('mget', () => { - it('returns the values of the keys if they are present in the cache', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - someOtherKey: { - value: 'someOtherValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - }, - }); - const cache = createStateCache(stateWithCache); - - const result = await cache.mget(['someKey', 'someOtherKey']); - - expect(result).toStrictEqual({ - someKey: 'someValue', - someOtherKey: 'someOtherValue', - }); - }); - - it('returns undefined for keys that are not present in the cache', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - }, - }); - const cache = createStateCache(stateWithCache); - - const result = await cache.mget(['someKey', 'someOtherKey']); - - expect(result).toEqual({ - someKey: 'someValue', - someOtherKey: undefined, - }); - }); - - it('returns undefined for keys that are expired', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: 1704067200000, // January 1, 2024 - }, - }, - }); - const cache = createStateCache(stateWithCache); - - // Mock Date.now to return a time after the expiration - const mockDateNow = jest - .spyOn(Date, 'now') - .mockReturnValue(1704067200001); // January 1, 2024 + 1 millisecond - - const result = await cache.mget(['someKey']); - - expect(result).toEqual({ - someKey: undefined, - }); - - mockDateNow.mockRestore(); - }); - - it('returns an empty object if the cache is not initialized', async () => { - const stateWithCache = new InMemoryState({}); - const cache = createStateCache(stateWithCache); - - const result = await cache.mget(['someKey', 'someOtherKey']); - - expect(result).toStrictEqual({}); - }); - - it('deletes expired cache entries upon retrieval', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: 1704067200000, // January 1, 2024 - }, - someOtherKey: { - value: 'someOtherValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - }, - }); - const cache = createStateCache(stateWithCache); - - // Mock Date.now to return a time after the expiration - const mockDateNow = jest - .spyOn(Date, 'now') - .mockReturnValue(1704067200001); // January 1, 2024 + 1 millisecond - - await cache.mget(['someKey']); - const stateValue = await stateWithCache.get(); - - expect(stateValue).toStrictEqual({ - __cache__default: { - someOtherKey: { - value: 'someOtherValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - }, - }); - - mockDateNow.mockRestore(); - }); - }); - - describe('mset', () => { - it('sets the values of the keys if they are present in the cache', async () => { - const stateWithCache = new InMemoryState({}); - const cache = createStateCache(stateWithCache); - - await cache.mset([ - { key: 'someKey', value: 'someValue' }, - { key: 'someOtherKey', value: 'someOtherValue' }, - ]); - - const result = await cache.mget(['someKey', 'someOtherKey']); - - expect(result).toStrictEqual({ - someKey: 'someValue', - someOtherKey: 'someOtherValue', - }); - }); - - it('does not store undefined values in the cache', async () => { - const stateWithCache = new InMemoryState({}); - const cache = createStateCache(stateWithCache); - - await cache.mset([ - { key: 'someKey', value: 'someValue' }, - { key: 'undefinedKey', value: undefined }, - ]); - - const result = await cache.mget(['someKey', 'undefinedKey']); - - expect(result).toEqual({ - someKey: 'someValue', - undefinedKey: undefined, - }); - - // Verify the undefined value was not stored in the cache - const stateValue = await stateWithCache.get(); - expect(stateValue).toStrictEqual({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - }, - }); - }); - - it('stores null values in the cache', async () => { - const stateWithCache = new InMemoryState({}); - const cache = createStateCache(stateWithCache); - - await cache.mset([{ key: 'someKey', value: null }]); - - const result = await cache.mget(['someKey']); - - expect(result).toStrictEqual({ - someKey: null, - }); - }); - - it('does not throw an error if the cache is not initialized', async () => { - const stateWithCache = new InMemoryState({}); - const cache = createStateCache(stateWithCache); - - await cache.mset([{ key: 'someKey', value: 'someValue' }]); - - const result = await cache.mget(['someKey']); - - expect(result).toStrictEqual({ - someKey: 'someValue', - }); - }); - - it('throws an error if the ttl is invalid', async () => { - const stateWithCache = new InMemoryState({}); - const cache = createStateCache(stateWithCache); - - await expect( - cache.mset([ - { - key: 'someKey', - value: 'someValue', - ttlMilliseconds: 'not a number' as unknown as number, - }, - ]), - ).rejects.toThrow('TTL must be a number'); - }); - - it('does not affect other keys in the cache', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey0: { - value: 'someValue0', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - someKey1: { - value: 'someValue1', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - }, - }); - const cache = createStateCache(stateWithCache); - - await cache.mset([ - { key: 'someKey0', value: 'someValue0Overwritten' }, - { key: 'someKey2', value: 'someValue2' }, - ]); - - const result = await cache.mget(['someKey0', 'someKey1', 'someKey2']); - - expect(result).toStrictEqual({ - someKey0: 'someValue0Overwritten', - someKey1: 'someValue1', - someKey2: 'someValue2', - }); - }); - - it('no-ops if no entries are provided', async () => { - const stateWithCache = new InMemoryState({}); - const cache = createStateCache(stateWithCache); - const updateSpy = jest.spyOn(stateWithCache, 'update'); - - await cache.mset([]); - - expect(updateSpy).not.toHaveBeenCalled(); - }); - - it('defers to set if there is only one entry', async () => { - const stateWithCache = new InMemoryState({}); - const cache = createStateCache(stateWithCache); - const setSpy = jest.spyOn(cache, 'set'); - - const singleEntry = { - key: 'someKey', - value: 'someValue', - ttlMilliseconds: 1000, - }; - await cache.mset([singleEntry]); - - expect(setSpy).toHaveBeenCalledWith( - singleEntry.key, - singleEntry.value, - singleEntry.ttlMilliseconds, - ); - }); - }); - - describe('mdelete', () => { - it('deletes the keys from the cache', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - someOtherKey: { - value: 'someOtherValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - }, - }); - const cache = createStateCache(stateWithCache); - - await cache.mdelete(['someKey', 'someOtherKey']); - - const result = await cache.mget(['someKey', 'someOtherKey']); - - expect(result).toEqual({ - someKey: undefined, - someOtherKey: undefined, - }); - }); - - it('returns an object where the values are true if the keys were deleted and false if they were not present', async () => { - const stateWithCache = new InMemoryState({ - __cache__default: { - someKey: { - value: 'someValue', - expiresAt: Number.MAX_SAFE_INTEGER, - }, - }, - }); - const cache = createStateCache(stateWithCache); - - const result = await cache.mdelete(['someKey', 'someOtherKey']); - - expect(result).toStrictEqual({ - someKey: true, - someOtherKey: false, - }); - }); - - it('does not throw an error if the cache is not initialized', async () => { - const stateWithCache = new InMemoryState({}); - const cache = createStateCache(stateWithCache); - - const result = await cache.mdelete(['someKey', 'someOtherKey']); - - expect(result).toStrictEqual({ - someKey: false, - someOtherKey: false, - }); - }); - }); -}); diff --git a/packages/stellar-wallet-snap/src/services/cache/StateCache.ts b/packages/stellar-wallet-snap/src/services/cache/StateCache.ts deleted file mode 100644 index 7858ac5f..00000000 --- a/packages/stellar-wallet-snap/src/services/cache/StateCache.ts +++ /dev/null @@ -1,269 +0,0 @@ -import type { Logger, Serializable } from '@metamask/snap-networks-utils'; -import { assert } from '@metamask/utils'; - -import type { IStateManager } from '../state/IStateManager'; -import type { ICache, CacheEntry } from './api'; - -/** - * The whole cache store. - */ -export type CacheStore = Record | undefined; - -/** - * A prefix for the cache "location" in the state. Enforced to start with `__cache__` to avoid collisions with other state values. - */ -export type CachePrefix = `__cache__${string}`; - -/** - * Describes the shape of the whole state inside which the cache is stored. - */ -export type StateValue = { - [x: string]: Serializable; -} & { - [K in CachePrefix]?: CacheStore; -}; - -/** - * A cache that wraps any implementation of the `IStateManager` interface to store the cache. - * - * It is intended to be used with the snap's `State` class, but can be used with any other implementation of the `IStateManager` interface. For instance it can be used with the `InMemoryState` class for testing purposes. - * - * By default, it stores its data in the `__cache__default` property of the state, but you can specify any other prefix you want, provided it starts with `__cache__` to avoid collisions with other state values. - * This is useful if you want to have multiple independent caches in the same state. - * - * ``` - * { - * ..., // other state values - * __cache__default: { - * key1: value1, - * key2: value2, - * }, - * __cache__my-prefix: { - * key3: value3, - * key4: value4, - * }, - * } - * ``` - * - * @example - * ```ts - * const state = new State({}); // Here we use the real snap's state - * const rootLogger = new Logger({ level: LogLevel.INFO }); - * const cache = new StateCache(state, rootLogger, '__cache__my-prefix'); - * - * // state looks like this: - * // { - * // ..., // other state values - * // no __cache__my-prefix yet - * // } - * - * await cache.set('key1', 'value1'); - * - * // state looks like this: - * // { - * // ..., // other state values - * // __cache__my-prefix: { - * // key1: value1, - * // }, - * // } - * ``` - */ -export class StateCache implements ICache { - readonly #state: IStateManager; - - public readonly prefix: CachePrefix; - - readonly #logger: Logger; - - constructor( - state: IStateManager, - logger: Logger, - prefix: CachePrefix = '__cache__default', - ) { - this.#state = state; - this.#logger = logger.withPrefix('[💾 StateCache]'); - this.prefix = prefix; - } - - async get(key: string): Promise { - const result = await this.mget([key]); - return result[key]; - } - - async set( - key: string, - value: Serializable, - ttlMilliseconds = Number.MAX_SAFE_INTEGER, - ): Promise { - this.#validateTtlOrThrow(ttlMilliseconds); - - await this.#state.setKey(`${this.prefix}.${key}`, { - value, - expiresAt: Math.min( - Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), - Number.MAX_SAFE_INTEGER, - ), - }); - } - - #validateTtlOrThrow(ttlMilliseconds?: number): void { - if (ttlMilliseconds === undefined) { - return; - } - - if (typeof ttlMilliseconds !== 'number') { - throw new Error('TTL must be a number'); - } - - if (ttlMilliseconds < 0) { - throw new Error('TTL must be positive'); - } - - if (ttlMilliseconds > Number.MAX_SAFE_INTEGER) { - throw new Error('TTL must be less than 2^53 - 1'); - } - } - - async delete(key: string): Promise { - const result = await this.mdelete([key]); - return result[key] ?? false; - } - - async clear(): Promise { - await this.#state.setKey(this.prefix, {}); - } - - async has(key: string): Promise { - const result = await this.get(key); - return result !== undefined; - } - - async keys(): Promise { - const cacheStore = await this.#state.getKey(this.prefix); - - return Object.keys(cacheStore ?? {}); - } - - async size(): Promise { - const cacheStore = await this.#state.getKey(this.prefix); - - return Object.keys(cacheStore ?? {}).length; - } - - async peek(key: string): Promise { - const cacheStore = await this.#state.getKey(this.prefix); - const cacheEntry = cacheStore?.[key]; - - return cacheEntry?.value; - } - - async mget( - keys: string[], - ): Promise> { - const cacheStore = await this.#state.getKey(this.prefix); - - // If cache is not initialized, return empty object - if (!cacheStore) { - return {}; - } - - const keysAndValues = Object.entries(cacheStore).filter(([key]) => - keys.includes(key), - ); - - const expiredKeys = keysAndValues.filter( - ([_unused, cacheEntry]) => - cacheEntry && cacheEntry.expiresAt < Date.now(), - ); - - await this.mdelete(expiredKeys.map(([key]) => key)); - - const result: Record = {}; - - // First, handle keys that exist in the cache - keysAndValues.forEach(([key, cacheEntry]) => { - if (cacheEntry === undefined) { - this.#logger.info(`[StateCache] ❌ Cache miss for key "${key}"`); - result[key] = undefined; - return; - } - - if (cacheEntry.expiresAt < Date.now()) { - this.#logger.info(`[StateCache] ⌛ Cache expired for key "${key}"`); - result[key] = undefined; - } else { - this.#logger.info(`[StateCache] 🎉 Cache hit for key "${key}"`); - result[key] = cacheEntry.value; - } - }); - - // Then, handle keys that don't exist in the cache - keys.forEach((key) => { - if (!(key in result)) { - this.#logger.info(`[StateCache] ❌ Cache miss for key "${key}"`); - result[key] = undefined; - } - }); - - return result; - } - - async mset( - entries: { key: string; value: Serializable; ttlMilliseconds?: number }[], - ): Promise { - if (entries.length === 0) { - return; - } - - if (entries.length === 1) { - assert(entries[0]); // Enforce type narrowing as TS cannot infer that entries[0] is defined - const { key, value, ttlMilliseconds } = entries[0]; - await this.set(key, value, ttlMilliseconds); - return; - } - - entries.forEach(({ ttlMilliseconds }) => { - this.#validateTtlOrThrow(ttlMilliseconds); - }); - - // Using `state.update` is preferred for bulk `set`s, because it's more efficient and atomic. - await this.#state.update((stateValue) => { - const cacheStore = stateValue[this.prefix] ?? {}; - entries.forEach(({ key, value, ttlMilliseconds }) => { - if (value === undefined) { - return; - } - cacheStore[key] = { - value, - expiresAt: Math.min( - Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), - Number.MAX_SAFE_INTEGER, - ), - }; - }); - stateValue[this.prefix] = cacheStore; - return stateValue; - }); - } - - async mdelete(keys: string[]): Promise> { - const result: Record = {}; - - // Using `state.update` is preferred for bulk `delete`s, because it's more efficient and atomic. - await this.#state.update((stateValue) => { - const cacheStore = stateValue[this.prefix] ?? {}; - keys.forEach((key) => { - if (cacheStore[key] === undefined) { - result[key] = false; - } else { - delete cacheStore[key]; - result[key] = true; - } - }); - stateValue[this.prefix] = cacheStore; - return stateValue; - }); - - return result; - } -} diff --git a/packages/stellar-wallet-snap/src/services/cache/api.ts b/packages/stellar-wallet-snap/src/services/cache/api.ts deleted file mode 100644 index 883bd4a9..00000000 --- a/packages/stellar-wallet-snap/src/services/cache/api.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { Serializable } from '@metamask/snap-networks-utils'; - -export type TimestampMilliseconds = number; - -/** - * A single cache entry. - */ -export type CacheEntry = { - value: Serializable; - expiresAt: TimestampMilliseconds; -}; - -/** - * Interface for a generic cache implementation. - * - * @template TValue - The type of values stored in the cache - */ -export type ICache = { - /** - * Retrieves a value from the cache by key. - * - * @param key - The key to retrieve - * @returns The value if found, undefined if not found - */ - get(key: string): Promise; - - /** - * Stores a value in the cache with an optional TTL. - * - If a value is undefined, it will not be stored in the cache. - * - If a value is null, it will be stored in the cache. - * - * @param key - The key to store the value under - * @param value - The value to store - * @param ttlMilliseconds - Optional time-to-live in milliseconds. If not provided, the value will not expire. - * @throws Error if any entry's ttlMilliseconds is not a number, is negative, or is greater than 2^53 - 1 - */ - set(key: string, value: TValue, ttlMilliseconds?: number): Promise; - - /** - * Removes a value from the cache. - * - * @param key - The key to remove - * @returns true if the key was found and removed, false otherwise - */ - delete(key: string): Promise; - - /** - * Removes all values from the cache. - */ - clear(): Promise; - - /** - * Checks if a key exists in the cache. - * - * @param key - The key to check - * @returns true if the key exists, false otherwise - */ - has(key: string): Promise; - - /** - * Returns all keys currently in the cache. - * - * @returns Array of keys - */ - keys(): Promise; - - /** - * Returns the number of items in the cache. - * - * @returns The number of items - */ - size(): Promise; - - /** - * Retrieves a value from the cache without affecting its TTL or last accessed time. - * - * @param key - The key to peek at - * @returns The value if found, undefined if not found - */ - peek(key: string): Promise; - - /** - * Retrieves multiple values from the cache in a single operation. - * - * @param keys - Array of keys to retrieve - * @returns Object mapping keys to their values (or undefined if not found) - */ - mget(keys: string[]): Promise>; - - /** - * Stores multiple values in the cache in a single operation. - * - If a value is undefined, it will not be stored in the cache. - * - If a value is null, it will be stored in the cache. - * - * @param entries - Array of entries to store, each with key, value, and optional TTL (if not provided, the value will not expire) - * @throws Error if any entry's ttlMilliseconds is not a number, is negative, or is greater than 2^53 - 1 - */ - mset( - entries: { key: string; value: TValue; ttlMilliseconds?: number }[], - ): Promise; - - /** - * Removes multiple values from the cache. - * - * @param keys - Array of keys to remove - * @returns An object mapping each key to a boolean indicating whether it was found and removed - */ - mdelete(keys: string[]): Promise>; -}; diff --git a/packages/stellar-wallet-snap/src/services/cache/index.ts b/packages/stellar-wallet-snap/src/services/cache/index.ts deleted file mode 100644 index b938e2da..00000000 --- a/packages/stellar-wallet-snap/src/services/cache/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './StateCache'; -export * from './InMemoryCache'; -export type * from './api'; -export * from './useCacheUntil'; -export * from './useCache'; diff --git a/packages/stellar-wallet-snap/src/services/cache/useCache.test.ts b/packages/stellar-wallet-snap/src/services/cache/useCache.test.ts deleted file mode 100644 index 4201d6c9..00000000 --- a/packages/stellar-wallet-snap/src/services/cache/useCache.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import type { Serializable } from '@metamask/snap-networks-utils'; - -import type { ICache } from './api'; -import { useCache } from './useCache'; -import type { CacheOptions } from './useCache'; - -jest.mock('../../utils/logger'); - -describe('useCache', () => { - // Spy to check if the original function was executed or not - let actualExecutionSpy: jest.Mock; - - // Mock cache - let cache: ICache; - - // Common cache options - let cacheOptions: CacheOptions; - - // Original test functions - let testFunction: () => Promise; - let testFunctionWithArgs: (arg1: string, arg2: number) => Promise; - let testFunctionWithComplexArgs: (obj: { - name: string; - age: number; - }) => Promise; - - // Cached versions - let cachedTestFunction: () => Promise; - let cachedTestFunctionWithArgs: ( - arg1: string, - arg2: number, - ) => Promise; - let cachedTestFunctionWithComplexArgs: (obj: { - name: string; - age: number; - }) => Promise; - - beforeEach(() => { - // Reset mocks for each test - actualExecutionSpy = jest.fn().mockResolvedValue('test'); - - // Create a mock cache - cache = { - get: jest.fn().mockResolvedValue(undefined), - set: jest.fn().mockResolvedValue(undefined), - } as unknown as ICache; - - // Define common cache options - cacheOptions = { - ttlMilliseconds: 1000, - functionName: 'testFunction', - }; - - // Define original functions - testFunction = async () => actualExecutionSpy(); - testFunctionWithArgs = async (arg1: string, arg2: number) => - actualExecutionSpy(arg1, arg2); - testFunctionWithComplexArgs = async (obj: { name: string; age: number }) => - actualExecutionSpy(obj); - - // Create cached versions - cachedTestFunction = useCache(testFunction, cache, { - ...cacheOptions, - functionName: 'testFunction', - }); - - cachedTestFunctionWithArgs = useCache(testFunctionWithArgs, cache, { - ...cacheOptions, - functionName: 'testFunctionWithArgs', - }); - - cachedTestFunctionWithComplexArgs = useCache( - testFunctionWithComplexArgs, - cache, - { - ...cacheOptions, - functionName: 'testFunctionWithComplexArgs', - }, - ); - }); - - describe('when the data is not cached', () => { - it('should cache the result of a function', async () => { - // No cached data - jest.spyOn(cache, 'get').mockResolvedValue(undefined); - - const result = await cachedTestFunction(); - - expect(result).toBe('test'); - expect(cache.get).toHaveBeenCalledTimes(1); - expect(actualExecutionSpy).toHaveBeenCalledTimes(1); - expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 1000); - }); - }); - - describe('when the data is cached', () => { - it('should return the cached result', async () => { - // Init the cache with some data - jest.spyOn(cache, 'get').mockResolvedValue('test'); - - const result = await cachedTestFunction(); - - expect(result).toBe('test'); - expect(cache.get).toHaveBeenCalledTimes(1); - expect(actualExecutionSpy).not.toHaveBeenCalled(); - expect(cache.set).not.toHaveBeenCalled(); - }); - }); - - describe('error handling', () => { - it('should propagate errors from the original function', async () => { - const error = new Error('Test error'); - actualExecutionSpy.mockRejectedValueOnce(error); - - await expect(cachedTestFunction()).rejects.toThrow('Test error'); - expect(cache.set).not.toHaveBeenCalled(); - }); - - it('should handle cache get errors gracefully', async () => { - jest.spyOn(cache, 'get').mockRejectedValueOnce(new Error('Cache error')); - actualExecutionSpy.mockResolvedValueOnce('test'); - - const result = await cachedTestFunction(); - - expect(result).toBe('test'); - expect(actualExecutionSpy).toHaveBeenCalledTimes(1); - expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 1000); - }); - - it('should handle cache set errors gracefully', async () => { - jest.spyOn(cache, 'get').mockResolvedValue(undefined); - jest - .spyOn(cache, 'set') - .mockRejectedValueOnce(new Error('Cache set error')); - actualExecutionSpy.mockResolvedValueOnce('test'); - - const result = await cachedTestFunction(); - - expect(result).toBe('test'); - expect(actualExecutionSpy).toHaveBeenCalledTimes(1); - }); - }); - - describe('different argument types', () => { - it('should handle primitive arguments correctly', async () => { - jest.spyOn(cache, 'get').mockResolvedValue(undefined); - jest.spyOn(cache, 'set').mockResolvedValueOnce(undefined); - actualExecutionSpy.mockResolvedValueOnce('test with args'); - - const result = await cachedTestFunctionWithArgs('hello', 42); - - expect(result).toBe('test with args'); - expect(cache.get).toHaveBeenCalledWith('testFunctionWithArgs:"hello":42'); - expect(actualExecutionSpy).toHaveBeenCalledWith('hello', 42); - }); - - it('should handle complex object arguments correctly', async () => { - jest.spyOn(cache, 'get').mockResolvedValue(undefined); - jest.spyOn(cache, 'set').mockResolvedValueOnce(undefined); - const testObj = { name: 'John', age: 30 }; - actualExecutionSpy.mockResolvedValueOnce('test with complex args'); - - const result = await cachedTestFunctionWithComplexArgs(testObj); - - expect(result).toBe('test with complex args'); - expect(cache.get).toHaveBeenCalledWith( - 'testFunctionWithComplexArgs:{"name":"John","age":30}', - ); - expect(actualExecutionSpy).toHaveBeenCalledWith(testObj); - }); - }); - - describe('custom generateCacheKey', () => { - it('should use a custom key generator if provided', async () => { - const customKeyGenerator = jest.fn().mockReturnValue('custom-key'); - - const customCachedFunction = useCache(testFunction, cache, { - ...cacheOptions, - generateCacheKey: customKeyGenerator, - }); - - await customCachedFunction(); - - expect(customKeyGenerator).toHaveBeenCalledTimes(1); - expect(cache.get).toHaveBeenCalledWith('custom-key'); - }); - }); - - describe('anonymous functions', () => { - it('should handle anonymous functions with a default name', async () => { - // Anonymous function with no name - const anonymousFunction = async () => actualExecutionSpy(); - Object.defineProperty(anonymousFunction, 'name', { value: null }); - - const cachedAnonymousFunction = useCache(anonymousFunction, cache, { - ttlMilliseconds: 1000, - }); - - await cachedAnonymousFunction(); - - expect(cache.get).toHaveBeenCalledWith('anonymousFunction:'); - }); - }); - - describe('function name override', () => { - it('should use the provided function name if given', async () => { - const cachedWithCustomName = useCache(testFunction, cache, { - ttlMilliseconds: 1000, - functionName: 'customFunctionName', - }); - - await cachedWithCustomName(); - - expect(cache.get).toHaveBeenCalledWith('customFunctionName:'); - }); - }); - - describe('falsy but valid cache values', () => { - it('should handle falsy but valid cache values (false, 0, empty string)', async () => { - // Test with false - jest.spyOn(cache, 'get').mockResolvedValue(false); - let result = await cachedTestFunction(); - expect(result).toBe(false); - expect(actualExecutionSpy).not.toHaveBeenCalled(); - - // Test with 0 - jest.spyOn(cache, 'get').mockResolvedValue(0); - result = await cachedTestFunction(); - expect(result).toBe(0); - expect(actualExecutionSpy).not.toHaveBeenCalled(); - - // Test with empty string - jest.spyOn(cache, 'get').mockResolvedValue(''); - result = await cachedTestFunction(); - expect(result).toBe(''); - expect(actualExecutionSpy).not.toHaveBeenCalled(); - }); - - it('should execute the function when cache returns undefined', async () => { - jest.spyOn(cache, 'get').mockResolvedValue(undefined); - actualExecutionSpy.mockResolvedValueOnce('test'); - - const result = await cachedTestFunction(); - - expect(result).toBe('test'); - expect(actualExecutionSpy).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/packages/stellar-wallet-snap/src/services/cache/useCache.ts b/packages/stellar-wallet-snap/src/services/cache/useCache.ts deleted file mode 100644 index 5d0ce07c..00000000 --- a/packages/stellar-wallet-snap/src/services/cache/useCache.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* eslint-disable no-void */ - -import { serialize } from '@metamask/snap-networks-utils'; -import type { Serializable } from '@metamask/snap-networks-utils'; - -import { logger } from '../../utils/logger'; -import type { ICache } from './api'; - -const cacheLogger = logger.withPrefix('useCache'); -/** - * Options for configuring the caching behavior of a function. - */ -export type CacheOptions = { - /** - * The time to live for the cache in milliseconds. - */ - ttlMilliseconds: number; - /** - * Set this if you want to use a custom function name for the cache key. - */ - functionName?: string; - /** - * Optional function to generate the cache key for the function call. - * Defaults to a function that generates the key based on function name and JSON stringified args separated by colons. - */ - generateCacheKey?: (functionName: string, args: Serializable[]) => string; - - /** - * Whether to refresh the cache. - * Defaults to false. - */ - refreshCache?: boolean; -}; - -/** - * Default function to generate the cache key for a function call. - * - * @param functionName - The name of the function. - * @param args - The arguments of the function call. - * @returns The cache key. - */ -const defaultGenerateCacheKey = ( - functionName: string, - args: Serializable[], -): string => - `${functionName}:${args.map((arg) => JSON.stringify(serialize(arg))).join(':')}`; - -/** - * Wraps a function with caching behavior. - * - * @template TArgs - Tuple type representing the arguments of the function. - * @template TResult - The return type of the function, must be Serializable. - * @param fn - The asynchronous function to wrap. Must return a Promise. - * @param cache - The cache instance to use. - * @param options - The caching options. - * @param options.ttlMilliseconds - The time to live for the cache in milliseconds. - * @param options.refreshCache - Whether to refresh the cache. - * @param options.functionName - The name of the function. - * @param options.generateCacheKey - Optional function to generate the cache key. - * @returns A new asynchronous function with caching behavior. - */ -export const useCache = < - TArgs extends Serializable[], - TResult extends Serializable, ->( - fn: (...args: TArgs) => Promise, - cache: ICache, - { - ttlMilliseconds, - functionName, - generateCacheKey, - refreshCache = false, - }: CacheOptions, -): ((...args: TArgs) => Promise) => { - // Use provided key generator or default, adapting the default to use the function's name - const _generateCacheKey = generateCacheKey ?? defaultGenerateCacheKey; - - // Get the function name for the default key generator, handle anonymous functions - const _functionName = functionName ?? fn.name ?? 'anonymousFunction'; - - return async (...args: TArgs): Promise => { - const cacheKey = _generateCacheKey(_functionName, args); - // Check if the data is cached - if (!refreshCache) { - try { - const cached = await cache.get(cacheKey); - // Check explicitly for undefined, as null or other falsy values might be valid cache results - if (cached !== undefined) { - // Type assertion because cache stores Serializable, but we expect TResult - return cached as TResult; - } - } catch (error) { - // Log cache get errors but proceed to execute the function - cacheLogger.error(`Cache get error for key "${cacheKey}":`, error); - } - } - - // Execute the original function - const result = await fn(...args); - - // Cache the result, handle potential errors silently - // We don't await this, allowing it to happen in the background - void cache.set(cacheKey, result, ttlMilliseconds).catch((error) => { - cacheLogger.error(`Cache set error for key "${cacheKey}":`, error); - }); - - return result; - }; -}; diff --git a/packages/stellar-wallet-snap/src/services/cache/useCacheUntil.test.ts b/packages/stellar-wallet-snap/src/services/cache/useCacheUntil.test.ts deleted file mode 100644 index c78f6a4e..00000000 --- a/packages/stellar-wallet-snap/src/services/cache/useCacheUntil.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import type { Serializable } from '@metamask/snap-networks-utils'; - -import type { ICache } from './api'; -import { useCacheUntil } from './useCacheUntil'; -import type { CacheUntilOptions, ResultWithExpiry } from './useCacheUntil'; - -jest.mock('../../utils/logger'); - -describe('useCacheUntil', () => { - // Spy to check if the original function was executed or not - let actualExecutionSpy: jest.Mock; - - // Mock cache - let cache: ICache; - - // Common cache options - let cacheOptions: CacheUntilOptions; - - // Original test function that returns result with expiry - let testFunction: () => Promise>; - let testFunctionWithArgs: (arg1: string) => Promise>; - - // Cached versions - let cachedTestFunction: () => Promise; - let cachedTestFunctionWithArgs: (arg1: string) => Promise; - - // Mock current time - const mockNow = 1700000000000; // Fixed timestamp for testing - - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(mockNow); - - // Reset mocks for each test - actualExecutionSpy = jest.fn().mockResolvedValue({ - result: 'test', - expiresAt: mockNow + 60000, // Expires in 60 seconds - }); - - // Create a mock cache - cache = { - get: jest.fn().mockResolvedValue(undefined), - set: jest.fn().mockResolvedValue(undefined), - } as unknown as ICache; - - // Define common cache options - cacheOptions = { - functionName: 'testFunction', - }; - - // Define original functions - testFunction = async () => actualExecutionSpy(); - testFunctionWithArgs = async (arg1: string) => actualExecutionSpy(arg1); - - // Create cached versions - cachedTestFunction = useCacheUntil(testFunction, cache, { - ...cacheOptions, - functionName: 'testFunction', - }); - - cachedTestFunctionWithArgs = useCacheUntil(testFunctionWithArgs, cache, { - ...cacheOptions, - functionName: 'testFunctionWithArgs', - }); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - describe('when the data is not cached', () => { - it('caches the result with TTL calculated from expiresAt', async () => { - // No cached data - jest.spyOn(cache, 'get').mockResolvedValue(undefined); - - const result = await cachedTestFunction(); - - expect(result).toBe('test'); - expect(actualExecutionSpy).toHaveBeenCalledTimes(1); - // TTL should be expiresAt - now = 60000 - expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 60000); - }); - - it('uses zero TTL when expiresAt is in the past', async () => { - actualExecutionSpy.mockResolvedValue({ - result: 'test', - expiresAt: mockNow - 1000, // Already expired - }); - - const result = await cachedTestFunction(); - - expect(result).toBe('test'); - // TTL should be 0 when expiresAt is in the past - expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 0); - }); - }); - - describe('when the data is cached and not expired', () => { - it('returns the cached result without calling the function', async () => { - // First call to populate the cache and expiry map - jest.spyOn(cache, 'get').mockResolvedValue(undefined); - await cachedTestFunction(); - - // Reset mocks - actualExecutionSpy.mockClear(); - jest.spyOn(cache, 'get').mockResolvedValue('cached-test'); - - // Second call within expiry period - const result = await cachedTestFunction(); - - expect(result).toBe('cached-test'); - expect(actualExecutionSpy).not.toHaveBeenCalled(); - expect(cache.set).toHaveBeenCalledTimes(1); // Only from first call - }); - }); - - describe('when the data is cached but expired', () => { - it('fetches fresh data after expiry time has passed', async () => { - // First call to populate the cache - jest.spyOn(cache, 'get').mockResolvedValue(undefined); - await cachedTestFunction(); - - // Advance time past the expiry - jest.setSystemTime(mockNow + 70000); // 70 seconds later - - // Reset mocks for second call - actualExecutionSpy.mockClear(); - actualExecutionSpy.mockResolvedValue({ - result: 'fresh-test', - expiresAt: mockNow + 70000 + 60000, // New expiry - }); - - const result = await cachedTestFunction(); - - expect(result).toBe('fresh-test'); - expect(actualExecutionSpy).toHaveBeenCalledTimes(1); - }); - }); - - describe('cache key generation', () => { - it('generates cache key with function name and arguments', async () => { - jest.spyOn(cache, 'get').mockResolvedValue(undefined); - actualExecutionSpy.mockResolvedValue({ - result: 'test with args', - expiresAt: mockNow + 60000, - }); - - await cachedTestFunctionWithArgs('hello'); - - expect(cache.set).toHaveBeenCalledWith( - 'testFunctionWithArgs:"hello"', - 'test with args', - 60000, - ); - }); - - it('uses a custom key generator if provided', async () => { - const customKeyGenerator = jest.fn().mockReturnValue('custom-key'); - - const customCachedFunction = useCacheUntil(testFunction, cache, { - ...cacheOptions, - generateCacheKey: customKeyGenerator, - }); - - await customCachedFunction(); - - expect(customKeyGenerator).toHaveBeenCalledTimes(1); - expect(cache.set).toHaveBeenCalledWith('custom-key', 'test', 60000); - }); - }); - - describe('error handling', () => { - it('propagates errors from the original function', async () => { - const error = new Error('Test error'); - actualExecutionSpy.mockRejectedValueOnce(error); - - await expect(cachedTestFunction()).rejects.toThrow('Test error'); - expect(cache.set).not.toHaveBeenCalled(); - }); - - it('handles cache get errors gracefully', async () => { - // First call to populate expiry map - jest.spyOn(cache, 'get').mockResolvedValue(undefined); - await cachedTestFunction(); - - // Reset for second call - actualExecutionSpy.mockClear(); - jest.spyOn(cache, 'get').mockRejectedValueOnce(new Error('Cache error')); - actualExecutionSpy.mockResolvedValue({ - result: 'test', - expiresAt: mockNow + 60000, - }); - - const result = await cachedTestFunction(); - - expect(result).toBe('test'); - expect(actualExecutionSpy).toHaveBeenCalledTimes(1); - }); - - it('handles cache set errors gracefully', async () => { - jest.spyOn(cache, 'get').mockResolvedValue(undefined); - jest - .spyOn(cache, 'set') - .mockRejectedValueOnce(new Error('Cache set error')); - - const result = await cachedTestFunction(); - - expect(result).toBe('test'); - expect(actualExecutionSpy).toHaveBeenCalledTimes(1); - }); - }); - - describe('anonymous functions', () => { - it('handles anonymous functions with a default name', async () => { - const anonymousFunction = async (): Promise> => - actualExecutionSpy(); - Object.defineProperty(anonymousFunction, 'name', { value: null }); - - const cachedAnonymousFunction = useCacheUntil(anonymousFunction, cache, { - // No functionName provided - }); - - await cachedAnonymousFunction(); - - expect(cache.set).toHaveBeenCalledWith( - 'anonymousFunction:', - 'test', - 60000, - ); - }); - }); - - describe('function name override', () => { - it('uses the provided function name if given', async () => { - const cachedWithCustomName = useCacheUntil(testFunction, cache, { - functionName: 'customFunctionName', - }); - - await cachedWithCustomName(); - - expect(cache.set).toHaveBeenCalledWith( - 'customFunctionName:', - 'test', - 60000, - ); - }); - }); - - describe('falsy but valid cache values', () => { - it('handles falsy but valid cache values (false, 0, empty string)', async () => { - // First call to populate expiry map with false result - actualExecutionSpy.mockResolvedValue({ - result: false, - expiresAt: mockNow + 60000, - }); - await cachedTestFunction(); - - // Reset and set cache to return false - actualExecutionSpy.mockClear(); - jest.spyOn(cache, 'get').mockResolvedValue(false); - - const result = await cachedTestFunction(); - - expect(result).toBe(false); - expect(actualExecutionSpy).not.toHaveBeenCalled(); - }); - - it('executes the function when cache returns undefined', async () => { - // First call to populate expiry map - await cachedTestFunction(); - - // Reset for second call with undefined cache - actualExecutionSpy.mockClear(); - jest.spyOn(cache, 'get').mockResolvedValue(undefined); - actualExecutionSpy.mockResolvedValue({ - result: 'fresh', - expiresAt: mockNow + 60000, - }); - - const result = await cachedTestFunction(); - - expect(result).toBe('fresh'); - expect(actualExecutionSpy).toHaveBeenCalledTimes(1); - }); - }); - - describe('maintenance-aligned caching scenario', () => { - it('caches until exact maintenance time and refetches after', async () => { - const maintenanceTime = mockNow + 6 * 60 * 60 * 1000; // 6 hours from now - - actualExecutionSpy.mockResolvedValue({ - result: { energyFee: 420, transactionFee: 1000 }, - expiresAt: maintenanceTime, - }); - - // First call - should fetch and cache - await cachedTestFunction(); - - expect(cache.set).toHaveBeenCalledWith( - 'testFunction:', - { energyFee: 420, transactionFee: 1000 }, - 6 * 60 * 60 * 1000, // 6 hours TTL - ); - - // Advance time to just before maintenance - jest.setSystemTime(maintenanceTime - 1000); - actualExecutionSpy.mockClear(); - jest - .spyOn(cache, 'get') - .mockResolvedValue({ energyFee: 420, transactionFee: 1000 }); - - await cachedTestFunction(); - expect(actualExecutionSpy).not.toHaveBeenCalled(); // Still using cache - - // Advance time past maintenance - jest.setSystemTime(maintenanceTime + 1000); - actualExecutionSpy.mockResolvedValue({ - result: { energyFee: 500, transactionFee: 1200 }, // New values - expiresAt: maintenanceTime + 6 * 60 * 60 * 1000, // Next maintenance - }); - - const freshResult = await cachedTestFunction(); - - expect(actualExecutionSpy).toHaveBeenCalledTimes(1); // Fetched fresh - expect(freshResult).toStrictEqual({ - energyFee: 500, - transactionFee: 1200, - }); - }); - }); -}); diff --git a/packages/stellar-wallet-snap/src/services/cache/useCacheUntil.ts b/packages/stellar-wallet-snap/src/services/cache/useCacheUntil.ts deleted file mode 100644 index 6a3cd19b..00000000 --- a/packages/stellar-wallet-snap/src/services/cache/useCacheUntil.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { serialize } from '@metamask/snap-networks-utils'; -import type { Serializable } from '@metamask/snap-networks-utils'; - -import { logger } from '../../utils/logger'; -import type { ICache } from './api'; - -const cacheLogger = logger.withPrefix('useCache'); - -/** - * Result type for functions that provide their own expiry time. - */ -export type ResultWithExpiry = { - result: TResult; - expiresAt: number; // Unix timestamp in milliseconds -}; - -/** - * Options for configuring the caching behavior of a function with dynamic expiry. - */ -export type CacheUntilOptions = { - /** - * Set this if you want to use a custom function name for the cache key. - */ - functionName?: string; - /** - * Optional function to generate the cache key for the function call. - * Defaults to a function that generates the key based on function name and JSON stringified args separated by colons. - */ - generateCacheKey?: (functionName: string, args: Serializable[]) => string; - - /** - * Whether to refresh the cache. - * Defaults to false. - */ - refreshCache?: boolean; -}; - -/** - * Default function to generate the cache key for a function call. - * - * @param functionName - The name of the function. - * @param args - The arguments of the function call. - * @returns The cache key. - */ -const defaultGenerateCacheKey = ( - functionName: string, - args: Serializable[], -): string => - `${functionName}:${args.map((arg) => JSON.stringify(serialize(arg))).join(':')}`; - -/** - * Wraps an async function with caching behavior where expiry is determined - * by the function result itself (dynamic TTL). - * - * Unlike `useCache` which uses a fixed TTL, this utility allows the wrapped - * function to specify when its result expires. This is useful for caching - * data that has known invalidation points (e.g., blockchain maintenance periods). - * - * @template TArgs - Tuple type representing the arguments of the function. - * @template TResult - The return type of the function, must be Serializable. - * @param fn - The asynchronous function to wrap. Must return a Promise>. - * @param cache - The cache instance to use. - * @param options - The caching options. - * @param options.refreshCache - Whether to refresh the cache. - * @param options.functionName - The name of the function. - * @param options.generateCacheKey - Optional function to generate the cache key. - * @returns A new asynchronous function with caching behavior. - */ -export const useCacheUntil = < - TArgs extends Serializable[], - TResult extends Serializable, ->( - fn: (...args: TArgs) => Promise>, - cache: ICache, - { functionName, generateCacheKey, refreshCache = false }: CacheUntilOptions, -): ((...args: TArgs) => Promise) => { - // Use provided key generator or default, adapting the default to use the function's name - const _generateCacheKey = generateCacheKey ?? defaultGenerateCacheKey; - - // Get the function name for the default key generator, handle anonymous functions - const _functionName = functionName ?? fn.name ?? 'anonymousFunction'; - - // Map to track expiry timestamps for each cache key - const expiryMap = new Map(); - - return async (...args: TArgs): Promise => { - const cacheKey = _generateCacheKey(_functionName, args); - const now = Date.now(); - - // Check if cached and not expired - const expiresAt = expiryMap.get(cacheKey); - if (!refreshCache && expiresAt !== undefined && now < expiresAt) { - try { - const cached = await cache.get(cacheKey); - // Check explicitly for undefined, as null or other falsy values might be valid cache results - if (cached !== undefined) { - // Type assertion because cache stores Serializable, but we expect TResult - return cached as TResult; - } - } catch (error) { - // Log cache get errors but proceed to execute the function - cacheLogger.error(`Cache get error for key "${cacheKey}":`, error); - } - } - - // Execute the original function to get result and new expiry - const { result, expiresAt: newExpiresAt } = await fn(...args); - - // Calculate TTL from expiry timestamp - const ttlMilliseconds = Math.max(0, newExpiresAt - now); - - // Store result in cache with calculated TTL - await cache.set(cacheKey, result, ttlMilliseconds).catch((error) => { - cacheLogger.error(`Cache set error for key "${cacheKey}":`, error); - }); - - // Store expiry timestamp - expiryMap.set(cacheKey, newExpiresAt); - - return result; - }; -}; diff --git a/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts b/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts index 454be3ec..bdeeadb9 100644 --- a/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts +++ b/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts @@ -1,4 +1,5 @@ import { TransactionStatus } from '@metamask/keyring-api'; +import { InMemoryCache } from '@metamask/snap-networks-utils'; import { Account, Contract, @@ -20,7 +21,6 @@ import { AppConfig } from '../../config'; import { STELLAR_DECIMAL_PLACES } from '../../constants'; import { toSmallestUnit } from '../../utils'; import { logger } from '../../utils/logger'; -import { InMemoryCache } from '../cache/InMemoryCache'; import { createMockAccountWithBalances } from '../on-chain-account/__mocks__/onChainAccount.fixtures'; import { OnChainAccount } from '../on-chain-account/OnChainAccount'; import { diff --git a/packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/packages/stellar-wallet-snap/src/services/network/NetworkService.ts index a74cd441..36f9ede6 100644 --- a/packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -1,4 +1,9 @@ -import type { Logger, Serializable } from '@metamask/snap-networks-utils'; +import type { + ICache, + Logger, + Serializable, +} from '@metamask/snap-networks-utils'; +import { useCache } from '@metamask/snap-networks-utils'; import { parseCaipAssetType } from '@metamask/utils'; import { Address, @@ -29,8 +34,6 @@ import { rethrowIfInstanceElseThrow, batchesAllSettled, } from '../../utils'; -import type { ICache } from '../cache'; -import { useCache } from '../cache'; import { OnChainAccount } from '../on-chain-account/OnChainAccount'; import { InvalidInvokeContractStructureException } from '../transaction/exceptions'; import { Transaction } from '../transaction/Transaction'; diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts index 585b7c36..1ecb8141 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import { InMemoryCache } from '@metamask/snap-networks-utils'; import type { Horizon } from '@stellar/stellar-sdk'; import { Account } from '@stellar/stellar-sdk'; @@ -6,7 +7,6 @@ import type { KnownCaip2ChainId } from '../../../api'; import { logger, noOpLogger } from '../../../utils/logger'; import { AccountService } from '../../account/AccountService'; import { AccountsRepository } from '../../account/AccountsRepository'; -import { InMemoryCache } from '../../cache'; import { NetworkService } from '../../network'; import { State } from '../../state/State'; import { WalletService } from '../../wallet'; diff --git a/packages/stellar-wallet-snap/src/services/price/PriceService.test.ts b/packages/stellar-wallet-snap/src/services/price/PriceService.test.ts index 8e740b83..5f78fa43 100644 --- a/packages/stellar-wallet-snap/src/services/price/PriceService.test.ts +++ b/packages/stellar-wallet-snap/src/services/price/PriceService.test.ts @@ -3,7 +3,7 @@ import type { CaipAssetType } from '@metamask/utils'; import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; import { AppConfig } from '../../config'; import { logger } from '../../utils'; -import { createMemoryCache } from '../cache/__mocks__/cache.fixtures'; +import { createMemoryCache } from '../../utils/__mocks__/cache.fixtures'; import type { SpotPrice } from './price-api/api'; import { PriceApiClient } from './price-api/PriceApiClient'; import { PriceService } from './PriceService'; diff --git a/packages/stellar-wallet-snap/src/services/price/PriceService.ts b/packages/stellar-wallet-snap/src/services/price/PriceService.ts index f2bae002..59ed3444 100644 --- a/packages/stellar-wallet-snap/src/services/price/PriceService.ts +++ b/packages/stellar-wallet-snap/src/services/price/PriceService.ts @@ -1,9 +1,12 @@ -import type { Logger, Serializable } from '@metamask/snap-networks-utils'; +import type { + ICache, + Logger, + Serializable, +} from '@metamask/snap-networks-utils'; import type { CaipAssetType } from '@metamask/utils'; import { AppConfig } from '../../config'; import { trackError } from '../../utils'; -import type { ICache } from '../cache'; import type { SpotPrice, SpotPricesResponse, diff --git a/packages/stellar-wallet-snap/src/services/transaction/TransactionSynchronizeService.test.ts b/packages/stellar-wallet-snap/src/services/transaction/TransactionSynchronizeService.test.ts index fa2f32a4..926ce557 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/TransactionSynchronizeService.test.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/TransactionSynchronizeService.test.ts @@ -10,13 +10,13 @@ import { Keypair, Networks } from '@stellar/stellar-sdk'; import { KnownCaip2ChainId } from '../../api'; import { toCaip19Sep41AssetId } from '../../utils'; +import { createMemoryCache } from '../../utils/__mocks__/cache.fixtures'; import { logger } from '../../utils/logger'; import { getSnapProvider } from '../../utils/snap'; import { generateStellarKeyringAccount } from '../account/__mocks__/account.fixtures'; import type { AccountService } from '../account/AccountService'; import type { StellarAssetMetadata } from '../asset-metadata/api'; import { toStellarAssetMetadata } from '../asset-metadata/utils'; -import { createMemoryCache } from '../cache/__mocks__/cache.fixtures'; import { NetworkService, TransactionNotFoundException } from '../network'; import { createMockAccountWithBalances, diff --git a/packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts b/packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts index c24348a7..0100e7fa 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts @@ -15,8 +15,8 @@ import { import type { KnownCaip19AssetIdOrSlip44Id } from '../../../api'; import { KnownCaip2ChainId } from '../../../api'; import { getSlip44AssetId, logger } from '../../../utils'; +import { createMemoryCache } from '../../../utils/__mocks__/cache.fixtures'; import { mockAccountService } from '../../account/__mocks__/account.fixtures'; -import { createMemoryCache } from '../../cache/__mocks__/cache.fixtures'; import { NetworkService } from '../../network'; import { State } from '../../state/State'; import { generateStellarAddress } from '../../wallet/__mocks__/wallet.fixtures'; diff --git a/packages/stellar-wallet-snap/src/services/cache/__mocks__/cache.fixtures.ts b/packages/stellar-wallet-snap/src/utils/__mocks__/cache.fixtures.ts similarity index 93% rename from packages/stellar-wallet-snap/src/services/cache/__mocks__/cache.fixtures.ts rename to packages/stellar-wallet-snap/src/utils/__mocks__/cache.fixtures.ts index 1c9da678..ab230d81 100644 --- a/packages/stellar-wallet-snap/src/services/cache/__mocks__/cache.fixtures.ts +++ b/packages/stellar-wallet-snap/src/utils/__mocks__/cache.fixtures.ts @@ -1,6 +1,4 @@ -import type { Serializable } from '@metamask/snap-networks-utils'; - -import type { ICache } from '../api'; +import type { ICache, Serializable } from '@metamask/snap-networks-utils'; /** * In-memory {@link ICache} for tests (jest.fn wrappers + backing map).