From d412ba92da20353354879a49961e35d16291bb41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Szymon=20Mas=C5=82owski?= Date: Wed, 5 Feb 2025 14:54:44 +0100 Subject: [PATCH] feat: introduce persistent cache for providers - define a contract of the cache - enable chain history and utxo providers to use injected cache - create persistent cache storage - utilizing web extension storage - implementing the cache contract - having data envicting capability --- .../BlockfrostChainHistoryProvider.ts | 21 +- .../UtxoProvider/BlockfrostUtxoProvider.ts | 24 ++- .../BlockfrostChainHistoryProvider.test.ts | 19 +- .../test/Utxo/BlockfrostUtxoProvider.test.ts | 27 ++- .../src/Program/programs/providerServer.ts | 27 ++- packages/e2e/src/factories.ts | 31 ++- packages/util/src/types.ts | 5 + packages/web-extension/src/index.ts | 1 + packages/web-extension/src/storage/index.ts | 1 + .../storage/persistentCacheStorage/index.ts | 6 + .../persistentCacheStorage.ts | 112 ++++++++++ .../storage/persistentCacheStorage.test.ts | 204 ++++++++++++++++++ 12 files changed, 451 insertions(+), 27 deletions(-) create mode 100644 packages/web-extension/src/storage/index.ts create mode 100644 packages/web-extension/src/storage/persistentCacheStorage/index.ts create mode 100644 packages/web-extension/src/storage/persistentCacheStorage/persistentCacheStorage.ts create mode 100644 packages/web-extension/test/storage/persistentCacheStorage.test.ts diff --git a/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts b/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts index 835d2673897..4b07ae85b9e 100644 --- a/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts +++ b/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts @@ -24,6 +24,7 @@ import { import { Logger } from 'ts-log'; import omit from 'lodash/omit.js'; import uniq from 'lodash/uniq.js'; +import type { Cache } from '@cardano-sdk/util'; import type { Responses } from '@blockfrost/blockfrost-js'; import type { Schemas } from '@blockfrost/blockfrost-js/lib/types/open-api'; @@ -33,12 +34,20 @@ export const DB_MAX_SAFE_INTEGER = 2_147_483_647; type BlockfrostTx = Pick; const compareTx = (a: BlockfrostTx, b: BlockfrostTx) => a.block_height - b.block_height || a.tx_index - b.tx_index; +type BlockfrostChainHistoryProviderDependencies = { + cache: Cache; + client: BlockfrostClient; + networkInfoProvider: NetworkInfoProvider; + logger: Logger; +}; + export class BlockfrostChainHistoryProvider extends BlockfrostProvider implements ChainHistoryProvider { - private readonly cache: Map = new Map(); + private readonly cache: Cache; private networkInfoProvider: NetworkInfoProvider; - constructor(client: BlockfrostClient, networkInfoProvider: NetworkInfoProvider, logger: Logger) { + constructor({ cache, client, networkInfoProvider, logger }: BlockfrostChainHistoryProviderDependencies) { super(client, logger); + this.cache = cache; this.networkInfoProvider = networkInfoProvider; } @@ -478,12 +487,12 @@ export class BlockfrostChainHistoryProvider extends BlockfrostProvider implement public async transactionsByHashes({ ids }: TransactionsByIdsArgs): Promise { return Promise.all( ids.map(async (id) => { - if (this.cache.has(id)) { - return this.cache.get(id)!; - } + const cached = await this.cache.get(id); + if (cached) return cached; + try { const fetchedTransaction = await this.fetchTransaction(id); - this.cache.set(id, fetchedTransaction); + void this.cache.set(id, fetchedTransaction); return fetchedTransaction; } catch (error) { throw this.toProviderError(error); diff --git a/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts b/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts index 6814e33f054..76e1295a278 100644 --- a/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts +++ b/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts @@ -1,9 +1,22 @@ -import { BlockfrostProvider, BlockfrostToCore, fetchSequentially } from '../blockfrost'; +import { BlockfrostClient, BlockfrostProvider, BlockfrostToCore, fetchSequentially } from '../blockfrost'; import { Cardano, Serialization, UtxoByAddressesArgs, UtxoProvider } from '@cardano-sdk/core'; +import { Logger } from 'ts-log'; +import type { Cache } from '@cardano-sdk/util'; import type { Responses } from '@blockfrost/blockfrost-js'; +type BlockfrostUtxoProviderDependencies = { + client: BlockfrostClient; + cache: Cache; + logger: Logger; +}; + export class BlockfrostUtxoProvider extends BlockfrostProvider implements UtxoProvider { - private readonly cache: Map = new Map(); + private readonly cache: Cache; + + constructor({ cache, client, logger }: BlockfrostUtxoProviderDependencies) { + super(client, logger); + this.cache = cache; + } protected async fetchUtxos(addr: Cardano.PaymentAddress, paginationQueryString: string): Promise { const queryString = `addresses/${addr.toString()}/utxos?${paginationQueryString}`; @@ -29,9 +42,8 @@ export class BlockfrostUtxoProvider extends BlockfrostProvider implements UtxoPr }); } protected async fetchDetailsFromCBOR(hash: string) { - if (this.cache.has(hash)) { - return this.cache.get(hash); - } + const cached = await this.cache.get(hash); + if (cached) return cached; const result = await this.fetchCBOR(hash) .then((cbor) => { @@ -48,7 +60,7 @@ export class BlockfrostUtxoProvider extends BlockfrostProvider implements UtxoPr return null; } - this.cache.set(hash, result); + void this.cache.set(hash, result); return result; } diff --git a/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts b/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts index 4ecb825b725..8760aeb2260 100644 --- a/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts +++ b/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts @@ -3,6 +3,7 @@ import { Cardano, NetworkInfoProvider } from '@cardano-sdk/core'; import { Responses } from '@blockfrost/blockfrost-js'; import { dummyLogger as logger } from 'ts-log'; import { mockResponses } from '../util'; +import type { Cache } from '@cardano-sdk/util'; jest.mock('@blockfrost/blockfrost-js'); @@ -370,7 +371,23 @@ describe('blockfrostChainHistoryProvider', () => { } ]) } as unknown as NetworkInfoProvider; - provider = new BlockfrostChainHistoryProvider(client, networkInfoProvider, logger); + const cacheStorage = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cache: Cache = { + async get(key) { + return cacheStorage.get(key); + }, + async set(key, value) { + cacheStorage.set(key, value); + } + }; + + provider = new BlockfrostChainHistoryProvider({ + cache, + client, + logger, + networkInfoProvider + }); mockResponses(request, [ [`txs/${txId1}/utxos`, txsUtxosResponse], [`txs/${txId1}`, mockedTx1Response], diff --git a/packages/cardano-services-client/test/Utxo/BlockfrostUtxoProvider.test.ts b/packages/cardano-services-client/test/Utxo/BlockfrostUtxoProvider.test.ts index 4255cf821af..9aead010d87 100644 --- a/packages/cardano-services-client/test/Utxo/BlockfrostUtxoProvider.test.ts +++ b/packages/cardano-services-client/test/Utxo/BlockfrostUtxoProvider.test.ts @@ -3,6 +3,7 @@ import { Cardano } from '@cardano-sdk/core'; import { Responses } from '@blockfrost/blockfrost-js'; import { logger } from '@cardano-sdk/util-dev'; import { mockResponses } from '../util'; +import type { Cache } from '@cardano-sdk/util'; jest.mock('@blockfrost/blockfrost-js'); const generateUtxoResponseMock = (qty: number) => @@ -31,7 +32,22 @@ describe('blockfrostUtxoProvider', () => { beforeEach(async () => { request = jest.fn(); const client = { request } as unknown as BlockfrostClient; - provider = new BlockfrostUtxoProvider(client, logger); + const cacheStorage = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cache: Cache = { + async get(key) { + return cacheStorage.get(key); + }, + async set(key, value) { + cacheStorage.set(key, value); + } + }; + + provider = new BlockfrostUtxoProvider({ + cache, + client, + logger + }); address = Cardano.PaymentAddress( 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp' ); @@ -84,7 +100,12 @@ describe('blockfrostUtxoProvider', () => { const txHash = '0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'; mockResponses(request, [ - [`txs/${txHash}/cbor`, 'mockedCBORData'], + [ + `txs/${txHash}/cbor`, + { + cbor: '84a5008182582038685cff32e65bbf5be53e5478b2c9f91c686a663394484df2205d4733d19ad501018182583900bdb17d476dfdaa0d06b970a19e260fc97b9622fa7b85fc51205a552b3781e8276be4ebdaff285d7a29a144a4cf681af786f441cd8a6eea041a062a3a78021a0002a019031a049dea66048183098200581c3781e8276be4ebdaff285d7a29a144a4cf681af786f441cd8a6eea048102a10082825820518ab6ccb82cf0db3893dc532af0eb27bdfd68d696811ff5acf16c47d4792ab3584018f37f5f94397c10ab44d6d382a0abc83807ab8039b8bff3b172b5875d928cec5022f02d027e4077253a79910126562988c306c34f14dca9d79be4e1c6de940f82582076f79de7b22ea72556735ba30b49a6b176e03641d088f43b64ec299400d971b7584046f4ce5ee2d14ab15c38aa74063bf25c9481ceaec59f9cf55bc25f6aae20a3c8761db8f7e9621c5f6b3f5628c574f738c3c997f83210effd5790d54419254a0ef5f6' + } + ], [`addresses/${address.toString()}/utxos?page=1&count=100`, generateUtxoResponseMock(1)], [`addresses/${address.toString()}/utxos?page=2&count=100`, generateUtxoResponseMock(0)] ]); @@ -101,7 +122,7 @@ describe('blockfrostUtxoProvider', () => { expect(secondResponse).toEqual(firstResponse); - expect(request).not.toHaveBeenCalledWith(`txs/${txHash}/cbor`); + expect(request).not.toHaveBeenCalledWith(`txs/${txHash}/cbor`, undefined); }); }); }); diff --git a/packages/cardano-services/src/Program/programs/providerServer.ts b/packages/cardano-services/src/Program/programs/providerServer.ts index 6e0188a71b8..8e4ee9f8aac 100644 --- a/packages/cardano-services/src/Program/programs/providerServer.ts +++ b/packages/cardano-services/src/Program/programs/providerServer.ts @@ -127,6 +127,19 @@ const selectProviderImplementation = ( return selected; }; +const createProviderCache = () => { + const cache = new Map(); + return { + async get(key: string) { + return cache.get(key); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async set(key: string, val: any) { + cache.set(key, val); + } + }; +}; + const serviceMapFactory = (options: ServiceMapFactoryOptions) => { const { args, pools, dnsResolver, genesisData, logger, node } = options; @@ -298,7 +311,12 @@ const serviceMapFactory = (options: ServiceMapFactoryOptions) => { const getBlockfrostAssetProvider = () => new BlockfrostAssetProvider(getBlockfrostClient(), logger); - const getBlockfrostUtxoProvider = () => new BlockfrostUtxoProvider(getBlockfrostClient(), logger); + const getBlockfrostUtxoProvider = () => + new BlockfrostUtxoProvider({ + cache: createProviderCache(), + client: getBlockfrostClient(), + logger + }); const getDbSyncUtxoProvider = withDbSyncProvider( (dbPools, cardanoNode) => @@ -344,7 +362,12 @@ const serviceMapFactory = (options: ServiceMapFactoryOptions) => { return networkInfoProvider; }; const getBlockfrostChainHistoryProvider = (nInfoProvider: NetworkInfoProvider | DbSyncNetworkInfoProvider) => - new BlockfrostChainHistoryProvider(getBlockfrostClient(), nInfoProvider, logger); + new BlockfrostChainHistoryProvider({ + cache: createProviderCache(), + client: getBlockfrostClient(), + logger, + networkInfoProvider: nInfoProvider + }); const getBlockfrostRewardsProvider = () => new BlockfrostRewardsProvider(getBlockfrostClient(), logger); diff --git a/packages/e2e/src/factories.ts b/packages/e2e/src/factories.ts index 44c9ff7e465..ef95b34fde6 100644 --- a/packages/e2e/src/factories.ts +++ b/packages/e2e/src/factories.ts @@ -128,6 +128,18 @@ const getWsClient = async (logger: Logger) => { return (wsClient = new CardanoWsClient({ chainHistoryProvider, logger }, { url: new URL(env.WS_PROVIDER_URL) })); }; +const createProviderCache = () => { + const cache = new Map(); + return { + async get(key: string) { + return cache.get(key); + }, + async set(key: string, val: any) { + cache.set(key, val); + } + }; +}; + // Asset providers assetProviderFactory.register(HTTP_PROVIDER, async (params: any, logger: Logger): Promise => { @@ -181,14 +193,15 @@ chainHistoryProviderFactory.register(BLOCKFROST_PROVIDER, async (params: any, lo return new Promise(async (resolve) => { resolve( - new BlockfrostChainHistoryProvider( - new BlockfrostClient( + new BlockfrostChainHistoryProvider({ + cache: createProviderCache(), + client: new BlockfrostClient( { apiVersion: params.apiVersion, baseUrl: params.baseUrl, projectId: params.projectId }, { rateLimiter: { schedule: (task) => task() } } ), - await networkInfoProviderFactory.create('blockfrost', params, logger), - logger - ) + logger, + networkInfoProvider: await networkInfoProviderFactory.create('blockfrost', params, logger) + }) ); }); }); @@ -360,16 +373,16 @@ utxoProviderFactory.register( utxoProviderFactory.register(BLOCKFROST_PROVIDER, async (params: any, logger) => { if (params.baseUrl === undefined) throw new Error(`${BlockfrostUtxoProvider.name}: ${MISSING_URL_PARAM}`); - return new Promise(async (resolve) => { resolve( - new BlockfrostUtxoProvider( - new BlockfrostClient( + new BlockfrostUtxoProvider({ + cache: createProviderCache(), + client: new BlockfrostClient( { apiVersion: params.apiVersion, baseUrl: params.baseUrl, projectId: params.projectId }, { rateLimiter: { schedule: (task) => task() } } ), logger - ) + }) ); }); }); diff --git a/packages/util/src/types.ts b/packages/util/src/types.ts index a37ab735b4c..18384a9e30f 100644 --- a/packages/util/src/types.ts +++ b/packages/util/src/types.ts @@ -28,3 +28,8 @@ type Impossible = { [P in K]: never; }; export type NoExtraProperties = U & Impossible>; + +export type Cache = { + get(key: string): Promise; + set(key: string, value: T): Promise; +}; diff --git a/packages/web-extension/src/index.ts b/packages/web-extension/src/index.ts index 801a27c6feb..580fb5ef31e 100644 --- a/packages/web-extension/src/index.ts +++ b/packages/web-extension/src/index.ts @@ -4,3 +4,4 @@ export * from './supplyDistributionTracker'; export * from './keyAgent'; export * from './walletManager'; export * as cip30 from './cip30'; +export * from './storage'; diff --git a/packages/web-extension/src/storage/index.ts b/packages/web-extension/src/storage/index.ts new file mode 100644 index 00000000000..9d30337f8a1 --- /dev/null +++ b/packages/web-extension/src/storage/index.ts @@ -0,0 +1 @@ +export * from './persistentCacheStorage'; diff --git a/packages/web-extension/src/storage/persistentCacheStorage/index.ts b/packages/web-extension/src/storage/persistentCacheStorage/index.ts new file mode 100644 index 00000000000..ae6c09d37fc --- /dev/null +++ b/packages/web-extension/src/storage/persistentCacheStorage/index.ts @@ -0,0 +1,6 @@ +import { makePersistentCacheStorageFactory } from './persistentCacheStorage'; +import { storage } from 'webextension-polyfill'; + +export const createPersistentCacheStorage = makePersistentCacheStorageFactory(storage.local, () => new Map()); + +export type CreatePersistentCacheStorage = typeof createPersistentCacheStorage; diff --git a/packages/web-extension/src/storage/persistentCacheStorage/persistentCacheStorage.ts b/packages/web-extension/src/storage/persistentCacheStorage/persistentCacheStorage.ts new file mode 100644 index 00000000000..dd195872f2c --- /dev/null +++ b/packages/web-extension/src/storage/persistentCacheStorage/persistentCacheStorage.ts @@ -0,0 +1,112 @@ +import type { Storage } from 'webextension-polyfill'; + +type StorageLocal = Pick; + +type MetadataItem = { + accessTime: number; +}; + +export type Metadata = Record; + +const sizeOfChunkToBePurged = 0.1; + +export const makeMetadataKey = (resourceName: string) => `${resourceName}-metadata`; + +export const makeItemKey = (resourceName: string, key: string) => `${resourceName}-item-${key}`; + +const isGetBytesInUsePresent = ( + storageLocal: StorageLocal +): storageLocal is StorageLocal & { getBytesInUse: (keys?: string | string[]) => Promise } => + 'getBytesInUse' in storageLocal; + +export const makePersistentCacheStorageFactory = + (extensionLocalStorage: StorageLocal, createVolatileCache: () => Map) => + ({ + fallbackMaxCollectionItemsGuard, + resourceName, + quotaInBytes + }: { + fallbackMaxCollectionItemsGuard: number; + resourceName: string; + quotaInBytes: number; + }) => { + const loaded = createVolatileCache(); + const metadataKey = makeMetadataKey(resourceName); + const getItemKey = (key: string) => makeItemKey(resourceName, key); + + const getMetadata = async () => { + const result = await extensionLocalStorage.get(metadataKey); + return result[metadataKey] as Metadata; + }; + + const updateAccessTime = async (key: string) => { + const metadata = await getMetadata(); + const nextMetadata: Metadata = { + ...metadata, + [key]: { + accessTime: Date.now() + } + }; + await extensionLocalStorage.set({ [metadataKey]: nextMetadata }); + }; + + const isQuotaExceeded = async () => { + const metadata = await getMetadata(); + const allCollectionKeys = [metadataKey, ...Object.keys(metadata)]; + + // Polyfill we use does not list the getBytesInUse method but that method exists in chrome API + if (isGetBytesInUsePresent(extensionLocalStorage)) { + const bytesInUse = await extensionLocalStorage.getBytesInUse(allCollectionKeys); + return bytesInUse > quotaInBytes; + } + + return allCollectionKeys.length > fallbackMaxCollectionItemsGuard; + }; + + const evict = async () => { + let metadata = await getMetadata(); + const mostDatedKeysToPurge = Object.entries(metadata) + .map(([key, { accessTime }]) => ({ accessTime, key })) + .sort((a, b) => a.accessTime - b.accessTime) + .filter((_, index, self) => { + const numberOfItemsToPurge = Math.abs(self.length * sizeOfChunkToBePurged); + return index < numberOfItemsToPurge; + }) + .map((i) => i.key); + + await extensionLocalStorage.remove(mostDatedKeysToPurge); + metadata = await getMetadata(); + for (const key of mostDatedKeysToPurge) { + delete metadata[key]; + } + await extensionLocalStorage.set({ [metadataKey]: metadata }); + }; + + return { + async get(key: string) { + const itemKey = getItemKey(key); + + let value = loaded.get(itemKey); + if (!value) { + const result = await extensionLocalStorage.get(itemKey); + value = result[itemKey]; + } + + if (value) { + void updateAccessTime(itemKey); + } + + return value; + }, + async set(key: string, value: T) { + const itemKey = getItemKey(key); + loaded.set(itemKey, value); + await extensionLocalStorage.set({ [itemKey]: value }); + + void (async () => { + await updateAccessTime(itemKey); + if (await isQuotaExceeded()) await evict(); + })(); + } + }; + }; diff --git a/packages/web-extension/test/storage/persistentCacheStorage.test.ts b/packages/web-extension/test/storage/persistentCacheStorage.test.ts new file mode 100644 index 00000000000..f39d04744b2 --- /dev/null +++ b/packages/web-extension/test/storage/persistentCacheStorage.test.ts @@ -0,0 +1,204 @@ +import { + Metadata, + makeItemKey, + makeMetadataKey, + makePersistentCacheStorageFactory +} from '../../src/storage/persistentCacheStorage/persistentCacheStorage'; +import { setTimeout } from 'node:timers/promises'; + +Object.defineProperty(global, 'performance', { + writable: true +}); + +const fakeDate = new Date(); + +jest.useFakeTimers().setSystemTime(fakeDate); + +const resourceName = 'resource-name'; +const metadataKey = makeMetadataKey(resourceName); +const itemKeyX = makeItemKey(resourceName, 'x'); +const itemKeyY = makeItemKey(resourceName, 'y'); +const defaultStorageLocalCache = new Map([ + [itemKeyX, 1], + [ + metadataKey, + { + [itemKeyX]: { + accessTime: fakeDate.getTime() - 1 + } + } + ] +]); + +const preparePersistentStorage = ({ + bytesInUse = 1, + inMemoryCache = new Map(), + storageLocalCache = new Map(defaultStorageLocalCache) +}: // eslint-disable-next-line @typescript-eslint/no-explicit-any +{ bytesInUse?: number; inMemoryCache?: Map; storageLocalCache?: Map } = {}) => { + const storageLocal = { + get: jest.fn().mockImplementation(async (key: string) => ({ [key]: storageLocalCache.get(key) })), + getBytesInUse: jest.fn().mockImplementation(async () => bytesInUse), + remove: jest.fn().mockImplementation(async () => void 0), + set: jest.fn().mockImplementation(async (change: Record) => { + for (const [key, value] of Object.entries(change)) { + storageLocalCache.set(key, value); + } + }) + }; + const persistentCache = makePersistentCacheStorageFactory( + storageLocal, + () => inMemoryCache + )({ + fallbackMaxCollectionItemsGuard: 30, + quotaInBytes: 1024, + resourceName + }); + + return { persistentCache, storageLocal, storageLocalCache }; +}; + +describe('createPersistentCacheStorage', () => { + describe('get', () => { + it('queries storage.local when no data is in memory cache', async () => { + const { persistentCache, storageLocal } = preparePersistentStorage(); + const value = await persistentCache.get('x'); + + expect(value).toEqual(1); + expect(storageLocal.get).toHaveBeenCalledTimes(2); + expect(storageLocal.get).toHaveBeenNthCalledWith(1, itemKeyX); + }); + + it('does not query storage.local when data is in memory cache', async () => { + const { persistentCache, storageLocal } = preparePersistentStorage({ + inMemoryCache: new Map([[itemKeyX, 1]]) + }); + const value = await persistentCache.get('x'); + + expect(value).toEqual(1); + expect(storageLocal.get).toHaveBeenCalledTimes(1); + }); + + it('updates accessTime when accessed from memory cache', async () => { + const { persistentCache, storageLocal } = preparePersistentStorage({ + inMemoryCache: new Map([[itemKeyX, 1]]) + }); + await persistentCache.get('x'); + await setTimeout(); + + expect(storageLocal.set).toHaveBeenCalledTimes(1); + expect(storageLocal.set).toHaveBeenNthCalledWith(1, { + [metadataKey]: { + [itemKeyX]: { + accessTime: fakeDate.getTime() + } + } + }); + }); + + it('updates accessTime when accessed from extension storage cache', async () => { + const { persistentCache, storageLocal } = preparePersistentStorage(); + await persistentCache.get('x'); + await setTimeout(); + + expect(storageLocal.set).toHaveBeenCalledTimes(1); + expect(storageLocal.set).toHaveBeenNthCalledWith(1, { + [metadataKey]: { + [itemKeyX]: { + accessTime: fakeDate.getTime() + } + } + }); + }); + }); + + describe('set', () => { + it('adds item to memory cache', async () => { + const inMemoryCache = new Map(); + const { persistentCache } = preparePersistentStorage({ + inMemoryCache + }); + jest.spyOn(inMemoryCache, 'set'); + + await persistentCache.set('y', 2); + + expect(inMemoryCache.set).toHaveBeenCalledTimes(1); + expect(inMemoryCache.set).toHaveBeenNthCalledWith(1, itemKeyY, 2); + }); + + it('stores item in the extension storage', async () => { + const { persistentCache, storageLocal } = preparePersistentStorage(); + await persistentCache.set('y', 2); + + expect(storageLocal.set).toHaveBeenCalledTimes(1); + expect(storageLocal.set).toHaveBeenNthCalledWith(1, { + [itemKeyY]: 2 + }); + }); + + it('updates accessTime', async () => { + const { persistentCache, storageLocal, storageLocalCache } = preparePersistentStorage(); + const metadataBeforeUpdate = storageLocalCache.get(metadataKey); + await persistentCache.set('y', 2); + await setTimeout(); + + expect(storageLocal.set).toHaveBeenCalledTimes(2); + expect(storageLocal.set).toHaveBeenNthCalledWith(2, { + [metadataKey]: { + [itemKeyY]: { + accessTime: fakeDate.getTime() + }, + ...metadataBeforeUpdate + } + }); + }); + + it('queries size of all items managed by the cache', async () => { + const { persistentCache, storageLocal } = preparePersistentStorage(); + await persistentCache.set('y', 2); + await setTimeout(); + + expect(storageLocal.getBytesInUse).toHaveBeenCalledTimes(1); + expect(storageLocal.getBytesInUse).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining([itemKeyY, ...defaultStorageLocalCache.keys()]) + ); + }); + + it('removes 10% of most dated items if quota exceeded', async () => { + let storageLocalCache: Map = new Map([ + [itemKeyX, 1], + [makeItemKey(resourceName, 'a'), 1], + [makeItemKey(resourceName, 'b'), 1], + [makeItemKey(resourceName, 'c'), 1], + [makeItemKey(resourceName, 'd'), 1], + [makeItemKey(resourceName, 'e'), 1], + [makeItemKey(resourceName, 'f'), 1], + [makeItemKey(resourceName, 'g'), 1], + [makeItemKey(resourceName, 'h'), 1] + ]); + + const metadataContent = [...storageLocalCache].reduce((acc, [key]) => { + acc[key] = { + accessTime: key === itemKeyX ? fakeDate.getTime() - 1 : fakeDate.getTime() + }; + return acc; + }, {} as Metadata); + + storageLocalCache = new Map([ + [metadataKey, metadataContent], + ...defaultStorageLocalCache + ]); + + const { persistentCache, storageLocal } = preparePersistentStorage({ + bytesInUse: 1025, + storageLocalCache + }); + await persistentCache.set('y', 2); + await setTimeout(); + + expect(storageLocal.remove).toHaveBeenCalledTimes(1); + expect(storageLocal.remove).toHaveBeenNthCalledWith(1, [itemKeyX]); + }); + }); +});