diff --git a/packages/snap-networks-utils/CHANGELOG.md b/packages/snap-networks-utils/CHANGELOG.md index aed06335..1ad15de4 100644 --- a/packages/snap-networks-utils/CHANGELOG.md +++ b/packages/snap-networks-utils/CHANGELOG.md @@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add shared snap state helpers `IStateManager`, `State`, and `InMemoryState` (Tron-style write mutex plus blob/path locking). ([#288](https://github.com/MetaMask/internal-snaps/pull/288)) +- 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 860ce9b9..e90003ea 100644 --- a/packages/snap-networks-utils/src/index.ts +++ b/packages/snap-networks-utils/src/index.ts @@ -74,6 +74,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..df6b3c52 --- /dev/null +++ b/packages/snap-networks-utils/src/utils/cache/useCache.ts @@ -0,0 +1,132 @@ +/* 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. + * + * WARNINGS: + * - The cache write is fire-and-forget (not awaited). This is only safe with + * caches whose writes are effectively instantaneous, such as + * `InMemoryCache`. Do not pair this wrapper with a mutex-guarded, persisted + * cache such as `StateCache`: unawaited state writes accumulate without + * backpressure, the state mutex queue grows without bound under sustained + * traffic, and foreground state operations eventually hit the snap's RPC + * timeout. Use `useCacheUntil` (which awaits its write) with a persisted + * cache instead. + * + * @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, handling potential errors silently. + // Fire-and-forget: we don't await this, allowing it to happen in the + // background. This is safe only because the snaps pair this wrapper with + // `InMemoryCache` (writes resolve in a microtask). With a mutex-guarded + // persisted cache such as `StateCache`, unawaited writes would accumulate + // without backpressure and starve foreground state operations — see the + // warnings in this function's documentation. + 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; + }; +};