diff --git a/.changeset/kind-buses-stick.md b/.changeset/kind-buses-stick.md new file mode 100644 index 000000000000..9334444b5178 --- /dev/null +++ b/.changeset/kind-buses-stick.md @@ -0,0 +1,6 @@ +--- +'@modern-js/runtime-utils': patch +--- + +feat: support configure storage container and unstable_shouldCache for cache +feat: 为 cache 函数支持自定义存储 container 和 unstable_shouldCache diff --git a/packages/document/main-doc/docs/en/guides/basic-features/data/data-cache.mdx b/packages/document/main-doc/docs/en/guides/basic-features/data/data-cache.mdx index 79b7214a9c68..c7475522ef10 100644 --- a/packages/document/main-doc/docs/en/guides/basic-features/data/data-cache.mdx +++ b/packages/document/main-doc/docs/en/guides/basic-features/data/data-cache.mdx @@ -74,7 +74,7 @@ EdenX's `cache` function can be used in any frontend or server-side code. ## Detailed Usage -### Without `options` Parameter +### Without options Parameter When no `options` parameter is provided, it's primarily useful in SSR projects, the cache lifecycle is limited to a single SSR rendering request. For example, when the same cachedFn is called in multiple data loaders, the cachedFn function won't be executed repeatedly. This allows data sharing between different data loaders while avoiding duplicate requests. EdenX will re-execute the `fn` function with each server request. @@ -97,7 +97,7 @@ const loader = async () => { ``` -### With `options` Parameter +### With options Parameter #### `maxAge` Parameter @@ -166,13 +166,15 @@ const getComplexStatistics = cache( } ); -revalidateTag('dashboard-stats'); // Invalidates the cache for both getDashboardStats and getComplexStatistics functions +await revalidateTag('dashboard-stats'); // Invalidates the cache for both getDashboardStats and getComplexStatistics functions ``` #### `getKey` Parameter -The `getKey` parameter simplifies cache key generation, especially useful when you only need to rely on part of the function parameters to differentiate caches. It's a function that receives the same parameters as the original function and returns a string or number as the cache key: +The `getKey` parameter simplifies cache key generation, especially useful when you only need to rely on part of the function parameters to differentiate caches. It's a function that receives the same parameters as the original function and returns a string. + +Its return value becomes part of the final cache key, but the key is still combined with a unique function identifier, making the cache **function-scoped**. ```ts import { cache, CacheTime } from '@modern-js/runtime/cache'; @@ -238,7 +240,9 @@ await getUserById(42); // Uses 42 as the cache key #### `customKey` parameter -The `customKey` parameter is used to customize the cache key. It is a function that receives an object with the following properties and returns a string or Symbol type as the cache key: +The `customKey` parameter is used to **fully customize** the cache key. It is a function that receives an object with the following properties and returns a string as the cache key. + +Its return value **directly becomes** the final cache key, **overriding** the default combination of a function identifier and parameter-based key. This allows you to create **globally unique** keys and share cache across different functions. - `params`: Array of arguments passed to the cached function - `fn`: Reference to the original function being cached @@ -247,16 +251,15 @@ The `customKey` parameter is used to customize the cache key. It is a function t :::info Generally, the cache will be invalidated in the following scenarios: -1. The referenced cached function changes -2. The function's input parameters change -3. The maxAge condition is no longer satisfied -4. The `revalidateTag` method has been called +1. The function's input parameters change +2. The maxAge condition is no longer satisfied +3. The `revalidateTag` method has been called -`customKey` can be used in scenarios where function references are different but shared caching is desired. If it's just for customizing the cache key, it is recommended to use `getKey`. +By default, the framework generates a stable function ID based on the function's string representation and combines it with the generated parameter key. `customKey` can be used when you need complete control over the cache key generation, especially useful for sharing cache across different function instances. If you just need to customize how parameters are converted to cache keys, it is recommended to use `getKey`. ::: -This is very useful in some scenarios, such as when the function reference changes , but you want to still return the cached data. +This is very useful in some scenarios, such as when you want to share cache across different function instances or when you need predictable cache keys for external cache management. ```ts import { cache } from '@modern-js/runtime/cache'; @@ -287,21 +290,26 @@ const getUserB = cache( } ); -// You can also use Symbol as a cache key (usually used to share cache within the same application) -const USER_CACHE_KEY = Symbol('user-cache'); +// Now you can share cache across different function implementations +await getUserA(123); // Fetches data and caches with key "user-123" +await getUserB(123); // Cache hit, returns cached data + +// You can utilize the generatedKey parameter to modify the default key const getUserC = cache( fetchUserData, { - maxAge: CacheTime.MINUTE * 5, - customKey: () => USER_CACHE_KEY, + customKey: ({ generatedKey }) => `prefix-${generatedKey}`, } ); -// You can utilize the generatedKey parameter to modify the default key +// For predictable cache keys that can be managed externally const getUserD = cache( - fetchUserData, + async (userId: string) => { + return await fetchUserData(userId); + }, { - customKey: ({ generatedKey }) => `prefix-${generatedKey}`, + maxAge: CacheTime.MINUTE * 5, + customKey: ({ params }) => `app:user:${params[0]}`, } ); ``` @@ -370,6 +378,8 @@ The `onCache` callback is useful for: ### Storage +#### Default Storage + Currently, both client and server caches are stored in memory. The default storage limit for all cached functions is 1GB. When this limit is reached, the oldest cache is removed using an LRU algorithm. @@ -386,3 +396,131 @@ configureCache({ maxSize: CacheSize.MB * 10, // 10MB }); ``` + +#### Custom Storage Container + +In addition to the default memory storage, you can use custom storage containers such as Redis, file systems, databases, etc. This enables cache sharing across processes and servers. + +##### Container Interface + +Custom storage containers need to implement the `Container` interface: + +```ts +interface Container { + get: (key: string) => Promise; + set: (key: string, value: string, options?: { ttl?: number }) => Promise; + has: (key: string) => Promise; + delete: (key: string) => Promise; + clear: () => Promise; +} +``` + +##### Basic Usage + +```ts +import { configureCache } from '@modern-js/runtime/cache'; + +// Use custom storage container +configureCache({ + container: customContainer, +}); +``` + +##### Using `customKey` to Ensure Cache Key Stability + +:::note + +When using custom storage containers (such as Redis), **it's recommended to configure `customKey`** to ensure cache key stability. This ensures: + +1. **Cross-process sharing**: Different server instances can share the same cache +2. **Cache validity after application restart**: Cache remains valid after restarting the application +3. **Cache persistence after code deployment**: Cache for the same logic remains effective after code updates + +::: + +The default cache key generation mechanism is based on function references, which may not be stable enough in distributed environments. It's recommended to use `customKey` to provide stable cache keys: + +```ts +import { cache, configureCache } from '@modern-js/runtime/cache'; + +// Configure Redis container +configureCache({ + container: redisContainer, +}); + +// Recommended: Use customKey to ensure key stability +const getUser = cache( + async (userId: string) => { + return await fetchUserData(userId); + }, + { + maxAge: CacheTime.MINUTE * 5, + // Use stable identifiers related to the cached function as cache keys + customKey: () => `fetchUserData`, + } +); +``` + +##### Redis Storage Example + +Here's an example of using Redis as a storage backend: + +```ts +import { Redis } from 'ioredis'; +import { Container, configureCache } from '@modern-js/runtime/cache'; + +class RedisContainer implements Container { + private client: Redis; + + constructor(client: Redis) { + this.client = client; + } + + async get(key: string): Promise { + return this.client.get(key); + } + + async set( + key: string, + value: string, + options?: { ttl?: number }, + ): Promise<'OK'> { + if (options?.ttl) { + return this.client.set(key, value, 'EX', options.ttl); + } + return this.client.set(key, value); + } + + async has(key: string): Promise { + const result = await this.client.exists(key); + return result === 1; + } + + async delete(key: string): Promise { + const result = await this.client.del(key); + return result > 0; + } + + async clear(): Promise { + // Be cautious with this in production. It will clear the entire Redis database. + // A more robust implementation might use a key prefix and delete keys matching that prefix. + await this.client.flushdb(); + } +} + +// Configure Redis storage +const redisClient = new Redis({ + host: 'localhost', + port: 6379, +}); + +configureCache({ + container: new RedisContainer(redisClient), +}); +``` + +##### Important Notes + +1. **Serialization**: All cached data will be serialized to strings for storage. The container only needs to handle string get/set operations. + +2. **TTL Support**: If your storage backend supports TTL (Time To Live), you can use the `options.ttl` parameter in the `set` method (in seconds). diff --git a/packages/document/main-doc/docs/zh/guides/basic-features/data/data-cache.mdx b/packages/document/main-doc/docs/zh/guides/basic-features/data/data-cache.mdx index 454c2043af9b..50e805cfa8f3 100644 --- a/packages/document/main-doc/docs/zh/guides/basic-features/data/data-cache.mdx +++ b/packages/document/main-doc/docs/zh/guides/basic-features/data/data-cache.mdx @@ -159,12 +159,14 @@ const getComplexStatistics = cache( } ); -revalidateTag('dashboard-stats'); // 会使 getDashboardStats 函数和 getComplexStatistics 函数的缓存都失效 +await revalidateTag('dashboard-stats'); // 会使 getDashboardStats 函数和 getComplexStatistics 函数的缓存都失效 ``` #### `getKey` 参数 -`getKey` 参数用于自定义缓存键的生成方式,例如你可能只需要依赖函数参数的一部分来区分缓存。它是一个函数,接收与原始函数相同的参数,返回一个字符串作为缓存键: +`getKey` 参数用于**简化**缓存键的生成方式,例如你可能只需要依赖函数参数的一部分来区分缓存。它是一个函数,接收与原始函数相同的参数,返回一个字符串。 + +它的返回值会作为参数部分参与最终缓存键的生成,但最终的键仍然会包含函数的唯一标识,因此缓存是**函数级别**的。 ```ts import { cache, CacheTime } from '@modern-js/runtime/cache'; @@ -216,7 +218,9 @@ const getUser = cache( #### `customKey` 参数 -`customKey` 参数用于定制缓存的键,它是一个函数,接收一个包含以下属性的对象,返回值必须是字符串或 Symbol 类型,将作为缓存的键: +`customKey` 参数用于**完全定制**缓存的键,它是一个函数,接收一个包含以下属性的对象,返回值必须是字符串类型。 + +它的返回值将**直接**作为最终的缓存键,**覆盖**了默认的函数标识和参数组合。这允许你创建**全局唯一**的缓存键,从而实现跨函数共享缓存。 - `params`:调用缓存函数时传入的参数数组 - `fn`:原始被缓存的函数引用 @@ -225,16 +229,15 @@ const getUser = cache( :::info 一般在以下场景,缓存会失效: -1. 缓存的函数引用发生变化 -2. 函数的入参发生变化 -3. 不满足 maxAge -4. 调用了 `revalidateTag` +1. 函数的入参发生变化 +2. 不满足 maxAge 条件 +3. 调用了 `revalidateTag` -`customKey` 可以用在函数引用不同,但希望共享缓存的场景,如果只是自定义缓存键,推荐使用 `getKey` +默认情况下,框架会基于函数的字符串表示生成稳定的函数 ID,并与生成的参数键组合。`customKey` 可以用于完全控制缓存键的生成,特别适用于在不同函数实例间共享缓存。如果只是需要自定义参数如何转换为缓存键,推荐使用 `getKey`。 ::: -这在某些场景下非常有用,比如当函数引用发生变化时,但你希望仍然返回缓存的数据。 +这在某些场景下非常有用,比如当你希望在不同函数实例间共享缓存,或者需要可预测的缓存键用于外部缓存管理时。 ```ts import { cache } from '@modern-js/runtime/cache'; @@ -264,26 +267,26 @@ const getUserB = cache( } ); -// 即使 getUserA 和 getUserB 是不同的函数引用,但由于它们的 customKey 返回相同的值 -// 所以当调用参数相同时,它们会共享缓存 -const dataA = await getUserA(1); -const dataB = await getUserB(1); // 这里会命中缓存,不会再次发起请求 +// 现在你可以在不同的函数实现间共享缓存 +await getUserA(123); // 获取数据并使用键 "user-123" 缓存 +await getUserB(123); // 缓存命中,返回缓存的数据 -// 也可以使用 Symbol 作为缓存键(通常用于共享同一个应用内的缓存) -const USER_CACHE_KEY = Symbol('user-cache'); +// 可以利用 generatedKey 参数在默认键的基础上进行修改 const getUserC = cache( fetchUserData, { - maxAge: CacheTime.MINUTE * 5, - customKey: () => USER_CACHE_KEY, + customKey: ({ generatedKey }) => `prefix-${generatedKey}`, } ); -// 可以利用 generatedKey 参数在默认键的基础上进行修改 +// 用于可预测的缓存键,便于外部管理 const getUserD = cache( - fetchUserData, + async (userId: string) => { + return await fetchUserData(userId); + }, { - customKey: ({ generatedKey }) => `prefix-${generatedKey}`, + maxAge: CacheTime.MINUTE * 5, + customKey: ({ params }) => `app:user:${params[0]}`, } ); ``` @@ -352,6 +355,8 @@ await getUser(2); // 缓存未命中 ### 存储 +#### 默认存储 + 目前不管是客户端还是服务端,缓存都存储在内存中,默认情况下所有缓存函数共享的存储上限是 1GB,当达到存储上限后,使用 LRU 算法移除旧的缓存。 :::info @@ -368,3 +373,129 @@ configureCache({ }); ``` +#### 自定义存储器 + +除了默认的内存存储,你还可以使用自定义的存储容器,例如 Redis、文件系统、数据库等。这样可以实现跨进程、跨服务器的缓存共享。 + +##### Container 接口 + +自定义存储容器需要实现 `Container` 接口: + +```ts +interface Container { + get: (key: string) => Promise; + set: (key: string, value: string, options?: { ttl?: number }) => Promise; + has: (key: string) => Promise; + delete: (key: string) => Promise; + clear: () => Promise; +} +``` + +##### 基本使用 + +```ts +import { configureCache } from '@modern-js/runtime/cache'; + +// 使用自定义存储容器 +configureCache({ + container: customContainer, +}); +``` + +##### 使用 `customKey` 确保缓存键稳定性 + +:::warning 重要建议 +当使用自定义存储容器(如 Redis)时,**建议配置 `customKey`** 来确保缓存键的稳定性。这样可以确保: + +1. **跨进程共享**:不同服务器实例能够共享相同的缓存 +2. **应用重启后缓存有效**:重启应用后仍能命中之前的缓存 +3. **代码部署后缓存保持**:代码更新后相同逻辑的缓存仍然有效 +::: + +默认的缓存键生成机制基于函数引用,在分布式环境中可能不够稳定。建议使用 `customKey` 提供稳定的缓存键: + +```ts +import { cache, configureCache } from '@modern-js/runtime/cache'; + +// 配置 Redis 容器 +configureCache({ + container: redisContainer, +}); + +// 推荐:使用 customKey 确保键的稳定性 +const getUser = cache( + async (userId: string) => { + return await fetchUserData(userId); + }, + { + maxAge: CacheTime.MINUTE * 5, + // 使用被缓存函数相关的稳定标识符作为缓存键 + customKey: () => `fetchUserData`, + } +); +``` + +##### Redis 存储示例 + +以下是一个使用 Redis 作为存储后端的示例: + +```ts +import { Redis } from 'ioredis'; +import { Container, configureCache } from '@modern-js/runtime/cache'; + +class RedisContainer implements Container { + private client: Redis; + + constructor(client: Redis) { + this.client = client; + } + + async get(key: string): Promise { + return this.client.get(key); + } + + async set( + key: string, + value: string, + options?: { ttl?: number }, + ): Promise<'OK'> { + if (options?.ttl) { + return this.client.set(key, value, 'EX', options.ttl); + } + return this.client.set(key, value); + } + + async has(key: string): Promise { + const result = await this.client.exists(key); + return result === 1; + } + + async delete(key: string): Promise { + const result = await this.client.del(key); + return result > 0; + } + + async clear(): Promise { + // 注意:在生产环境中要谨慎使用,这会清空整个 Redis 数据库 + // 更好的实现方式是使用键前缀,然后删除匹配该前缀的所有键 + await this.client.flushdb(); + } +} + +// 配置 Redis 存储 +const redisClient = new Redis({ + host: 'localhost', + port: 6379, +}); + +configureCache({ + container: new RedisContainer(redisClient), +}); +``` + +##### 注意事项 + +1. **序列化**:所有的缓存数据都会被序列化为字符串存储,容器只需要处理字符串的存取操作。 + +2. **TTL 支持**:如果你的存储后端支持 TTL(生存时间),可以在 `set` 方法中使用 `options.ttl` 参数(单位为秒)。 + diff --git a/packages/toolkit/runtime-utils/package.json b/packages/toolkit/runtime-utils/package.json index c88cf667a145..bedd8a9e0364 100644 --- a/packages/toolkit/runtime-utils/package.json +++ b/packages/toolkit/runtime-utils/package.json @@ -257,9 +257,11 @@ "devDependencies": { "@scripts/build": "workspace:*", "@scripts/jest-config": "workspace:*", + "@types/ioredis-mock": "^8.2.6", "@types/jest": "^29", "@types/node": "^14", "@types/serialize-javascript": "^5.0.1", + "ioredis-mock": "^8.9.0", "jest": "^29", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/packages/toolkit/runtime-utils/src/universal/cache.ts b/packages/toolkit/runtime-utils/src/universal/cache.ts index db66cfcda9f9..8ba69bfd7fcd 100644 --- a/packages/toolkit/runtime-utils/src/universal/cache.ts +++ b/packages/toolkit/runtime-utils/src/universal/cache.ts @@ -20,26 +20,91 @@ export type CacheStatus = 'hit' | 'stale' | 'miss'; export interface CacheStatsInfo { status: CacheStatus; - key: string | symbol; + key: string; params: any[]; result: any; + /** + * Cache miss reason: + * 1: Caching is disabled for the current request + * 2: Item not found in cache + * 3: Item found in cache but has expired + * 4: Failed to parse data from cache + * 5: Execution error + */ + reason?: number; } -interface CacheOptions { +export interface Container { + get: (key: string) => Promise; + set: (key: string, value: string, options?: { ttl?: number }) => Promise; + has: (key: string) => Promise; + delete: (key: string) => Promise; + clear: () => Promise; +} + +class MemoryContainer implements Container { + private lru: LRUCache; + + constructor(options?: { maxSize?: number }) { + this.lru = new LRUCache({ + maxSize: options?.maxSize ?? CacheSize.GB, + sizeCalculation: (value: string): number => { + return value.length * 2; + }, + updateAgeOnGet: true, + updateAgeOnHas: true, + }); + } + + async get(key: string): Promise { + return this.lru.get(key); + } + + async set( + key: string, + value: string, + options?: { ttl?: number }, + ): Promise { + if (options?.ttl) { + this.lru.set(key, value, { ttl: options.ttl * 1000 }); + } else { + this.lru.set(key, value); + } + } + + async has(key: string): Promise { + return this.lru.has(key); + } + + async delete(key: string): Promise { + return this.lru.delete(key); + } + + async clear(): Promise { + this.lru.clear(); + } +} + +interface CacheOptions any> { tag?: string | string[]; maxAge?: number; revalidate?: number; - getKey?: (...args: Args) => string; - customKey?: (options: { - params: Args; - fn: (...args: Args) => any; + getKey?: (...args: Parameters) => string; + customKey?: (options: { + params: Parameters; + fn: T; generatedKey: string; - }) => string | symbol; + }) => string; onCache?: (info: CacheStatsInfo) => void; + unstable_shouldCache?: (info: { + params: Parameters; + result: Awaited>; + }) => boolean | Promise; } interface CacheConfig { maxSize?: number; + container?: Container; unstable_shouldDisable?: ({ request, }: { @@ -50,28 +115,36 @@ interface CacheConfig { interface CacheItem { data: T; timestamp: number; - isRevalidating?: boolean; + tags?: string[]; } const isServer = typeof window === 'undefined'; const requestCacheMap = new WeakMap>(); -let lruCache: - | LRUCache>> - | undefined; +const TAG_PREFIX = 'tag:'; +const CACHE_PREFIX = 'modernjs_cache:'; + +const ongoingRevalidations = new Map>(); + +let storage: Container | undefined; let cacheConfig: CacheConfig = { maxSize: CacheSize.GB, }; -const tagKeyMap = new Map>(); +function getStorage(): Container { + if (storage) { + return storage; + } -function addTagKeyRelation(tag: string, key: Function | string | symbol) { - let keys = tagKeyMap.get(tag); - if (!keys) { - keys = new Set(); - tagKeyMap.set(tag, keys); + if (cacheConfig.container) { + storage = cacheConfig.container; + } else { + storage = new MemoryContainer({ + maxSize: cacheConfig.maxSize, + }); } - keys.add(key); + + return storage; } export function configureCache(config: CacheConfig): void { @@ -79,73 +152,7 @@ export function configureCache(config: CacheConfig): void { ...cacheConfig, ...config, }; -} - -function getLRUCache() { - if (!lruCache) { - lruCache = new LRUCache< - Function | string | symbol, - Map> - >({ - maxSize: cacheConfig.maxSize ?? CacheSize.GB, - sizeCalculation: (value: Map>): number => { - if (!value.size) { - return 1; - } - - let size = 0; - for (const [k, item] of value.entries()) { - size += k.length * 2; - size += estimateObjectSize(item.data); - size += 8; - } - return size; - }, - updateAgeOnGet: true, - updateAgeOnHas: true, - }); - } - return lruCache; -} - -function estimateObjectSize(data: unknown): number { - const type = typeof data; - - if (type === 'number') return 8; - if (type === 'boolean') return 4; - if (type === 'string') return Math.max((data as string).length * 2, 1); - if (data === null || data === undefined) return 1; - - if (ArrayBuffer.isView(data)) { - return Math.max(data.byteLength, 1); - } - - if (Array.isArray(data)) { - return Math.max( - data.reduce((acc, item) => acc + estimateObjectSize(item), 0), - 1, - ); - } - - if (data instanceof Map || data instanceof Set) { - return 1024; - } - - if (data instanceof Date) { - return 8; - } - - if (type === 'object') { - return Math.max( - Object.entries(data).reduce( - (acc, [key, value]) => acc + key.length * 2 + estimateObjectSize(value), - 0, - ), - 1, - ); - } - - return 1; + storage = undefined; } export function generateKey(args: unknown[]): string { @@ -162,26 +169,22 @@ export function generateKey(args: unknown[]): string { }); } +function generateStableFunctionId(fn: Function): string { + const fnString = fn.toString(); + let hash = 0; + for (let i = 0; i < fnString.length; i++) { + const char = fnString.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + + return `fn_${fn.name || 'anonymous'}_${Math.abs(hash).toString(36)}`; +} + export function cache Promise>( fn: T, - options?: CacheOptions, + options?: CacheOptions, ): T { - const { - tag = 'default', - maxAge = CacheTime.MINUTE * 5, - revalidate = 0, - customKey, - onCache, - getKey, - } = options || {}; - const store = getLRUCache(); - - const tags = Array.isArray(tag) ? tag : [tag]; - - const getCacheKey = (args: Parameters, generatedKey: string) => { - return customKey ? customKey({ params: args, fn, generatedKey }) : fn; - }; - return (async (...args: Parameters) => { if (isServer && typeof options === 'undefined') { const storage = getAsyncLocalStorage(); @@ -227,110 +230,183 @@ export function cache Promise>( } } } else if (typeof options !== 'undefined') { - const genKey = getKey ? getKey(...args) : generateKey(args); - const now = Date.now(); - - const cacheKey = getCacheKey(args, genKey); - const finalKey = typeof cacheKey === 'function' ? genKey : cacheKey; - - tags.forEach(t => addTagKeyRelation(t, cacheKey)); - - let cacheStore = store.get(cacheKey); - if (!cacheStore) { - cacheStore = new Map(); - } - - const storeKey = - customKey && typeof cacheKey === 'symbol' ? 'symbol-key' : genKey; - - let shouldDisableCaching = false; - if (isServer && cacheConfig.unstable_shouldDisable) { - const storage = getAsyncLocalStorage(); - const request = storage?.useContext()?.request; - if (request) { - shouldDisableCaching = await cacheConfig.unstable_shouldDisable({ - request, + try { + const { + tag, + maxAge = CacheTime.MINUTE * 5, + revalidate = 0, + customKey, + onCache, + getKey, + unstable_shouldCache, + } = options; + + let missReason: number | undefined; + + const currentStorage = getStorage(); + const now = Date.now(); + const tags = tag ? (Array.isArray(tag) ? tag : [tag]) : []; + + const genKey = getKey ? getKey(...args) : generateKey(args); + let finalKey: string; + if (customKey) { + finalKey = customKey({ + params: args, + fn, + generatedKey: genKey, }); + } else { + const functionId = generateStableFunctionId(fn); + finalKey = `${functionId}:${genKey}`; } - } - const cached = cacheStore.get(storeKey); - if (cached && !shouldDisableCaching) { - const age = now - cached.timestamp; + const storageKey = `${CACHE_PREFIX}${finalKey}`; - if (age < maxAge) { - if (onCache) { - onCache({ - status: 'hit', - key: finalKey, - params: args, - result: cached.data, + let shouldDisableCaching = false; + if (isServer && cacheConfig.unstable_shouldDisable) { + const asyncStorage = getAsyncLocalStorage(); + const request = asyncStorage?.useContext()?.request; + if (request) { + shouldDisableCaching = await cacheConfig.unstable_shouldDisable({ + request, }); } - return cached.data; } - if (revalidate > 0 && age < maxAge + revalidate) { - if (onCache) { - onCache({ - status: 'stale', - key: finalKey, - params: args, - result: cached.data, - }); - } + if (!shouldDisableCaching) { + const cachedRaw = await currentStorage.get(storageKey); + if (cachedRaw) { + try { + const cached = JSON.parse(cachedRaw) as CacheItem; + const age = now - cached.timestamp; + + if (age < maxAge) { + onCache?.({ + status: 'hit', + key: finalKey, + params: args, + result: cached.data, + }); + return cached.data; + } - if (!cached.isRevalidating) { - cached.isRevalidating = true; - Promise.resolve().then(async () => { - try { - const newData = await fn(...args); - cacheStore!.set(storeKey, { - data: newData, - timestamp: Date.now(), - isRevalidating: false, + if (revalidate > 0 && age < maxAge + revalidate) { + onCache?.({ + status: 'stale', + key: finalKey, + params: args, + result: cached.data, }); - store.set(cacheKey, cacheStore!); - } catch (error) { - cached.isRevalidating = false; - if (isServer) { - const storage = getAsyncLocalStorage(); - storage - ?.useContext() - ?.monitors?.error((error as Error).message); - } else { - console.error('Background revalidation failed:', error); + if (!ongoingRevalidations.has(storageKey)) { + const revalidationPromise = (async () => { + try { + const newData = await fn(...args); + + let shouldCache = true; + if (unstable_shouldCache) { + shouldCache = await unstable_shouldCache({ + params: args, + result: newData, + }); + } + + if (shouldCache) { + await setCacheItem( + currentStorage, + storageKey, + newData, + tags, + maxAge, + revalidate, + ); + } + } catch (error) { + if (isServer) { + const asyncStorage = getAsyncLocalStorage(); + asyncStorage + ?.useContext() + ?.monitors?.error((error as Error).message); + } else { + console.error('Background revalidation failed:', error); + } + } finally { + ongoingRevalidations.delete(storageKey); + } + })(); + + ongoingRevalidations.set(storageKey, revalidationPromise); } + + return cached.data; } - }); + missReason = 3; // cache-expired + } catch (error) { + console.warn('Failed to parse cached data:', error); + missReason = 4; // cache-parse-error + } + } else { + missReason = 2; // cache-not-found } - return cached.data; + } else { + missReason = 1; // cache-disabled } - } - const data = await fn(...args); + const data = await fn(...args); - if (!shouldDisableCaching) { - cacheStore.set(storeKey, { - data, - timestamp: now, - isRevalidating: false, - }); + if (!shouldDisableCaching) { + let shouldCache = true; + if (unstable_shouldCache) { + shouldCache = await unstable_shouldCache({ + params: args, + result: data, + }); + } - store.set(cacheKey, cacheStore); - } + if (shouldCache) { + await setCacheItem( + currentStorage, + storageKey, + data, + tags, + maxAge, + revalidate, + ); + } + } - if (onCache) { - onCache({ + onCache?.({ status: 'miss', key: finalKey, params: args, result: data, + reason: missReason, }); - } - return data; + return data; + } catch (error) { + console.warn( + 'Cache operation failed, falling back to direct execution:', + error, + ); + const data = await fn(...args); + + const { onCache } = options; + + try { + onCache?.({ + status: 'miss', + key: 'cache_failed', + params: args, + result: data, + reason: 5, + }); + } catch (callbackError) { + console.warn('Failed to call onCache callback:', callbackError); + } + + return data; + } } else { console.warn( 'The cache function will not work because it runs on the browser and there are no options are provided.', @@ -340,6 +416,68 @@ export function cache Promise>( }) as T; } +async function setCacheItem( + storage: Container, + storageKey: string, + data: any, + tags: string[], + maxAge: number, + revalidate: number, +): Promise { + const newItem: CacheItem = { + data, + timestamp: Date.now(), + tags: tags.length > 0 ? tags : undefined, + }; + + const ttl = (maxAge + revalidate) / 1000; + await storage.set(storageKey, JSON.stringify(newItem), { + ttl: ttl > 0 ? ttl : undefined, + }); + + await updateTagRelationships(storage, storageKey, tags); +} + +async function updateTagRelationships( + storage: Container, + storageKey: string, + tags: string[], +): Promise { + for (const tag of tags) { + const tagStoreKey = `${TAG_PREFIX}${tag}`; + const keyListJson = await storage.get(tagStoreKey); + const keyList: string[] = keyListJson ? JSON.parse(keyListJson) : []; + if (!keyList.includes(storageKey)) { + keyList.push(storageKey); + } + await storage.set(tagStoreKey, JSON.stringify(keyList)); + } +} + +async function removeKeyFromTags( + storage: Container, + storageKey: string, + tags: string[], +): Promise { + for (const tag of tags) { + const tagStoreKey = `${TAG_PREFIX}${tag}`; + const keyListJson = await storage.get(tagStoreKey); + if (keyListJson) { + try { + const keyList: string[] = JSON.parse(keyListJson); + const updatedKeyList = keyList.filter(key => key !== storageKey); + if (updatedKeyList.length > 0) { + await storage.set(tagStoreKey, JSON.stringify(updatedKeyList)); + } else { + await storage.delete(tagStoreKey); + } + } catch (error) { + console.warn(`Failed to parse tag key list for tag ${tag}:`, error); + } + } + } +} + export function withRequestCache< T extends (req: Request, ...args: any[]) => Promise, >(handler: T): T { @@ -353,17 +491,52 @@ export function withRequestCache< }) as T; } -export function revalidateTag(tag: string): void { - const keys = tagKeyMap.get(tag); - if (keys) { - keys.forEach(key => { - lruCache?.delete(key); - }); +export async function revalidateTag(tag: string): Promise { + const currentStorage = getStorage(); + const tagStoreKey = `${TAG_PREFIX}${tag}`; + + const keyListJson = await currentStorage.get(tagStoreKey); + if (keyListJson) { + try { + const keyList: string[] = JSON.parse(keyListJson); + + // For each cache key, we need to: + // 1. Get the cache item to find its associated tags + // 2. Remove this key from all other tag relationships + // 3. Delete the cache item itself + for (const cacheKey of keyList) { + const cachedRaw = await currentStorage.get(cacheKey); + if (cachedRaw) { + try { + const cached = JSON.parse(cachedRaw) as CacheItem; + if (cached.tags) { + // Remove this cache key from all its associated tags (except the current one being revalidated) + const otherTags = cached.tags.filter(t => t !== tag); + await removeKeyFromTags(currentStorage, cacheKey, otherTags); + } + } catch (error) { + console.warn( + 'Failed to parse cached data while revalidating:', + error, + ); + } + } + + // Delete the cache item itself + await currentStorage.delete(cacheKey); + } + + // Delete the tag relationship record + await currentStorage.delete(tagStoreKey); + } catch (error) { + console.warn('Failed to parse tag key list:', error); + } } } -export function clearStore(): void { - lruCache?.clear(); - lruCache = undefined; - tagKeyMap.clear(); +export async function clearStore(): Promise { + const currentStorage = getStorage(); + await currentStorage.clear(); + storage = undefined; + ongoingRevalidations.clear(); } diff --git a/packages/toolkit/runtime-utils/tests/universal/cache-client.test.ts b/packages/toolkit/runtime-utils/tests/universal/cache-client.test.ts index a9fb86b3b015..ca36f9726368 100644 --- a/packages/toolkit/runtime-utils/tests/universal/cache-client.test.ts +++ b/packages/toolkit/runtime-utils/tests/universal/cache-client.test.ts @@ -1,3 +1,6 @@ +/** + * @jest-environment jsdom + */ import { CacheTime, cache, clearStore } from '../../src/universal/cache'; describe('client-side behavior', () => { diff --git a/packages/toolkit/runtime-utils/tests/universal/cache-container.test.ts b/packages/toolkit/runtime-utils/tests/universal/cache-container.test.ts new file mode 100644 index 000000000000..e27eea767338 --- /dev/null +++ b/packages/toolkit/runtime-utils/tests/universal/cache-container.test.ts @@ -0,0 +1,418 @@ +/** + * @jest-environment node + */ +import Redis from 'ioredis-mock'; +import { + CacheTime, + type Container, + cache, + clearStore, + configureCache, + revalidateTag, + withRequestCache, +} from '../../src/universal/cache'; + +class MockRequest { + url: string; + method: string; + constructor(url = 'http://example.com', method = 'GET') { + this.url = url; + this.method = method; + } +} + +class RedisContainer implements Container { + private redis: InstanceType; + + private operations: string[] = []; + + constructor() { + this.redis = new Redis(); + } + + async get(key: string): Promise { + this.operations.push(`get:${key}`); + return this.redis.get(key); + } + + async set( + key: string, + value: string, + options?: { ttl?: number }, + ): Promise { + this.operations.push( + `set:${key}${options?.ttl ? `:ttl=${options.ttl}` : ''}`, + ); + if (options?.ttl && options.ttl > 0) { + await this.redis.set(key, value, 'EX', options.ttl); + } else { + await this.redis.set(key, value); + } + } + + async has(key: string): Promise { + this.operations.push(`has:${key}`); + return (await this.redis.exists(key)) === 1; + } + + async delete(key: string): Promise { + this.operations.push(`delete:${key}`); + return (await this.redis.del(key)) > 0; + } + + async clear(): Promise { + this.operations.push('clear'); + await this.redis.flushall(); + } + + getOperations(): string[] { + return [...this.operations]; + } + + clearOperations(): void { + this.operations = []; + } + + async getStoredKeys(): Promise { + return this.redis.keys('*'); + } + + static async clearAllMocks() { + await new Redis().flushall(); + } +} + +describe('Custom Container Integration', () => { + beforeEach(async () => { + jest.useFakeTimers(); + await RedisContainer.clearAllMocks(); + configureCache({ container: undefined }); + await clearStore(); + }); + + afterEach(() => { + jest.useRealTimers(); + configureCache({ container: undefined }); + }); + + describe('Basic Container Operations', () => { + it('should cache function results with custom container', async () => { + const redisContainer = new RedisContainer(); + configureCache({ container: redisContainer }); + + const mockFn = jest.fn().mockResolvedValue('test data'); + const cachedFn = cache(mockFn, { tag: 'test' }); + + const result1 = await cachedFn('param1'); + const result2 = await cachedFn('param1'); + const result3 = await cachedFn('param2'); + + expect(result1).toBe('test data'); + expect(result2).toBe('test data'); + expect(result3).toBe('test data'); + expect(mockFn).toHaveBeenCalledTimes(2); + + const operations = redisContainer.getOperations(); + expect(operations.some(op => op.startsWith('get:modernjs_cache:'))).toBe( + true, + ); + expect(operations.some(op => op.startsWith('set:modernjs_cache:'))).toBe( + true, + ); + }); + + it('should handle TTL and expiration correctly', async () => { + const redisContainer = new RedisContainer(); + configureCache({ container: redisContainer }); + + const mockFn = jest.fn().mockResolvedValue('test data'); + const cachedFn = cache(mockFn, { + maxAge: CacheTime.SECOND, + revalidate: CacheTime.SECOND, + }); + + await cachedFn('param1'); + expect(mockFn).toHaveBeenCalledTimes(1); + + const operations = redisContainer.getOperations(); + const setOperation = operations.find(op => + op.startsWith('set:modernjs_cache:'), + ); + expect(setOperation).toContain('ttl=2'); + + jest.advanceTimersByTime(CacheTime.SECOND / 2); + await cachedFn('param1'); + expect(mockFn).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(CacheTime.SECOND / 2 + 1); + await cachedFn('param1'); + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should handle container switching', async () => { + const mockFn = jest.fn().mockResolvedValue('test data'); + const cachedFn = cache(mockFn, { tag: 'test' }); + + await cachedFn('param1'); + expect(mockFn).toHaveBeenCalledTimes(1); + + const redisContainer = new RedisContainer(); + configureCache({ container: redisContainer }); + + await cachedFn('param1'); + expect(mockFn).toHaveBeenCalledTimes(2); + + const operations = redisContainer.getOperations(); + expect(operations.some(op => op.startsWith('get:modernjs_cache:'))).toBe( + true, + ); + }); + }); + + describe('Tag Management', () => { + it('should handle tags and revalidation correctly', async () => { + const redisContainer = new RedisContainer(); + configureCache({ container: redisContainer }); + + const mockFn1 = jest.fn(async (param: string): Promise => { + return 'data1'; + }); + + const mockFn2 = jest.fn(async (param: string): Promise => { + return 'data2'; + }); + + const cachedFn1 = cache(mockFn1, { + tag: ['shared', 'fn1'], + customKey: ({ generatedKey }) => `fn1:${generatedKey}`, + }); + const cachedFn2 = cache(mockFn2, { + tag: ['shared', 'fn2'], + customKey: ({ generatedKey }) => `fn2:${generatedKey}`, + }); + + await cachedFn1('param'); + await cachedFn2('param'); + expect(mockFn1).toHaveBeenCalledTimes(1); + expect(mockFn2).toHaveBeenCalledTimes(1); + + await cachedFn1('param'); + await cachedFn2('param'); + expect(mockFn1).toHaveBeenCalledTimes(1); + expect(mockFn2).toHaveBeenCalledTimes(1); + + await revalidateTag('shared'); + await cachedFn1('param'); + await cachedFn2('param'); + expect(mockFn1).toHaveBeenCalledTimes(2); + expect(mockFn2).toHaveBeenCalledTimes(2); + + await revalidateTag('fn1'); + await cachedFn1('param'); + await cachedFn2('param'); + expect(mockFn1).toHaveBeenCalledTimes(3); + expect(mockFn2).toHaveBeenCalledTimes(2); + + const operations = redisContainer.getOperations(); + expect(operations.some(op => op.startsWith('delete:tag:shared'))).toBe( + true, + ); + expect(operations.some(op => op.startsWith('delete:tag:fn1'))).toBe(true); + }); + }); + + describe('Cache Lifecycle', () => { + it('should handle stale-while-revalidate correctly', async () => { + const redisContainer = new RedisContainer(); + configureCache({ container: redisContainer }); + + const mockFn = jest + .fn() + .mockResolvedValueOnce('initial data') + .mockResolvedValueOnce('updated data'); + + const cachedFn = cache(mockFn, { + maxAge: CacheTime.SECOND, + revalidate: CacheTime.SECOND, + }); + + const result1 = await cachedFn('param'); + expect(result1).toBe('initial data'); + expect(mockFn).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(CacheTime.SECOND + 500); + + const result2 = await cachedFn('param'); + expect(result2).toBe('initial data'); + expect(mockFn).toHaveBeenCalledTimes(2); + + await jest.runOnlyPendingTimersAsync(); + await Promise.resolve(); + const result3 = await cachedFn('param'); + expect(result3).toBe('updated data'); + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should prevent duplicate revalidation requests', async () => { + const redisContainer = new RedisContainer(); + configureCache({ container: redisContainer }); + + let resolveRevalidation: (value: string) => void; + const revalidationPromise = new Promise(resolve => { + resolveRevalidation = resolve; + }); + + const mockFn = jest + .fn() + .mockResolvedValueOnce('initial data') + .mockImplementationOnce(() => revalidationPromise); + + const cachedFn = cache(mockFn, { + maxAge: CacheTime.SECOND, + revalidate: CacheTime.SECOND * 2, + }); + + await cachedFn('param'); + jest.advanceTimersByTime(CacheTime.SECOND + 500); + + const results = await Promise.all([ + cachedFn('param'), + cachedFn('param'), + cachedFn('param'), + ]); + + expect(results).toEqual(['initial data', 'initial data', 'initial data']); + expect(mockFn).toHaveBeenCalledTimes(2); + + resolveRevalidation!('updated data'); + await jest.runOnlyPendingTimersAsync(); + await Promise.resolve(); + }); + }); + + describe('Advanced Features', () => { + it('should support customKey functionality', async () => { + const redisContainer = new RedisContainer(); + configureCache({ container: redisContainer }); + + const mockFn1 = jest.fn().mockResolvedValue('data1'); + const mockFn2 = jest.fn().mockResolvedValue('data2'); + + const cachedFn1 = cache(mockFn1, { + customKey: () => 'shared-key', + }); + + const cachedFn2 = cache(mockFn2, { + customKey: () => 'shared-key', + }); + + const result1 = await cachedFn1('param'); + expect(result1).toBe('data1'); + expect(mockFn1).toHaveBeenCalledTimes(1); + + const result2 = await cachedFn2('param'); + expect(result2).toBe('data1'); + expect(mockFn2).toHaveBeenCalledTimes(0); + }); + + it('should support getKey functionality', async () => { + const redisContainer = new RedisContainer(); + configureCache({ container: redisContainer }); + + const mockFn = jest + .fn() + .mockImplementation((id, data) => + Promise.resolve(`data for ${id}: ${JSON.stringify(data)}`), + ); + + const cachedFn = cache(mockFn, { + getKey: (...args) => `user-${args[0]}`, + }); + + const result1 = await cachedFn(1, { name: 'user1', role: 'admin' }); + const result2 = await cachedFn(1, { name: 'user1', role: 'editor' }); + + expect(result1).toBe('data for 1: {"name":"user1","role":"admin"}'); + expect(result2).toBe('data for 1: {"name":"user1","role":"admin"}'); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('should trigger onCache callbacks correctly', async () => { + const redisContainer = new RedisContainer(); + configureCache({ container: redisContainer }); + + const mockFn = jest.fn().mockResolvedValue('test data'); + const onCacheMock = jest.fn(); + + const cachedFn = cache(mockFn, { + onCache: onCacheMock, + }); + + await cachedFn('param1'); + expect(onCacheMock).toHaveBeenCalledWith( + expect.objectContaining({ status: 'miss' }), + ); + + onCacheMock.mockClear(); + await cachedFn('param1'); + expect(onCacheMock).toHaveBeenCalledWith( + expect.objectContaining({ status: 'hit' }), + ); + }); + }); + + describe('Server-side Integration', () => { + it('should work correctly in server environment', async () => { + const redisContainer = new RedisContainer(); + configureCache({ container: redisContainer }); + + const mockFn = jest.fn().mockResolvedValue('test data'); + + const cachedFnWithOptions = cache(mockFn, { maxAge: CacheTime.SECOND }); + + const cachedFnWithoutOptions = cache(mockFn); + + const handler = withRequestCache(async (req: Request) => { + const result1 = await cachedFnWithOptions('param1'); + const result2 = await cachedFnWithoutOptions('param1'); + const result3 = await cachedFnWithoutOptions('param1'); + return { result1, result2, result3 }; + }); + + const { result1, result2, result3 } = await handler( + new MockRequest() as unknown as Request, + ); + + expect(result1).toBe('test data'); + expect(result2).toBe('test data'); + expect(result3).toBe('test data'); + expect(mockFn).toHaveBeenCalledTimes(2); + + const operations = redisContainer.getOperations(); + expect(operations.some(op => op.startsWith('get:modernjs_cache:'))).toBe( + true, + ); + }); + }); + + describe('Resource Management', () => { + it('should clean up resources correctly', async () => { + const redisContainer = new RedisContainer(); + configureCache({ container: redisContainer }); + + const mockFn = jest.fn().mockResolvedValue('test data'); + const cachedFn = cache(mockFn, { tag: 'cleanup' }); + + await cachedFn('param1'); + await cachedFn('param2'); + + const keysBeforeClear = await redisContainer.getStoredKeys(); + expect(keysBeforeClear.length).toBeGreaterThan(0); + + await clearStore(); + + const keysAfterClear = await redisContainer.getStoredKeys(); + expect(keysAfterClear).toHaveLength(0); + }); + }); +}); diff --git a/packages/toolkit/runtime-utils/tests/universal/cache-server.test.ts b/packages/toolkit/runtime-utils/tests/universal/cache-server.test.ts index 00e645ef717a..2b65bbad48e9 100644 --- a/packages/toolkit/runtime-utils/tests/universal/cache-server.test.ts +++ b/packages/toolkit/runtime-utils/tests/universal/cache-server.test.ts @@ -101,7 +101,7 @@ describe('cache function', () => { await cachedFn('param1'); expect(mockFn).toHaveBeenCalledTimes(1); - revalidateTag('testTag'); + await revalidateTag('testTag'); await cachedFn('param1'); expect(mockFn).toHaveBeenCalledTimes(2); @@ -115,14 +115,14 @@ describe('cache function', () => { await cachedFn('param1'); expect(mockFn).toHaveBeenCalledTimes(1); - revalidateTag('tag1'); + await revalidateTag('tag1'); await cachedFn('param1'); expect(mockFn).toHaveBeenCalledTimes(2); await cachedFn('param1'); expect(mockFn).toHaveBeenCalledTimes(2); - revalidateTag('tag2'); + await revalidateTag('tag2'); await cachedFn('param1'); expect(mockFn).toHaveBeenCalledTimes(3); }); @@ -138,14 +138,14 @@ describe('cache function', () => { expect(mockFn1).toHaveBeenCalledTimes(1); expect(mockFn2).toHaveBeenCalledTimes(1); - revalidateTag('shared'); + await revalidateTag('shared'); await cachedFn1('param1'); await cachedFn2('param2'); expect(mockFn1).toHaveBeenCalledTimes(2); expect(mockFn2).toHaveBeenCalledTimes(2); - revalidateTag('other'); + await revalidateTag('other'); await cachedFn1('param1'); await cachedFn2('param2'); expect(mockFn1).toHaveBeenCalledTimes(2); @@ -159,7 +159,6 @@ describe('cache function', () => { tag: 'testTag', }); - await expect(cachedFn('param1')).rejects.toThrow(error); await expect(cachedFn('param1')).rejects.toThrow(error); expect(mockFn).toHaveBeenCalledTimes(2); }); @@ -356,7 +355,8 @@ describe('cache function', () => { await cachedFn(1024); expect(mockFn).toHaveBeenCalledTimes(1); - await cachedFn(3 * CacheSize.KB); + await cachedFn(1025); + expect(mockFn).toHaveBeenCalledTimes(2); await cachedFn(1024); expect(mockFn).toHaveBeenCalledTimes(3); @@ -492,23 +492,6 @@ describe('cache function', () => { expect(mockFn2).toHaveBeenCalledTimes(0); }); - it('should support Symbol as customKey return value', async () => { - const SYMBOL_KEY = Symbol('test-symbol'); - const mockFn = jest.fn().mockResolvedValue('symbol data'); - - const cachedFn = cache(mockFn, { - customKey: () => SYMBOL_KEY, - }); - - const result1 = await cachedFn('param1'); - expect(result1).toBe('symbol data'); - expect(mockFn).toHaveBeenCalledTimes(1); - - const result2 = await cachedFn('param2'); - expect(result2).toBe('symbol data'); - expect(mockFn).toHaveBeenCalledTimes(1); - }); - it('should support customKey that depends on function arguments', async () => { const mockFn = jest .fn() @@ -542,7 +525,7 @@ describe('cache function', () => { await cachedFn('param'); expect(mockFn).toHaveBeenCalledTimes(1); - revalidateTag('custom-tag'); + await revalidateTag('custom-tag'); await cachedFn('param'); expect(mockFn).toHaveBeenCalledTimes(2); @@ -598,7 +581,7 @@ describe('cache function', () => { expect(onCacheMock).toHaveBeenLastCalledWith( expect.objectContaining({ status: 'hit', - key: 'constant-key', + key: expect.stringMatching(/^fn_.*:constant-key$/), }), ); }); @@ -615,7 +598,7 @@ describe('cache function', () => { await cachedFn('a'); expect(mockFn).toHaveBeenCalledTimes(1); - revalidateTag('getKey-test'); + await revalidateTag('getKey-test'); await cachedFn('a'); expect(mockFn).toHaveBeenCalledTimes(2); @@ -695,7 +678,6 @@ describe('cache function', () => { const mockFn = jest.fn().mockResolvedValue('test data'); const onCacheMock = jest.fn(); - // Case 1: Default key (function reference) const cachedFn1 = cache(mockFn, { onCache: onCacheMock, }); @@ -704,7 +686,7 @@ describe('cache function', () => { expect(onCacheMock).toHaveBeenLastCalledWith( expect.objectContaining({ status: 'miss', - key: JSON.stringify(['param1']), + key: expect.stringContaining(JSON.stringify(['param1'])), }), ); @@ -724,23 +706,6 @@ describe('cache function', () => { key: CUSTOM_KEY, }), ); - - onCacheMock.mockClear(); - - // Case 3: Custom symbol key - const SYMBOL_KEY = Symbol('test-key'); - const cachedFn3 = cache(mockFn, { - customKey: () => SYMBOL_KEY, - onCache: onCacheMock, - }); - - await cachedFn3('param1'); - expect(onCacheMock).toHaveBeenLastCalledWith( - expect.objectContaining({ - status: 'miss', - key: SYMBOL_KEY, - }), - ); }); it('should not call onCache when no options are provided', async () => { @@ -917,4 +882,145 @@ describe('cache function', () => { ); }); }); + + describe('unstable_shouldCache', () => { + beforeEach(() => { + jest.useFakeTimers(); + clearStore(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should not cache when unstable_shouldCache returns false', async () => { + const mockFn = jest.fn().mockResolvedValue('test data'); + const cachedFn = cache(mockFn, { + maxAge: CacheTime.MINUTE, + unstable_shouldCache: () => false, + }); + + const handler = withRequestCache(async () => { + await cachedFn('param1'); + await cachedFn('param1'); + }); + + await handler(); + + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should cache when unstable_shouldCache returns true', async () => { + const mockFn = jest.fn().mockResolvedValue('test data'); + const cachedFn = cache(mockFn, { + maxAge: CacheTime.MINUTE, + unstable_shouldCache: () => true, + }); + + const handler = withRequestCache(async () => { + await cachedFn('param1'); + await cachedFn('param1'); + }); + + await handler(); + + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('should receive correct parameters in unstable_shouldCache', async () => { + const mockFn = jest.fn().mockResolvedValue('test data'); + const shouldCacheMock = jest.fn().mockReturnValue(true); + + const cachedFn = cache(mockFn, { + maxAge: CacheTime.MINUTE, + unstable_shouldCache: shouldCacheMock, + }); + + const handler = withRequestCache(async () => { + await cachedFn('param1', { value: 42 }); + }); + + await handler(); + + expect(shouldCacheMock).toHaveBeenCalledTimes(1); + expect(shouldCacheMock).toHaveBeenCalledWith( + expect.objectContaining({ + params: ['param1', { value: 42 }], + result: 'test data', + }), + ); + }); + + it('should support async unstable_shouldCache function', async () => { + const mockFn = jest.fn().mockResolvedValue('test data'); + const cachedFn = cache(mockFn, { + maxAge: CacheTime.MINUTE, + unstable_shouldCache: async () => Promise.resolve(false), + }); + + const handler = withRequestCache(async () => { + await cachedFn('param1'); + await cachedFn('param1'); + }); + + await handler(); + + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should only cache if result meets the condition', async () => { + const mockFn = jest + .fn() + .mockResolvedValueOnce({ status: 'error', data: 'bad' }) + .mockResolvedValueOnce({ status: 'ok', data: 'good' }); + + const cachedFn = cache(mockFn, { + maxAge: CacheTime.MINUTE, + unstable_shouldCache: ({ result }) => result.status === 'ok', + }); + + const handler = withRequestCache(async () => { + const result1 = await cachedFn('param'); + expect(result1).toEqual({ status: 'error', data: 'bad' }); + + const result2 = await cachedFn('param'); + expect(result2).toEqual({ status: 'ok', data: 'good' }); + + const result3 = await cachedFn('param'); + expect(result3).toEqual({ status: 'ok', data: 'good' }); + }); + + await handler(); + + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should respect unstable_shouldCache false in stale revalidation', async () => { + const mockFn = jest + .fn() + .mockResolvedValueOnce('cached') + .mockResolvedValueOnce('rejected') + .mockResolvedValueOnce('new'); + + const cachedFn = cache(mockFn, { + maxAge: CacheTime.SECOND, + revalidate: CacheTime.SECOND, + unstable_shouldCache: ({ result }) => result !== 'rejected', + }); + + const handler = withRequestCache(async () => { + await cachedFn('test'); + + jest.advanceTimersByTime(CacheTime.SECOND + 10); + await cachedFn('test'); + await jest.runAllTimersAsync(); + + jest.advanceTimersByTime(CacheTime.SECOND + 10); + await cachedFn('test'); + }); + + await handler(); + expect(mockFn).toHaveBeenCalledTimes(3); + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4add4949f6c9..d4b5b19624c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2352,13 +2352,13 @@ importers: dependencies: '@rsbuild-image/core': specifier: 0.0.1-next.16 - version: 0.0.1-next.16(ipx@3.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sharp@0.34.1) + version: 0.0.1-next.16(ipx@3.0.3(ioredis@5.6.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sharp@0.34.1) '@rsbuild-image/react': specifier: 0.0.1-next.16 - version: 0.0.1-next.16(@rsbuild-image/core@0.0.1-next.16(ipx@3.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sharp@0.34.1))(ipx@3.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sharp@0.34.1) + version: 0.0.1-next.16(@rsbuild-image/core@0.0.1-next.16(ipx@3.0.3(ioredis@5.6.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sharp@0.34.1))(ipx@3.0.3(ioredis@5.6.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sharp@0.34.1) ipx: specifier: ^3.0.3 - version: 3.0.3 + version: 3.0.3(ioredis@5.6.1) sharp: specifier: ^0.34.1 version: 0.34.1 @@ -4374,6 +4374,9 @@ importers: '@scripts/jest-config': specifier: workspace:* version: link:../../../scripts/jest-config + '@types/ioredis-mock': + specifier: ^8.2.6 + version: 8.2.6(ioredis@5.6.1) '@types/jest': specifier: ^29 version: 29.5.14 @@ -4383,6 +4386,9 @@ importers: '@types/serialize-javascript': specifier: ^5.0.1 version: 5.0.4 + ioredis-mock: + specifier: ^8.9.0 + version: 8.9.0(@types/ioredis-mock@8.2.6(ioredis@5.6.1))(ioredis@5.6.1) jest: specifier: ^29 version: 29.5.0(@types/node@14.18.35)(ts-node@10.9.2(@swc/core@1.10.18(@swc/helpers@0.5.17))(@types/node@14.18.35)(typescript@5.6.3)) @@ -10638,6 +10644,12 @@ packages: cpu: [x64] os: [win32] + '@ioredis/as-callback@3.0.0': + resolution: {integrity: sha512-Kqv1rZ3WbgOrS+hgzJ5xG5WQuhvzzSTRYvNeyPMLOAM78MHSnuKI20JeJGbpuAt//LCuP0vsexZcorqW7kWhJg==} + + '@ioredis/commands@1.2.0': + resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -14040,6 +14052,11 @@ packages: '@types/invariant@2.2.37': resolution: {integrity: sha512-IwpIMieE55oGWiXkQPSBY1nw1nFs6bsKXTFskNY8sdS17K24vyEBRQZEwlRS7ZmXCWnJcQtbxWzly+cODWGs2A==} + '@types/ioredis-mock@8.2.6': + resolution: {integrity: sha512-5heqtZMvQ4nXARY0o8rc8cjkJjct2ScM12yCJ/h731S9He93a2cv+kAhwPCNwTKDfNH9gjRfLG4VpAEYJU0/gQ==} + peerDependencies: + ioredis: '>=5' + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -15404,6 +15421,10 @@ packages: resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + co-body@5.2.0: resolution: {integrity: sha512-sX/LQ7LqUhgyaxzbe7IqwPeTr2yfpfUIQ/dgpKo6ZI4y4lpQA0YxAomWIY+7I7rHWcG02PG+OuPREzMW/5tszQ==} @@ -16238,6 +16259,10 @@ packages: delegates@1.0.0: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@1.1.2: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} engines: {node: '>= 0.6'} @@ -17020,6 +17045,14 @@ packages: picomatch: optional: true + fengari-interop@0.1.3: + resolution: {integrity: sha512-EtZ+oTu3kEwVJnoymFPBVLIbQcCoy9uWCVnMA6h3M/RqHkUBsLYp29+RRHf9rKr6GwjubWREU1O7RretFIXjHw==} + peerDependencies: + fengari: ^0.1.0 + + fengari@0.1.4: + resolution: {integrity: sha512-6ujqUuiIYmcgkGz8MGAdERU57EIluGGPSUgGPTsco657EHa+srq0S3/YUl/r9kx1+D+d4rGfYObd+m8K22gB1g==} + fetch-retry@5.0.6: resolution: {integrity: sha512-3yurQZ2hD9VISAhJJP9bpYFNQrHHBXE2JxxjY5aLEcDi46RmAzJE2OC9FAde0yis5ElW0jTTzs0zfg/Cca4XqQ==} @@ -17881,6 +17914,17 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + ioredis-mock@8.9.0: + resolution: {integrity: sha512-yIglcCkI1lvhwJVoMsR51fotZVsPsSk07ecTCgRTRlicG0Vq3lke6aAaHklyjmRNRsdYAgswqC2A0bPtQK4LSw==} + engines: {node: '>=12.22'} + peerDependencies: + '@types/ioredis-mock': ^8 + ioredis: ^5 + + ioredis@5.6.1: + resolution: {integrity: sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==} + engines: {node: '>=12.22.0'} + ip-regex@2.1.0: resolution: {integrity: sha512-58yWmlHpp7VYfcdTwMTvwMmqx/Elfxjd9RXTDyMsbL7lLWmhMylLEqiYVLKuLzOZqVgiWXD9MfR62Vv89VRxkw==} engines: {node: '>=4'} @@ -18665,6 +18709,9 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.flatmap@4.5.0: resolution: {integrity: sha512-/OcpcAGWlrZyoHGeHh3cAoa6nGdX6QYtmzNP84Jqol6UEQQ2gIaU3H+0eICcjcKGl0/XF8LWOujNn9lffsnaOg==} @@ -18678,6 +18725,9 @@ packages: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. @@ -21315,6 +21365,10 @@ packages: resolution: {integrity: sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==} engines: {node: '>= 14.16.0'} + readline-sync@1.4.10: + resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==} + engines: {node: '>= 0.8.0'} + recast@0.23.9: resolution: {integrity: sha512-Hx/BGIbwj+Des3+xy5uAtAbdCyqK9y9wbBcDFDYanLS9JnMqf7OeF87HQwUimE87OEc72mr6tkKUKMBBL+hF9Q==} engines: {node: '>= 4'} @@ -21339,6 +21393,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reduce-configs@1.1.0: resolution: {integrity: sha512-DQxy6liNadHfrLahZR7lMdc227NYVaQZhY5FMsxLEjX8X0SCuH+ESHSLCoz2yDZFq1/CLMDOAHdsEHwOEXKtvg==} @@ -22131,6 +22193,9 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + stack-generator@2.0.10: resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} @@ -22150,6 +22215,9 @@ packages: stacktrace-js@2.0.2: resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + starting@8.0.3: resolution: {integrity: sha512-kk2co1LglBnwEEprHUI96khhi4vWhgQlloeGF5XNF+z+Mo6x4fof3kcf2t0iWgDuw+5Z12B/Y1WqgfTQUsawow==} engines: {node: '>= 0.10.0'} @@ -26026,6 +26094,10 @@ snapshots: '@img/sharp-win32-x64@0.34.1': optional: true + '@ioredis/as-callback@3.0.0': {} + + '@ioredis/commands@1.2.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -28989,7 +29061,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.34.8': optional: true - '@rsbuild-image/core@0.0.1-next.16(ipx@3.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sharp@0.34.1)': + '@rsbuild-image/core@0.0.1-next.16(ipx@3.0.3(ioredis@5.6.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sharp@0.34.1)': dependencies: '@sindresorhus/is': 7.0.1 image-size: 2.0.2 @@ -28999,19 +29071,19 @@ snapshots: type-fest: 4.40.0 ufo: 1.5.4 optionalDependencies: - ipx: 3.0.3 + ipx: 3.0.3(ioredis@5.6.1) sharp: 0.34.1 - '@rsbuild-image/react@0.0.1-next.16(@rsbuild-image/core@0.0.1-next.16(ipx@3.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sharp@0.34.1))(ipx@3.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sharp@0.34.1)': + '@rsbuild-image/react@0.0.1-next.16(@rsbuild-image/core@0.0.1-next.16(ipx@3.0.3(ioredis@5.6.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sharp@0.34.1))(ipx@3.0.3(ioredis@5.6.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sharp@0.34.1)': dependencies: - '@rsbuild-image/core': 0.0.1-next.16(ipx@3.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sharp@0.34.1) + '@rsbuild-image/core': 0.0.1-next.16(ipx@3.0.3(ioredis@5.6.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sharp@0.34.1) '@sindresorhus/is': 7.0.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) type-fest: 4.40.0 ufo: 1.5.4 optionalDependencies: - ipx: 3.0.3 + ipx: 3.0.3(ioredis@5.6.1) sharp: 0.34.1 '@rsbuild/core@1.2.3': @@ -31030,6 +31102,10 @@ snapshots: '@types/invariant@2.2.37': {} + '@types/ioredis-mock@8.2.6(ioredis@5.6.1)': + dependencies: + ioredis: 5.6.1 + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -32789,6 +32865,8 @@ snapshots: clsx@1.2.1: {} + cluster-key-slot@1.1.2: {} + co-body@5.2.0: dependencies: inflation: 2.0.0 @@ -33646,6 +33724,8 @@ snapshots: delegates@1.0.0: {} + denque@2.1.0: {} + depd@1.1.2: {} depd@2.0.0: {} @@ -34573,6 +34653,16 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fengari-interop@0.1.3(fengari@0.1.4): + dependencies: + fengari: 0.1.4 + + fengari@0.1.4: + dependencies: + readline-sync: 1.4.10 + sprintf-js: 1.1.3 + tmp: 0.0.33 + fetch-retry@5.0.6: {} fflate@0.8.2: {} @@ -35667,11 +35757,35 @@ snapshots: dependencies: loose-envify: 1.4.0 + ioredis-mock@8.9.0(@types/ioredis-mock@8.2.6(ioredis@5.6.1))(ioredis@5.6.1): + dependencies: + '@ioredis/as-callback': 3.0.0 + '@ioredis/commands': 1.2.0 + '@types/ioredis-mock': 8.2.6(ioredis@5.6.1) + fengari: 0.1.4 + fengari-interop: 0.1.3(fengari@0.1.4) + ioredis: 5.6.1 + semver: 7.7.2 + + ioredis@5.6.1: + dependencies: + '@ioredis/commands': 1.2.0 + cluster-key-slot: 1.1.2 + debug: 4.3.7(supports-color@5.5.0) + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ip-regex@2.1.0: {} ipaddr.js@1.9.1: {} - ipx@3.0.3: + ipx@3.0.3(ioredis@5.6.1): dependencies: '@fastify/accept-negotiator': 2.0.1 citty: 0.1.6 @@ -35687,7 +35801,7 @@ snapshots: sharp: 0.33.5 svgo: 3.3.2 ufo: 1.5.4 - unstorage: 1.15.0 + unstorage: 1.15.0(ioredis@5.6.1) xss: 1.0.15 transitivePeerDependencies: - '@azure/app-configuration' @@ -37032,6 +37146,8 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.defaults@4.2.0: {} + lodash.flatmap@4.5.0: {} lodash.flattendeep@4.4.0: {} @@ -37040,6 +37156,8 @@ snapshots: lodash.get@4.4.2: {} + lodash.isarguments@3.1.0: {} + lodash.isequal@4.5.0: {} lodash.isfunction@3.0.9: {} @@ -40955,6 +41073,8 @@ snapshots: readdirp@4.0.2: {} + readline-sync@1.4.10: {} + recast@0.23.9: dependencies: ast-types: 0.16.1 @@ -41002,6 +41122,12 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reduce-configs@1.1.0: {} reduce-css-calc@2.1.8: @@ -41991,6 +42117,8 @@ snapshots: sprintf-js@1.0.3: {} + sprintf-js@1.1.3: {} + stack-generator@2.0.10: dependencies: stackframe: 1.3.4 @@ -42014,6 +42142,8 @@ snapshots: stack-generator: 2.0.10 stacktrace-gps: 3.1.2 + standard-as-callback@2.1.0: {} + starting@8.0.3: dependencies: commander: 2.7.1 @@ -43095,7 +43225,7 @@ snapshots: optionalDependencies: webpack-sources: 3.2.3 - unstorage@1.15.0: + unstorage@1.15.0(ioredis@5.6.1): dependencies: anymatch: 3.1.3 chokidar: 4.0.3 @@ -43105,6 +43235,8 @@ snapshots: node-fetch-native: 1.6.6 ofetch: 1.4.1 ufo: 1.5.4 + optionalDependencies: + ioredis: 5.6.1 untildify@4.0.0: {}