├── .npmrc ├── .gitattributes ├── .gitignore ├── .github ├── security.md └── workflows │ └── main.yml ├── .editorconfig ├── index.test-d.ts ├── package.json ├── license ├── index.d.ts ├── readme.md ├── index.js └── test.js /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | .nyc_output 4 | coverage 5 | -------------------------------------------------------------------------------- /.github/security.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 20 14 | - 18 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm test 22 | # TODO: Disabled until `nyc` supports ESM. 23 | # - uses: codecov/codecov-action@v1 24 | # if: matrix.node-version == 14 25 | # with: 26 | # fail_ci_if_error: true 27 | -------------------------------------------------------------------------------- /index.test-d.ts: -------------------------------------------------------------------------------- 1 | import {expectType} from 'tsd'; 2 | import QuickLRU from './index.js'; 3 | 4 | const lru = new QuickLRU({maxSize: 1000, maxAge: 200}); 5 | 6 | expectType>(lru.set('🦄', 1).set('🌈', 2)); 7 | expectType(lru.get('🦄')); 8 | expectType(lru.has('🦄')); 9 | expectType(lru.peek('🦄')); 10 | expectType(lru.expiresIn('🦄')); 11 | expectType(lru.delete('🦄')); 12 | expectType(lru.size); 13 | expectType(lru.maxAge); 14 | 15 | for (const [key, value] of lru) { 16 | expectType(key); 17 | expectType(value); 18 | } 19 | 20 | for (const key of lru.keys()) { 21 | expectType(key); 22 | } 23 | 24 | for (const value of lru.values()) { 25 | expectType(value); 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quick-lru", 3 | "version": "7.3.0", 4 | "description": "Simple “Least Recently Used” (LRU) cache", 5 | "license": "MIT", 6 | "repository": "sindresorhus/quick-lru", 7 | "funding": "https://github.com/sponsors/sindresorhus", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "exports": { 15 | "types": "./index.d.ts", 16 | "default": "./index.js" 17 | }, 18 | "sideEffects": false, 19 | "engines": { 20 | "node": ">=18" 21 | }, 22 | "scripts": { 23 | "test": "xo && nyc ava && tsd" 24 | }, 25 | "files": [ 26 | "index.js", 27 | "index.d.ts" 28 | ], 29 | "keywords": [ 30 | "lru", 31 | "quick", 32 | "cache", 33 | "caching", 34 | "least", 35 | "recently", 36 | "used", 37 | "fast", 38 | "map", 39 | "hash", 40 | "buffer" 41 | ], 42 | "devDependencies": { 43 | "ava": "^5.3.1", 44 | "nyc": "^15.1.0", 45 | "tsd": "^0.29.0", 46 | "xo": "^0.56.0" 47 | }, 48 | "nyc": { 49 | "reporter": [ 50 | "text", 51 | "lcov" 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export type Options = { 2 | /** 3 | The maximum number of milliseconds an item should remain in the cache. 4 | 5 | @default Infinity 6 | 7 | By default, `maxAge` will be `Infinity`, which means that items will never expire. 8 | Lazy expiration occurs upon the next write or read call. 9 | 10 | Individual expiration of an item can be specified with the `set(key, value, {maxAge})` method. 11 | */ 12 | readonly maxAge?: number; 13 | 14 | /** 15 | The target maximum number of items before evicting the least recently used items. 16 | 17 | __Note:__ This package uses an [algorithm](https://github.com/sindresorhus/quick-lru#algorithm) which maintains between `maxSize` and `2 × maxSize` items for performance reasons. The cache may temporarily contain up to twice the specified size due to the dual-cache design that avoids expensive delete operations. 18 | */ 19 | readonly maxSize: number; 20 | 21 | /** 22 | Called right before an item is evicted from the cache due to LRU pressure, TTL expiration, or manual eviction via `evict()`. 23 | 24 | Useful for side effects or for items like object URLs that need explicit cleanup (`revokeObjectURL`). 25 | 26 | __Note:__ This callback is not called for manual removals via `delete()` or `clear()`. It fires for automatic evictions and manual evictions via `evict()`. 27 | */ 28 | onEviction?: (key: KeyType, value: ValueType) => void; 29 | }; 30 | 31 | // eslint-disable-next-line @typescript-eslint/naming-convention 32 | export default class QuickLRU extends Map implements Iterable<[KeyType, ValueType]> { 33 | /** 34 | Simple ["Least Recently Used" (LRU) cache](https://en.m.wikipedia.org/wiki/Cache_replacement_policies#Least_Recently_Used_.28LRU.29). 35 | 36 | The instance is an [`Iterable`](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Iteration_protocols) of `[key, value]` pairs so you can use it directly in a [`for…of`](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Statements/for...of) loop. 37 | 38 | @example 39 | ``` 40 | import QuickLRU from 'quick-lru'; 41 | 42 | const lru = new QuickLRU({maxSize: 1000}); 43 | 44 | lru.set('🦄', '🌈'); 45 | 46 | lru.has('🦄'); 47 | //=> true 48 | 49 | lru.get('🦄'); 50 | //=> '🌈' 51 | ``` 52 | */ 53 | constructor(options: Options); 54 | 55 | [Symbol.iterator](): IterableIterator<[KeyType, ValueType]>; 56 | 57 | /** 58 | Set an item. Returns the instance. 59 | 60 | Individual expiration of an item can be specified with the `maxAge` option. If not specified, the global `maxAge` value will be used in case it is specified in the constructor; otherwise the item will never expire. 61 | 62 | @returns The cache instance. 63 | */ 64 | set(key: KeyType, value: ValueType, options?: {maxAge?: number}): this; 65 | 66 | /** 67 | Get an item. 68 | 69 | @returns The stored item or `undefined`. 70 | */ 71 | get(key: KeyType): ValueType | undefined; 72 | 73 | /** 74 | Check if an item exists. 75 | */ 76 | has(key: KeyType): boolean; 77 | 78 | /** 79 | Get an item without marking it as recently used. 80 | 81 | @returns The stored item or `undefined`. 82 | */ 83 | peek(key: KeyType): ValueType | undefined; 84 | 85 | /** 86 | Delete an item. 87 | 88 | @returns `true` if the item is removed or `false` if the item doesn't exist. 89 | */ 90 | delete(key: KeyType): boolean; 91 | 92 | /** 93 | Delete all items. 94 | */ 95 | clear(): void; 96 | 97 | /** 98 | Get the remaining time to live (in milliseconds) for the given item, or `undefined` when the item is not in the cache. 99 | 100 | - Does not mark the item as recently used. 101 | - Does not trigger lazy expiration or remove the entry when it is expired. 102 | - Returns `Infinity` if the item has no expiration. 103 | - May return a negative number if the item is already expired but not yet lazily removed. 104 | 105 | @returns Remaining time to live in milliseconds when set, `Infinity` when there is no expiration, or `undefined` when the item does not exist. 106 | */ 107 | expiresIn(key: KeyType): number | undefined; 108 | 109 | /** 110 | Update the `maxSize` in-place, discarding items as necessary. Insertion order is mostly preserved, though this is not a strong guarantee. 111 | 112 | Useful for on-the-fly tuning of cache sizes in live systems. 113 | */ 114 | resize(maxSize: number): void; 115 | 116 | /** 117 | The stored item count. 118 | */ 119 | get size(): number; 120 | 121 | /** 122 | The set max size. 123 | */ 124 | get maxSize(): number; 125 | 126 | /** 127 | The set max age. 128 | */ 129 | get maxAge(): number; 130 | 131 | /** 132 | Iterable for all the keys. 133 | */ 134 | keys(): IterableIterator; 135 | 136 | /** 137 | Iterable for all the values. 138 | */ 139 | values(): IterableIterator; 140 | 141 | /** 142 | Iterable for all entries, starting with the oldest (ascending in recency). 143 | */ 144 | entriesAscending(): IterableIterator<[KeyType, ValueType]>; 145 | 146 | /** 147 | Iterable for all entries, starting with the newest (descending in recency). 148 | */ 149 | entriesDescending(): IterableIterator<[KeyType, ValueType]>; 150 | 151 | /** 152 | Evict the least recently used items from the cache. 153 | 154 | @param count - The number of items to evict. Defaults to 1. 155 | 156 | It will always keep at least one item in the cache. 157 | 158 | @example 159 | ``` 160 | import QuickLRU from 'quick-lru'; 161 | 162 | const lru = new QuickLRU({maxSize: 10}); 163 | 164 | lru.set('a', 1); 165 | lru.set('b', 2); 166 | lru.set('c', 3); 167 | 168 | lru.evict(2); // Evicts 'a' and 'b' 169 | 170 | console.log(lru.has('a')); 171 | //=> false 172 | 173 | console.log(lru.has('c')); 174 | //=> true 175 | ``` 176 | */ 177 | evict(count?: number): void; 178 | } 179 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # quick-lru [![Coverage Status](https://codecov.io/gh/sindresorhus/quick-lru/branch/main/graph/badge.svg)](https://codecov.io/gh/sindresorhus/quick-lru/branch/main) 2 | 3 | > Simple [“Least Recently Used” (LRU) cache](https://en.m.wikipedia.org/wiki/Cache_replacement_policies#Least_Recently_Used_.28LRU.29) 4 | 5 | Useful when you need to cache something and limit memory usage. 6 | 7 | See the [algorithm section](#algorithm) for implementation details. 8 | 9 | ## Install 10 | 11 | ```sh 12 | npm install quick-lru 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```js 18 | import QuickLRU from 'quick-lru'; 19 | 20 | const lru = new QuickLRU({maxSize: 1000}); 21 | 22 | lru.set('🦄', '🌈'); 23 | 24 | lru.has('🦄'); 25 | //=> true 26 | 27 | lru.get('🦄'); 28 | //=> '🌈' 29 | ``` 30 | 31 | ## API 32 | 33 | ### new QuickLRU(options?) 34 | 35 | Returns a new instance. 36 | 37 | It's a [`Map`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Map) subclass. 38 | 39 | ### options 40 | 41 | Type: `object` 42 | 43 | #### maxSize 44 | 45 | *Required*\ 46 | Type: `number` 47 | 48 | The target maximum number of items before evicting the least recently used items. 49 | 50 | > [!NOTE] 51 | > This package uses an [algorithm](#algorithm) which maintains between `maxSize` and `2 × maxSize` items for performance reasons. The cache may temporarily contain up to twice the specified size due to the dual-cache design that avoids expensive delete operations. 52 | 53 | #### maxAge 54 | 55 | Type: `number`\ 56 | Default: `Infinity` 57 | 58 | The maximum number of milliseconds an item should remain in the cache. 59 | By default, `maxAge` will be `Infinity`, which means that items will never expire. 60 | 61 | Lazy expiration occurs upon the next `write` or `read` call. 62 | 63 | Individual expiration of an item can be specified by the `set(key, value, options)` method. 64 | 65 | #### onEviction 66 | 67 | *Optional*\ 68 | Type: `(key, value) => void` 69 | 70 | Called right before an item is evicted from the cache due to LRU pressure, TTL expiration, or manual eviction via `evict()`. 71 | 72 | Useful for side effects or for items like object URLs that need explicit cleanup (`revokeObjectURL`). 73 | 74 | > [!NOTE] 75 | > This callback is **not** called for manual removals via `delete()` or `clear()`. It fires for automatic evictions and manual evictions via `evict()`. 76 | 77 | ### Instance 78 | 79 | The instance is an [`Iterable`](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Iteration_protocols) of `[key, value]` pairs so you can use it directly in a [`for…of`](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Statements/for...of) loop. 80 | 81 | Both `key` and `value` can be of any type. 82 | 83 | #### .set(key, value, options?) 84 | 85 | Set an item. Returns the instance. 86 | 87 | Individual expiration of an item can be specified with the `maxAge` option. If not specified, the global `maxAge` value will be used in case it is specified in the constructor; otherwise, the item will never expire. 88 | 89 | #### .get(key) 90 | 91 | Get an item. 92 | 93 | #### .has(key) 94 | 95 | Check if an item exists. 96 | 97 | #### .peek(key) 98 | 99 | Get an item without marking it as recently used. 100 | 101 | #### .delete(key) 102 | 103 | Delete an item. 104 | 105 | Returns `true` if the item is removed or `false` if the item doesn't exist. 106 | 107 | #### .clear() 108 | 109 | Delete all items. 110 | 111 | #### .expiresIn(key) 112 | 113 | Get the remaining time to live (in milliseconds) for the given item, or `undefined` if the item is not in the cache. 114 | 115 | - Does not mark the item as recently used. 116 | - Does not trigger lazy expiration or remove the entry when it’s expired. 117 | - Returns `Infinity` if the item has no expiration (`maxAge` not set for the item and no global `maxAge`). 118 | - May return a negative number if the item has already expired but has not yet been lazily removed. 119 | 120 | #### .resize(maxSize) 121 | 122 | Update the `maxSize`, discarding items as necessary. Insertion order is mostly preserved, though this is not a strong guarantee. 123 | 124 | Useful for on-the-fly tuning of cache sizes in live systems. 125 | 126 | #### .evict(count?) 127 | 128 | Evict the least recently used items from the cache. 129 | 130 | The `count` parameter specifies how many items to evict. Defaults to 1. 131 | 132 | It will always keep at least one item in the cache. 133 | 134 | ```js 135 | import QuickLRU from 'quick-lru'; 136 | 137 | const lru = new QuickLRU({maxSize: 10}); 138 | 139 | lru.set('a', 1); 140 | lru.set('b', 2); 141 | lru.set('c', 3); 142 | 143 | lru.evict(2); // Evicts 'a' and 'b' 144 | 145 | console.log(lru.has('a')); 146 | //=> false 147 | 148 | console.log(lru.has('c')); 149 | //=> true 150 | ``` 151 | 152 | #### .keys() 153 | 154 | Iterable for all the keys. 155 | 156 | #### .values() 157 | 158 | Iterable for all the values. 159 | 160 | #### .entriesAscending() 161 | 162 | Iterable for all entries, starting with the oldest (ascending in recency). 163 | 164 | #### .entriesDescending() 165 | 166 | Iterable for all entries, starting with the newest (descending in recency). 167 | 168 | #### .entries() 169 | 170 | Iterable for all entries, starting with the oldest (ascending in recency). 171 | 172 | **This method exists for `Map` compatibility. Prefer [.entriesAscending()](#entriesascending) instead.** 173 | 174 | #### .forEach(callbackFunction, thisArgument) 175 | 176 | Loop over entries calling the `callbackFunction` for each entry (ascending in recency). 177 | 178 | **This method exists for `Map` compatibility. Prefer [.entriesAscending()](#entriesascending) instead.** 179 | 180 | #### .size *(getter)* 181 | 182 | The stored item count. 183 | 184 | #### .maxSize *(getter)* 185 | 186 | The set max size. 187 | 188 | #### .maxAge *(getter)* 189 | 190 | The set max age. 191 | 192 | ## Algorithm 193 | 194 | This library implements a variant of the [hashlru algorithm](https://github.com/dominictarr/hashlru#algorithm) using JavaScript's `Map` for broader key type support. 195 | 196 | ### How it works 197 | 198 | The algorithm uses a dual-cache approach with two `Map` objects: 199 | 200 | 1. New cache - Stores recently accessed items 201 | 2. Old cache - Stores less recently accessed items 202 | 203 | On `set()` operations: 204 | - If the key exists in the new cache, update it 205 | - Otherwise, add the key-value pair to the new cache 206 | - When the new cache reaches `maxSize`, promote it to become the old cache and create a fresh new cache 207 | 208 | On `get()` operations: 209 | - If the key is in the new cache, return it directly 210 | - If the key is in the old cache, move it to the new cache (promoting its recency) 211 | 212 | ### Benefits 213 | 214 | - Performance: Avoids expensive `delete` operations that can cause performance issues in JavaScript engines 215 | - Simplicity: No complex linked list management required 216 | - Cache efficiency: Maintains LRU semantics while being much faster than traditional implementations 217 | 218 | ### Trade-offs 219 | 220 | - Size variance: The cache can contain between `maxSize` and `2 × maxSize` items temporarily 221 | - Memory overhead: Uses up to twice the target memory compared to strict LRU implementations 222 | 223 | ### When to use 224 | 225 | Choose this implementation when: 226 | - You need high-performance caching with many operations 227 | - You can tolerate temporary size variance for better performance 228 | - You want simple, reliable caching without complex data structures 229 | 230 | Consider alternatives when: 231 | - You need strict memory limits (exactly `maxSize` items) 232 | - Memory usage is more critical than performance 233 | 234 | ## Related 235 | 236 | - [yocto-queue](https://github.com/sindresorhus/yocto-queue) - Tiny queue data structure 237 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export default class QuickLRU extends Map { 2 | #size = 0; 3 | #cache = new Map(); 4 | #oldCache = new Map(); 5 | #maxSize; 6 | #maxAge; 7 | #onEviction; 8 | 9 | constructor(options = {}) { 10 | super(); 11 | 12 | if (!(options.maxSize && options.maxSize > 0)) { 13 | throw new TypeError('`maxSize` must be a number greater than 0'); 14 | } 15 | 16 | if (typeof options.maxAge === 'number' && options.maxAge === 0) { 17 | throw new TypeError('`maxAge` must be a number greater than 0'); 18 | } 19 | 20 | this.#maxSize = options.maxSize; 21 | this.#maxAge = options.maxAge || Number.POSITIVE_INFINITY; 22 | this.#onEviction = options.onEviction; 23 | } 24 | 25 | // For tests. 26 | get __oldCache() { 27 | return this.#oldCache; 28 | } 29 | 30 | #emitEvictions(cache) { 31 | if (typeof this.#onEviction !== 'function') { 32 | return; 33 | } 34 | 35 | for (const [key, item] of cache) { 36 | this.#onEviction(key, item.value); 37 | } 38 | } 39 | 40 | #deleteIfExpired(key, item) { 41 | if (typeof item.expiry === 'number' && item.expiry <= Date.now()) { 42 | if (typeof this.#onEviction === 'function') { 43 | this.#onEviction(key, item.value); 44 | } 45 | 46 | return this.delete(key); 47 | } 48 | 49 | return false; 50 | } 51 | 52 | #getOrDeleteIfExpired(key, item) { 53 | const deleted = this.#deleteIfExpired(key, item); 54 | if (deleted === false) { 55 | return item.value; 56 | } 57 | } 58 | 59 | #getItemValue(key, item) { 60 | return item.expiry ? this.#getOrDeleteIfExpired(key, item) : item.value; 61 | } 62 | 63 | #peek(key, cache) { 64 | const item = cache.get(key); 65 | return this.#getItemValue(key, item); 66 | } 67 | 68 | #set(key, value) { 69 | this.#cache.set(key, value); 70 | this.#size++; 71 | 72 | if (this.#size >= this.#maxSize) { 73 | this.#size = 0; 74 | this.#emitEvictions(this.#oldCache); 75 | this.#oldCache = this.#cache; 76 | this.#cache = new Map(); 77 | } 78 | } 79 | 80 | #moveToRecent(key, item) { 81 | this.#oldCache.delete(key); 82 | this.#set(key, item); 83 | } 84 | 85 | * #entriesAscending() { 86 | for (const item of this.#oldCache) { 87 | const [key, value] = item; 88 | if (!this.#cache.has(key)) { 89 | const deleted = this.#deleteIfExpired(key, value); 90 | if (deleted === false) { 91 | yield item; 92 | } 93 | } 94 | } 95 | 96 | for (const item of this.#cache) { 97 | const [key, value] = item; 98 | const deleted = this.#deleteIfExpired(key, value); 99 | if (deleted === false) { 100 | yield item; 101 | } 102 | } 103 | } 104 | 105 | get(key) { 106 | if (this.#cache.has(key)) { 107 | const item = this.#cache.get(key); 108 | return this.#getItemValue(key, item); 109 | } 110 | 111 | if (this.#oldCache.has(key)) { 112 | const item = this.#oldCache.get(key); 113 | if (this.#deleteIfExpired(key, item) === false) { 114 | this.#moveToRecent(key, item); 115 | return item.value; 116 | } 117 | } 118 | } 119 | 120 | set(key, value, {maxAge = this.#maxAge} = {}) { 121 | const expiry = typeof maxAge === 'number' && maxAge !== Number.POSITIVE_INFINITY 122 | ? (Date.now() + maxAge) 123 | : undefined; 124 | 125 | if (this.#cache.has(key)) { 126 | this.#cache.set(key, { 127 | value, 128 | expiry, 129 | }); 130 | } else { 131 | this.#set(key, {value, expiry}); 132 | } 133 | 134 | return this; 135 | } 136 | 137 | has(key) { 138 | if (this.#cache.has(key)) { 139 | return !this.#deleteIfExpired(key, this.#cache.get(key)); 140 | } 141 | 142 | if (this.#oldCache.has(key)) { 143 | return !this.#deleteIfExpired(key, this.#oldCache.get(key)); 144 | } 145 | 146 | return false; 147 | } 148 | 149 | peek(key) { 150 | if (this.#cache.has(key)) { 151 | return this.#peek(key, this.#cache); 152 | } 153 | 154 | if (this.#oldCache.has(key)) { 155 | return this.#peek(key, this.#oldCache); 156 | } 157 | } 158 | 159 | expiresIn(key) { 160 | const item = this.#cache.get(key) ?? this.#oldCache.get(key); 161 | if (item) { 162 | return item.expiry ? item.expiry - Date.now() : Number.POSITIVE_INFINITY; 163 | } 164 | } 165 | 166 | delete(key) { 167 | const deleted = this.#cache.delete(key); 168 | if (deleted) { 169 | this.#size--; 170 | } 171 | 172 | return this.#oldCache.delete(key) || deleted; 173 | } 174 | 175 | clear() { 176 | this.#cache.clear(); 177 | this.#oldCache.clear(); 178 | this.#size = 0; 179 | } 180 | 181 | resize(newSize) { 182 | if (!(newSize && newSize > 0)) { 183 | throw new TypeError('`maxSize` must be a number greater than 0'); 184 | } 185 | 186 | const items = [...this.#entriesAscending()]; 187 | const removeCount = items.length - newSize; 188 | if (removeCount < 0) { 189 | this.#cache = new Map(items); 190 | this.#oldCache = new Map(); 191 | this.#size = items.length; 192 | } else { 193 | if (removeCount > 0) { 194 | this.#emitEvictions(items.slice(0, removeCount)); 195 | } 196 | 197 | this.#oldCache = new Map(items.slice(removeCount)); 198 | this.#cache = new Map(); 199 | this.#size = 0; 200 | } 201 | 202 | this.#maxSize = newSize; 203 | } 204 | 205 | evict(count = 1) { 206 | const requested = Number(count); 207 | if (!requested || requested <= 0) { 208 | return; 209 | } 210 | 211 | const items = [...this.#entriesAscending()]; 212 | const evictCount = Math.trunc(Math.min(requested, Math.max(items.length - 1, 0))); 213 | if (evictCount <= 0) { 214 | return; 215 | } 216 | 217 | this.#emitEvictions(items.slice(0, evictCount)); 218 | this.#oldCache = new Map(items.slice(evictCount)); 219 | this.#cache = new Map(); 220 | this.#size = 0; 221 | } 222 | 223 | * keys() { 224 | for (const [key] of this) { 225 | yield key; 226 | } 227 | } 228 | 229 | * values() { 230 | for (const [, value] of this) { 231 | yield value; 232 | } 233 | } 234 | 235 | * [Symbol.iterator]() { 236 | for (const item of this.#cache) { 237 | const [key, value] = item; 238 | const deleted = this.#deleteIfExpired(key, value); 239 | if (deleted === false) { 240 | yield [key, value.value]; 241 | } 242 | } 243 | 244 | for (const item of this.#oldCache) { 245 | const [key, value] = item; 246 | if (!this.#cache.has(key)) { 247 | const deleted = this.#deleteIfExpired(key, value); 248 | if (deleted === false) { 249 | yield [key, value.value]; 250 | } 251 | } 252 | } 253 | } 254 | 255 | * entriesDescending() { 256 | let items = [...this.#cache]; 257 | for (let i = items.length - 1; i >= 0; --i) { 258 | const item = items[i]; 259 | const [key, value] = item; 260 | const deleted = this.#deleteIfExpired(key, value); 261 | if (deleted === false) { 262 | yield [key, value.value]; 263 | } 264 | } 265 | 266 | items = [...this.#oldCache]; 267 | for (let i = items.length - 1; i >= 0; --i) { 268 | const item = items[i]; 269 | const [key, value] = item; 270 | if (!this.#cache.has(key)) { 271 | const deleted = this.#deleteIfExpired(key, value); 272 | if (deleted === false) { 273 | yield [key, value.value]; 274 | } 275 | } 276 | } 277 | } 278 | 279 | * entriesAscending() { 280 | for (const [key, value] of this.#entriesAscending()) { 281 | yield [key, value.value]; 282 | } 283 | } 284 | 285 | get size() { 286 | if (!this.#size) { 287 | return this.#oldCache.size; 288 | } 289 | 290 | let oldCacheSize = 0; 291 | for (const key of this.#oldCache.keys()) { 292 | if (!this.#cache.has(key)) { 293 | oldCacheSize++; 294 | } 295 | } 296 | 297 | return Math.min(this.#size + oldCacheSize, this.#maxSize); 298 | } 299 | 300 | get maxSize() { 301 | return this.#maxSize; 302 | } 303 | 304 | get maxAge() { 305 | return this.#maxAge; 306 | } 307 | 308 | entries() { 309 | return this.entriesAscending(); 310 | } 311 | 312 | forEach(callbackFunction, thisArgument = this) { 313 | for (const [key, value] of this.entriesAscending()) { 314 | callbackFunction.call(thisArgument, value, key, this); 315 | } 316 | } 317 | 318 | get [Symbol.toStringTag]() { 319 | return 'QuickLRU'; 320 | } 321 | 322 | toString() { 323 | return `QuickLRU(${this.size}/${this.maxSize})`; 324 | } 325 | 326 | [Symbol.for('nodejs.util.inspect.custom')]() { 327 | return this.toString(); 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import {setTimeout as delay} from 'node:timers/promises'; 2 | import test from 'ava'; 3 | import QuickLRU from './index.js'; 4 | 5 | const lruWithDuplicates = () => { 6 | const lru = new QuickLRU({maxSize: 2}); 7 | lru.set('key', 'value'); 8 | lru.set('keyDupe', 1); 9 | lru.set('keyDupe', 2); 10 | return lru; 11 | }; 12 | 13 | test('main', t => { 14 | t.throws(() => { 15 | new QuickLRU(); // eslint-disable-line no-new 16 | }, {message: /maxSize/}); 17 | }); 18 | 19 | test('maxAge: throws on invalid value', t => { 20 | t.throws(() => { 21 | new QuickLRU({maxSize: 10, maxAge: 0}); // eslint-disable-line no-new 22 | }, {message: /maxAge/}); 23 | }); 24 | 25 | test('.get() / .set()', t => { 26 | const lru = new QuickLRU({maxSize: 100}); 27 | lru.set('foo', 1); 28 | const setReturnValue = lru.set('bar', 2); 29 | t.is(setReturnValue, lru); 30 | t.is(lru.get('foo'), 1); 31 | t.is(lru.size, 2); 32 | }); 33 | 34 | test('.get() - limit', t => { 35 | const lru = new QuickLRU({maxSize: 2}); 36 | lru.set('1', 1); 37 | lru.set('2', 2); 38 | t.is(lru.get('1'), 1); 39 | t.is(lru.get('3'), undefined); 40 | lru.set('3', 3); 41 | lru.get('1'); 42 | lru.set('4', 4); 43 | lru.get('1'); 44 | lru.set('5', 5); 45 | t.true(lru.has('1')); 46 | }); 47 | 48 | test('.set() - limit', t => { 49 | const lru = new QuickLRU({maxSize: 2}); 50 | lru.set('foo', 1); 51 | lru.set('bar', 2); 52 | t.is(lru.get('foo'), 1); 53 | t.is(lru.get('bar'), 2); 54 | lru.set('baz', 3); 55 | lru.set('faz', 4); 56 | t.false(lru.has('foo')); 57 | t.false(lru.has('bar')); 58 | t.true(lru.has('baz')); 59 | t.true(lru.has('faz')); 60 | t.is(lru.size, 2); 61 | }); 62 | 63 | test('.set() - update item', t => { 64 | const lru = new QuickLRU({maxSize: 100}); 65 | lru.set('foo', 1); 66 | t.is(lru.get('foo'), 1); 67 | lru.set('foo', 2); 68 | t.is(lru.get('foo'), 2); 69 | t.is(lru.size, 1); 70 | }); 71 | 72 | test('.has()', t => { 73 | const lru = new QuickLRU({maxSize: 100}); 74 | lru.set('foo', 1); 75 | t.true(lru.has('foo')); 76 | }); 77 | 78 | test('.peek()', t => { 79 | const lru = new QuickLRU({maxSize: 2}); 80 | lru.set('1', 1); 81 | t.is(lru.peek('1'), 1); 82 | lru.set('2', 2); 83 | t.is(lru.peek('1'), 1); 84 | t.is(lru.peek('3'), undefined); 85 | lru.set('3', 3); 86 | lru.set('4', 4); 87 | t.false(lru.has('1')); 88 | }); 89 | 90 | test('expiresIn() returns undefined for missing key', t => { 91 | const lru = new QuickLRU({maxSize: 100}); 92 | t.is(lru.expiresIn('nope'), undefined); 93 | }); 94 | 95 | test('expiresIn() returns Infinity when no maxAge', t => { 96 | const lru = new QuickLRU({maxSize: 100}); 97 | lru.set('infinity', 'no ttl given'); 98 | t.is(lru.expiresIn('infinity'), Number.POSITIVE_INFINITY); 99 | }); 100 | 101 | test('expiresIn() returns remaining ms for expiring item', async t => { 102 | const lru = new QuickLRU({maxSize: 100}); 103 | lru.set('100ms', 'ttl given', {maxAge: 100}); 104 | t.is(lru.expiresIn('100ms'), 100); 105 | await delay(50); 106 | const remainingMs = lru.expiresIn('100ms'); 107 | t.true(remainingMs > 40 && remainingMs < 60); 108 | }); 109 | 110 | test('expiresIn() returns <= 0 when expired and does not evict', async t => { 111 | const lru = new QuickLRU({maxSize: 100}); 112 | lru.set('short', 'value', {maxAge: 20}); 113 | await delay(30); 114 | const remainingMs = lru.expiresIn('short'); 115 | t.true(remainingMs <= 0); 116 | }); 117 | 118 | test('.delete()', t => { 119 | const lru = new QuickLRU({maxSize: 100}); 120 | lru.set('foo', 1); 121 | lru.set('bar', 2); 122 | t.true(lru.delete('foo')); 123 | t.false(lru.has('foo')); 124 | t.true(lru.has('bar')); 125 | t.false(lru.delete('foo')); 126 | t.is(lru.size, 1); 127 | }); 128 | 129 | test('.delete() - limit', t => { 130 | const lru = new QuickLRU({maxSize: 2}); 131 | lru.set('foo', 1); 132 | lru.set('bar', 2); 133 | t.is(lru.size, 2); 134 | t.true(lru.delete('foo')); 135 | t.false(lru.has('foo')); 136 | t.true(lru.has('bar')); 137 | lru.delete('bar'); 138 | t.is(lru.size, 0); 139 | }); 140 | 141 | test('.clear()', t => { 142 | const lru = new QuickLRU({maxSize: 2}); 143 | lru.set('foo', 1); 144 | lru.set('bar', 2); 145 | lru.set('baz', 3); 146 | lru.clear(); 147 | t.is(lru.size, 0); 148 | }); 149 | 150 | test('.keys()', t => { 151 | const lru = new QuickLRU({maxSize: 2}); 152 | lru.set('1', 1); 153 | lru.set('2', 2); 154 | lru.set('3', 3); 155 | t.deepEqual([...lru.keys()].sort(), ['1', '2', '3']); 156 | }); 157 | 158 | test('.keys() - accounts for duplicates', t => { 159 | const lru = lruWithDuplicates(); 160 | t.deepEqual([...lru.keys()].sort(), ['key', 'keyDupe']); 161 | }); 162 | 163 | test('.values()', t => { 164 | const lru = new QuickLRU({maxSize: 2}); 165 | lru.set('1', 1); 166 | lru.set('2', 2); 167 | lru.set('3', 3); 168 | t.deepEqual([...lru.values()].sort(), [1, 2, 3]); 169 | }); 170 | 171 | test('.values() - accounts for duplicates', t => { 172 | const lru = lruWithDuplicates(); 173 | t.deepEqual([...lru.values()].sort(), [2, 'value']); 174 | }); 175 | 176 | test('.[Symbol.iterator]()', t => { 177 | const lru = new QuickLRU({maxSize: 2}); 178 | lru.set('1', 1); 179 | lru.set('2', 2); 180 | lru.set('3', 3); 181 | t.deepEqual([...lru].sort(), [['1', 1], ['2', 2], ['3', 3]]); 182 | }); 183 | 184 | test('.[Symbol.iterator]() - accounts for duplicates', t => { 185 | const lru = lruWithDuplicates(); 186 | t.deepEqual([...lru].sort(), [['key', 'value'], ['keyDupe', 2]]); 187 | }); 188 | 189 | test('.size', t => { 190 | const lru = new QuickLRU({maxSize: 100}); 191 | lru.set('1', 1); 192 | lru.set('2', 2); 193 | t.is(lru.size, 2); 194 | lru.delete('1'); 195 | t.is(lru.size, 1); 196 | lru.set('3', 3); 197 | t.is(lru.size, 2); 198 | }); 199 | 200 | test('.size - accounts for duplicates', t => { 201 | const lru = lruWithDuplicates(); 202 | t.is(lru.size, 2); 203 | }); 204 | 205 | test('max size', t => { 206 | const lru = new QuickLRU({maxSize: 3}); 207 | lru.set('1', 1); 208 | lru.set('2', 2); 209 | lru.set('3', 3); 210 | t.is(lru.size, 3); 211 | lru.set('4', 4); 212 | t.is(lru.size, 3); 213 | }); 214 | 215 | test('.maxSize', t => { 216 | const maxSize = 100; 217 | const lru = new QuickLRU({maxSize}); 218 | t.is(lru.maxSize, maxSize); 219 | }); 220 | 221 | test('.maxAge', t => { 222 | const lru = new QuickLRU({maxSize: 1}); 223 | t.is(lru.maxAge, Number.POSITIVE_INFINITY); 224 | }); 225 | 226 | test('checks total cache size does not exceed `maxSize`', t => { 227 | const lru = new QuickLRU({maxSize: 2}); 228 | lru.set('1', 1); 229 | lru.set('2', 2); 230 | lru.get('1'); 231 | t.is(lru.__oldCache.has('1'), false); 232 | }); 233 | 234 | test('`onEviction` is called after `maxSize` is exceeded', t => { 235 | const expectedKey = '1'; 236 | const expectedValue = 1; 237 | let evictionCalled = false; 238 | let actualKey; 239 | let actualValue; 240 | 241 | const onEviction = (key, value) => { 242 | actualKey = key; 243 | actualValue = value; 244 | evictionCalled = true; 245 | }; 246 | 247 | const lru = new QuickLRU({maxSize: 1, onEviction}); 248 | lru.set(expectedKey, expectedValue); 249 | lru.set('2', 2); 250 | t.is(actualKey, expectedKey); 251 | t.is(actualValue, expectedValue); 252 | t.true(evictionCalled); 253 | }); 254 | 255 | test('set(maxAge): an item can have a custom expiration', async t => { 256 | const lru = new QuickLRU({maxSize: 10}); 257 | lru.set('1', 'test', {maxAge: 100}); 258 | await delay(200); 259 | t.false(lru.has('1')); 260 | }); 261 | 262 | test('set(maxAge): items without expiration never expire', async t => { 263 | const lru = new QuickLRU({maxSize: 10}); 264 | lru.set('1', 'test', {maxAge: 100}); 265 | lru.set('2', 'boo'); 266 | await delay(200); 267 | t.false(lru.has('1')); 268 | await delay(200); 269 | t.true(lru.has('2')); 270 | }); 271 | 272 | test('set(maxAge): ignores non-numeric maxAge option', async t => { 273 | const lru = new QuickLRU({maxSize: 10}); 274 | lru.set('1', 'test', 'string'); 275 | lru.set('2', 'boo'); 276 | await delay(200); 277 | t.true(lru.has('1')); 278 | await delay(200); 279 | t.true(lru.has('2')); 280 | }); 281 | 282 | test('set(maxAge): per-item maxAge overrides global maxAge', async t => { 283 | const lru = new QuickLRU({maxSize: 10, maxAge: 1000}); 284 | lru.set('1', 'test', {maxAge: 100}); 285 | lru.set('2', 'boo'); 286 | await delay(300); 287 | t.false(lru.has('1')); 288 | await delay(200); 289 | t.true(lru.has('2')); 290 | }); 291 | 292 | test('set(maxAge): setting the same key refreshes expiration', async t => { 293 | const lru = new QuickLRU({maxSize: 10, maxAge: 150}); 294 | lru.set('1', 'test'); 295 | await delay(100); 296 | lru.set('1', 'test'); 297 | await delay(100); 298 | t.true(lru.has('1')); 299 | }); 300 | 301 | test('set(maxAge): is returned by getter', t => { 302 | const maxAge = 100; 303 | const lru = new QuickLRU({maxSize: 1, maxAge}); 304 | t.is(lru.maxAge, maxAge); 305 | }); 306 | 307 | test('maxAge: get() removes an expired item', async t => { 308 | const lru = new QuickLRU({maxSize: 10, maxAge: 90}); 309 | lru.set('1', 'test'); 310 | await delay(200); 311 | t.is(lru.get('1'), undefined); 312 | }); 313 | 314 | test('maxAge: non-recent items can also expire', async t => { 315 | const lru = new QuickLRU({maxSize: 2, maxAge: 100}); 316 | lru.set('1', 'test1'); 317 | lru.set('2', 'test2'); 318 | lru.set('3', 'test4'); 319 | await delay(200); 320 | t.is(lru.get('1'), undefined); 321 | }); 322 | 323 | test('maxAge: setting the same key refreshes expiration', async t => { 324 | const lru = new QuickLRU({maxSize: 2, maxAge: 100}); 325 | lru.set('1', 'test'); 326 | await delay(50); 327 | lru.set('1', 'test2'); 328 | await delay(50); 329 | t.is(lru.get('1'), 'test2'); 330 | }); 331 | 332 | test('maxAge: setting an item with a local expiration', async t => { 333 | const lru = new QuickLRU({maxSize: 2, maxAge: 100}); 334 | lru.set('1', 'test'); 335 | lru.set('2', 'test2', {maxAge: 500}); 336 | await delay(200); 337 | t.true(lru.has('2')); 338 | await delay(300); 339 | t.false(lru.has('2')); 340 | }); 341 | 342 | test('maxAge: empty options object uses global maxAge', async t => { 343 | const lru = new QuickLRU({maxSize: 2, maxAge: 100}); 344 | lru.set('1', 'test'); 345 | lru.set('2', 'test2', {}); 346 | await delay(200); 347 | t.false(lru.has('2')); 348 | }); 349 | 350 | test('maxAge: calls onEviction for expired recent item', async t => { 351 | t.timeout(1000); 352 | const expectedKey = '1'; 353 | const expectedValue = 'test'; 354 | 355 | let evictionCalled = false; 356 | let actualKey; 357 | let actualValue; 358 | const onEviction = (key, value) => { 359 | evictionCalled = true; 360 | actualKey = key; 361 | actualValue = value; 362 | }; 363 | 364 | const lru = new QuickLRU({ 365 | maxSize: 2, 366 | maxAge: 100, 367 | onEviction, 368 | }); 369 | 370 | lru.set(expectedKey, expectedValue); 371 | 372 | await delay(200); 373 | 374 | t.is(lru.get('1'), undefined); 375 | t.true(evictionCalled); 376 | t.is(actualKey, expectedKey); 377 | t.is(actualValue, expectedValue); 378 | }); 379 | 380 | test('maxAge: calls onEviction for expired non-recent items', async t => { 381 | t.timeout(1000); 382 | const expectedKeys = ['1', '2']; 383 | const expectedValues = ['test', 'test2']; 384 | 385 | let evictionCalled = false; 386 | const actualKeys = []; 387 | const actualValues = []; 388 | const onEviction = (key, value) => { 389 | evictionCalled = true; 390 | actualKeys.push(key); 391 | actualValues.push(value); 392 | }; 393 | 394 | const lru = new QuickLRU({ 395 | maxSize: 2, 396 | maxAge: 100, 397 | onEviction, 398 | }); 399 | 400 | lru.set('1', 'test'); 401 | lru.set('2', 'test2'); 402 | lru.set('3', 'test3'); 403 | lru.set('4', 'test4'); 404 | lru.set('5', 'test5'); 405 | 406 | await delay(200); 407 | 408 | t.is(lru.get('1'), undefined); 409 | t.true(evictionCalled); 410 | t.deepEqual(actualKeys, expectedKeys); 411 | t.deepEqual(actualValues, expectedValues); 412 | }); 413 | 414 | test('maxAge: evicts expired items on resize', async t => { 415 | t.timeout(1000); 416 | const expectedKeys = ['1', '2', '3']; 417 | const expectedValues = ['test', 'test2', 'test3']; 418 | 419 | let evictionCalled = false; 420 | const actualKeys = []; 421 | const actualValues = []; 422 | const onEviction = (key, value) => { 423 | evictionCalled = true; 424 | actualKeys.push(key); 425 | actualValues.push(value); 426 | }; 427 | 428 | const lru = new QuickLRU({ 429 | maxSize: 3, 430 | maxAge: 100, 431 | onEviction, 432 | }); 433 | 434 | lru.set('1', 'test'); 435 | lru.set('2', 'test2'); 436 | lru.set('3', 'test3'); 437 | lru.set('4', 'test4'); 438 | lru.set('5', 'test5'); 439 | 440 | lru.resize(2); 441 | 442 | await delay(200); 443 | 444 | t.false(lru.has('1')); 445 | t.true(evictionCalled); 446 | t.deepEqual(actualKeys, expectedKeys); 447 | t.deepEqual(actualValues, expectedValues); 448 | }); 449 | 450 | test('maxAge: peek() returns non-expired items', async t => { 451 | const lru = new QuickLRU({maxSize: 10, maxAge: 400}); 452 | lru.set('1', 'test'); 453 | await delay(200); 454 | t.is(lru.peek('1'), 'test'); 455 | }); 456 | 457 | test('maxAge: peek() lazily removes expired recent items', async t => { 458 | const lru = new QuickLRU({maxSize: 10, maxAge: 100}); 459 | lru.set('1', 'test'); 460 | await delay(200); 461 | t.is(lru.peek('1'), undefined); 462 | }); 463 | 464 | test('maxAge: peek() lazily removes expired non-recent items', async t => { 465 | const lru = new QuickLRU({maxSize: 2, maxAge: 100}); 466 | lru.set('1', 'test'); 467 | lru.set('2', 'test'); 468 | lru.set('3', 'test'); 469 | await delay(200); 470 | t.is(lru.peek('1'), undefined); 471 | }); 472 | 473 | test('maxAge: non-recent items not expired are valid', async t => { 474 | const lru = new QuickLRU({maxSize: 2, maxAge: 200}); 475 | lru.set('1', 'test'); 476 | lru.set('2', 'test2'); 477 | lru.set('3', 'test4'); 478 | await delay(100); 479 | t.is(lru.get('1'), 'test'); 480 | }); 481 | 482 | test('maxAge: has() deletes expired items and returns false', async t => { 483 | const lru = new QuickLRU({maxSize: 4, maxAge: 100}); 484 | lru.set('1', undefined); 485 | lru.set('2', 'test'); 486 | lru.set('3', 'test'); 487 | await delay(200); 488 | t.false(lru.has('1')); 489 | }); 490 | 491 | test('maxAge: has() returns true when not expired', t => { 492 | const lru = new QuickLRU({maxSize: 4, maxAge: 100}); 493 | lru.set('1', undefined); 494 | lru.set('2', 'test'); 495 | lru.set('3', 'test'); 496 | t.true(lru.has('1')); 497 | }); 498 | 499 | test('maxAge: has() returns true for undefined values with expiration', t => { 500 | const lru = new QuickLRU({maxSize: 2, maxAge: 100}); 501 | lru.set('1', undefined); 502 | lru.set('2', 'test'); 503 | lru.set('3', 'test'); 504 | t.true(lru.has('1')); 505 | }); 506 | 507 | test('maxAge: keys() returns only non-expired keys', async t => { 508 | const lru = new QuickLRU({maxSize: 2, maxAge: 100}); 509 | lru.set('1', undefined); 510 | lru.set('2', 'test2'); 511 | lru.set('3', 'test3'); 512 | await delay(200); 513 | lru.set('4', 'loco'); 514 | 515 | t.deepEqual([...lru.keys()].sort(), ['4']); 516 | }); 517 | 518 | test('maxAge: keys() returns empty when all items expired', async t => { 519 | const lru = new QuickLRU({maxSize: 2, maxAge: 100}); 520 | lru.set('1', undefined); 521 | lru.set('2', 'test2'); 522 | lru.set('3', 'test3'); 523 | await delay(200); 524 | 525 | t.deepEqual([...lru.keys()].sort(), []); 526 | }); 527 | 528 | test('maxAge: values() returns empty when all items expired', async t => { 529 | const lru = new QuickLRU({maxSize: 2, maxAge: 100}); 530 | lru.set('1', undefined); 531 | lru.set('2', 'test2'); 532 | lru.set('3', 'test3'); 533 | await delay(200); 534 | 535 | t.deepEqual([...lru.values()].sort(), []); 536 | }); 537 | 538 | test('maxAge: values() returns only non-expired values', async t => { 539 | const lru = new QuickLRU({maxSize: 2, maxAge: 100}); 540 | lru.set('1', undefined); 541 | lru.set('2', 'test2'); 542 | lru.set('3', 'test3'); 543 | await delay(200); 544 | lru.set('5', 'loco'); 545 | 546 | t.deepEqual([...lru.values()].sort(), ['loco']); 547 | }); 548 | 549 | test('maxAge: entriesDescending() excludes expired entries', async t => { 550 | const lru = new QuickLRU({maxSize: 10, maxAge: 100}); 551 | lru.set('1', undefined); 552 | lru.set('2', 'test2'); 553 | lru.set('3', 'test3'); 554 | await delay(200); 555 | lru.set('4', 'coco'); 556 | lru.set('5', 'loco'); 557 | 558 | t.deepEqual([...lru.entriesDescending()], [['5', 'loco'], ['4', 'coco']]); 559 | }); 560 | 561 | test('maxAge: entriesDescending() excludes expired entries from old cache', async t => { 562 | const lru = new QuickLRU({maxSize: 2, maxAge: 100}); 563 | lru.set('1', undefined); 564 | lru.set('2', 'test2'); 565 | lru.set('3', 'test3'); 566 | await delay(200); 567 | lru.set('4', 'coco'); 568 | lru.set('5', 'loco'); 569 | 570 | t.deepEqual([...lru.entriesDescending()], [['5', 'loco'], ['4', 'coco']]); 571 | }); 572 | 573 | test('maxAge: entriesDescending() returns all non-expired entries in order', async t => { 574 | const lru = new QuickLRU({maxSize: 10, maxAge: 5000}); 575 | lru.set('1', undefined); 576 | lru.set('2', 'test2'); 577 | lru.set('3', 'test3'); 578 | await delay(200); 579 | lru.set('4', 'coco'); 580 | lru.set('5', 'loco'); 581 | 582 | t.deepEqual([...lru.entriesDescending()], [['5', 'loco'], ['4', 'coco'], ['3', 'test3'], ['2', 'test2'], ['1', undefined]]); 583 | }); 584 | 585 | test('maxAge: entriesAscending() excludes expired entries', async t => { 586 | const lru = new QuickLRU({maxSize: 5, maxAge: 100}); 587 | lru.set('1', undefined); 588 | lru.set('2', 'test2'); 589 | lru.set('3', 'test3'); 590 | await delay(200); 591 | lru.set('4', 'coco'); 592 | lru.set('5', 'loco'); 593 | 594 | t.deepEqual([...lru.entriesAscending()], [['4', 'coco'], ['5', 'loco']]); 595 | }); 596 | 597 | test('maxAge: entriesAscending() excludes expired non-recent entries', async t => { 598 | const lru = new QuickLRU({maxSize: 3, maxAge: 100}); 599 | lru.set('1', undefined); 600 | lru.set('2', 'test2'); 601 | lru.set('3', 'test3'); 602 | await delay(200); 603 | lru.set('4', 'coco'); 604 | lru.set('5', 'loco'); 605 | 606 | t.deepEqual([...lru.entriesAscending()], [['4', 'coco'], ['5', 'loco']]); 607 | }); 608 | 609 | test('maxAge: entriesAscending() returns only non-expired entries', async t => { 610 | const lru = new QuickLRU({maxSize: 10, maxAge: 100}); 611 | lru.set('1', undefined); 612 | lru.set('2', 'test2'); 613 | await delay(200); 614 | lru.set('3', 'test3'); 615 | lru.set('4', 'coco'); 616 | lru.set('5', 'loco'); 617 | 618 | t.deepEqual([...lru.entriesAscending()], [['3', 'test3'], ['4', 'coco'], ['5', 'loco']]); 619 | }); 620 | 621 | test('maxAge: entries() returns only non-expired entries', async t => { 622 | const lru = new QuickLRU({maxSize: 10, maxAge: 100}); 623 | lru.set('1', undefined); 624 | lru.set('2', 'test2'); 625 | await delay(200); 626 | lru.set('3', 'test3'); 627 | lru.set('4', 'coco'); 628 | lru.set('5', 'loco'); 629 | 630 | t.deepEqual([...lru.entries()], [['3', 'test3'], ['4', 'coco'], ['5', 'loco']]); 631 | }); 632 | 633 | test('maxAge: forEach() excludes expired entries', async t => { 634 | const lru = new QuickLRU({maxSize: 5, maxAge: 100}); 635 | lru.set('1', undefined); 636 | lru.set('2', 'test2'); 637 | lru.set('3', 'test3'); 638 | await delay(200); 639 | lru.set('4', 'coco'); 640 | lru.set('5', 'loco'); 641 | const entries = []; 642 | 643 | for (const [key, value] of lru.entries()) { 644 | entries.push([key, value]); 645 | } 646 | 647 | t.deepEqual(entries, [['4', 'coco'], ['5', 'loco']]); 648 | }); 649 | 650 | test('maxAge: iterator excludes expired items', async t => { 651 | const lru = new QuickLRU({maxSize: 2, maxAge: 100}); 652 | lru.set('key', 'value'); 653 | lru.set('key3', 1); 654 | await delay(200); 655 | lru.set('key4', 2); 656 | 657 | t.deepEqual([...lru].sort(), [['key4', 2]]); 658 | }); 659 | 660 | test('maxAge: iterator excludes expired items from old cache', async t => { 661 | const lru = new QuickLRU({maxSize: 1, maxAge: 100}); 662 | lru.set('keyunique', 'value'); 663 | lru.set('key3unique', 1); 664 | lru.set('key4unique', 2); 665 | await delay(200); 666 | 667 | t.deepEqual([...lru].sort(), []); 668 | }); 669 | 670 | test('entriesAscending enumerates cache items oldest-first', t => { 671 | const lru = new QuickLRU({maxSize: 3}); 672 | lru.set('1', 1); 673 | lru.set('2', 2); 674 | lru.set('3', 3); 675 | lru.set('3', 7); 676 | lru.set('2', 8); 677 | t.deepEqual([...lru.entriesAscending()], [['1', 1], ['3', 7], ['2', 8]]); 678 | }); 679 | 680 | test('entriesDescending enumerates cache items newest-first', t => { 681 | const lru = new QuickLRU({maxSize: 3}); 682 | lru.set('t', 1); 683 | lru.set('q', 2); 684 | lru.set('a', 8); 685 | lru.set('t', 4); 686 | lru.set('v', 3); 687 | t.deepEqual([...lru.entriesDescending()], [['v', 3], ['t', 4], ['a', 8], ['q', 2]]); 688 | }); 689 | 690 | test('entries enumerates cache items oldest-first', t => { 691 | const lru = new QuickLRU({maxSize: 3}); 692 | lru.set('1', 1); 693 | lru.set('2', 2); 694 | lru.set('3', 3); 695 | lru.set('3', 7); 696 | lru.set('2', 8); 697 | t.deepEqual([...lru.entries()], [['1', 1], ['3', 7], ['2', 8]]); 698 | }); 699 | 700 | test('forEach calls the cb function for each cache item oldest-first', t => { 701 | const lru = new QuickLRU({maxSize: 3}); 702 | lru.set('1', 1); 703 | lru.set('2', 2); 704 | lru.set('3', 3); 705 | lru.set('3', 7); 706 | lru.set('2', 8); 707 | const entries = []; 708 | 709 | for (const [key, value] of lru.entries()) { 710 | entries.push([key, value]); 711 | } 712 | 713 | t.deepEqual(entries, [['1', 1], ['3', 7], ['2', 8]]); 714 | }); 715 | 716 | test('resize removes older items', t => { 717 | const lru = new QuickLRU({maxSize: 2}); 718 | lru.set('1', 1); 719 | lru.set('2', 2); 720 | lru.set('3', 3); 721 | lru.resize(1); 722 | t.is(lru.peek('1'), undefined); 723 | t.is(lru.peek('3'), 3); 724 | lru.set('3', 4); 725 | t.is(lru.peek('3'), 4); 726 | lru.set('4', 5); 727 | t.is(lru.peek('4'), 5); 728 | t.is(lru.peek('2'), undefined); 729 | }); 730 | 731 | test('resize omits evictions', t => { 732 | const calls = []; 733 | const onEviction = (...args) => calls.push(args); 734 | const lru = new QuickLRU({maxSize: 2, onEviction}); 735 | 736 | lru.set('1', 1); 737 | lru.set('2', 2); 738 | lru.set('3', 3); 739 | lru.resize(1); 740 | t.true(calls.length > 0); 741 | t.true(calls.some(([key]) => key === '1')); 742 | }); 743 | 744 | test('resize increases capacity', t => { 745 | const lru = new QuickLRU({maxSize: 2}); 746 | lru.set('1', 1); 747 | lru.set('2', 2); 748 | lru.resize(3); 749 | lru.set('3', 3); 750 | lru.set('4', 4); 751 | lru.set('5', 5); 752 | t.deepEqual([...lru.entriesAscending()], [['1', 1], ['2', 2], ['3', 3], ['4', 4], ['5', 5]]); 753 | }); 754 | 755 | test('resize does not conflict with the same number of items', t => { 756 | const lru = new QuickLRU({maxSize: 2}); 757 | lru.set('1', 1); 758 | lru.set('2', 2); 759 | lru.set('3', 3); 760 | lru.resize(3); 761 | lru.set('4', 4); 762 | lru.set('5', 5); 763 | t.deepEqual([...lru.entriesAscending()], [['1', 1], ['2', 2], ['3', 3], ['4', 4], ['5', 5]]); 764 | }); 765 | 766 | test('resize checks parameter bounds', t => { 767 | const lru = new QuickLRU({maxSize: 2}); 768 | t.throws(() => { 769 | lru.resize(-1); 770 | }, {message: /maxSize/}); 771 | }); 772 | 773 | test('function value', t => { 774 | const lru = new QuickLRU({maxSize: 1}); 775 | let isCalled = false; 776 | 777 | lru.set('fn', () => { 778 | isCalled = true; 779 | }); 780 | 781 | lru.get('fn')(); 782 | t.true(isCalled); 783 | }); 784 | 785 | test('[Symbol.toStringTag] output', t => { 786 | const lru = new QuickLRU({maxSize: 2}); 787 | lru.set('1', 1); 788 | t.is(lru[Symbol.toStringTag], 'QuickLRU'); 789 | }); 790 | 791 | test('toString() works as expected', t => { 792 | const lru = new QuickLRU({maxSize: 2}); 793 | lru.set('1', 1); 794 | lru.set('2', 2); 795 | t.is(lru.toString(), 'QuickLRU(2/2)'); 796 | }); 797 | 798 | test('non-primitive key', t => { 799 | const lru = new QuickLRU({maxSize: 99}); 800 | const key = ['foo', 'bar']; 801 | const value = true; 802 | lru.set(key, value); 803 | t.true(lru.has(key)); 804 | t.is(lru.get(key), value); 805 | }); 806 | 807 | test('handles circular references gracefully', t => { 808 | const lru = new QuickLRU({maxSize: 2}); 809 | 810 | const object1 = {name: 'object1'}; 811 | const object2 = {name: 'object2'}; 812 | object1.ref = object2; 813 | object2.ref = object1; 814 | 815 | lru.set('key1', object1); 816 | lru.set('key2', object2); 817 | 818 | t.notThrows(() => { 819 | String(lru); 820 | }); 821 | 822 | t.is(lru.toString(), 'QuickLRU(2/2)'); 823 | t.is(Object.prototype.toString.call(lru), '[object QuickLRU]'); 824 | }); 825 | 826 | test('.evict() removes least recently used items', t => { 827 | const lru = new QuickLRU({maxSize: 5}); 828 | 829 | lru.set('a', 1); 830 | lru.set('b', 2); 831 | lru.set('c', 3); 832 | lru.set('d', 4); 833 | lru.set('e', 5); 834 | 835 | // Evict 2 least recently used items 836 | lru.evict(2); 837 | 838 | t.is(lru.size, 3); 839 | t.false(lru.has('a')); 840 | t.false(lru.has('b')); 841 | t.true(lru.has('c')); 842 | t.true(lru.has('d')); 843 | t.true(lru.has('e')); 844 | }); 845 | 846 | test('.evict() with accessed items changes order', t => { 847 | const lru = new QuickLRU({maxSize: 5}); 848 | 849 | lru.set('a', 1); 850 | lru.set('b', 2); 851 | lru.set('c', 3); 852 | lru.set('d', 4); 853 | lru.set('e', 5); 854 | 855 | // Access 'a' and 'b' to make them recently used 856 | lru.get('a'); 857 | lru.get('b'); 858 | 859 | // Should evict 'c' and 'd' (now the oldest) 860 | lru.evict(2); 861 | 862 | t.is(lru.size, 3); 863 | t.true(lru.has('a')); 864 | t.true(lru.has('b')); 865 | t.false(lru.has('c')); 866 | t.false(lru.has('d')); 867 | t.true(lru.has('e')); 868 | }); 869 | 870 | test('.evict() keeps at least one item', t => { 871 | const lru = new QuickLRU({maxSize: 5}); 872 | 873 | lru.set('a', 1); 874 | lru.set('b', 2); 875 | lru.set('c', 3); 876 | 877 | // Try to evict all items (should keep the most recent) 878 | lru.evict(10); 879 | 880 | t.is(lru.size, 1); 881 | t.true(lru.has('c')); 882 | }); 883 | 884 | test('.evict() triggers onEviction callback', t => { 885 | const evicted = []; 886 | const lru = new QuickLRU({ 887 | maxSize: 5, 888 | onEviction(key, value) { 889 | evicted.push({key, value}); 890 | }, 891 | }); 892 | 893 | lru.set('a', 1); 894 | lru.set('b', 2); 895 | lru.set('c', 3); 896 | 897 | lru.evict(2); 898 | 899 | t.is(evicted.length, 2); 900 | t.deepEqual(evicted[0], {key: 'a', value: 1}); 901 | t.deepEqual(evicted[1], {key: 'b', value: 2}); 902 | }); 903 | 904 | test('.evict() default count is 1', t => { 905 | const lru = new QuickLRU({maxSize: 5}); 906 | 907 | lru.set('a', 1); 908 | lru.set('b', 2); 909 | lru.set('c', 3); 910 | 911 | // Evict without parameter (should default to 1) 912 | lru.evict(); 913 | 914 | t.is(lru.size, 2); 915 | t.false(lru.has('a')); 916 | t.true(lru.has('b')); 917 | t.true(lru.has('c')); 918 | }); 919 | 920 | test('.evict() with zero or negative count does nothing', t => { 921 | const lru = new QuickLRU({maxSize: 5}); 922 | 923 | lru.set('a', 1); 924 | lru.set('b', 2); 925 | 926 | const initialSize = lru.size; 927 | 928 | lru.evict(0); 929 | t.is(lru.size, initialSize); 930 | 931 | lru.evict(-5); 932 | t.is(lru.size, initialSize); 933 | }); 934 | 935 | test('.evict() on empty cache does nothing', t => { 936 | const lru = new QuickLRU({maxSize: 5}); 937 | 938 | t.notThrows(() => { 939 | lru.evict(5); 940 | }); 941 | 942 | t.is(lru.size, 0); 943 | }); 944 | 945 | test('.evict() respects maxSize after eviction', t => { 946 | const lru = new QuickLRU({maxSize: 5}); 947 | 948 | lru.set('a', 1); 949 | lru.set('b', 2); 950 | lru.set('c', 3); 951 | lru.set('d', 4); 952 | lru.set('e', 5); 953 | 954 | // Evict 2 items 955 | lru.evict(2); 956 | t.is(lru.size, 3); 957 | 958 | // Can still add up to maxSize 959 | lru.set('f', 6); 960 | lru.set('g', 7); 961 | t.is(lru.size, 5); 962 | 963 | // Adding more triggers normal LRU eviction 964 | lru.set('h', 8); 965 | lru.set('i', 9); 966 | t.is(lru.size, 5); 967 | }); 968 | 969 | test('.evict() handles edge case inputs', t => { 970 | const lru = new QuickLRU({maxSize: 5}); 971 | 972 | lru.set('a', 1); 973 | lru.set('b', 2); 974 | lru.set('c', 3); 975 | 976 | // NaN should do nothing 977 | lru.evict(Number.NaN); 978 | t.is(lru.size, 3); 979 | 980 | // String "1" should be coerced to number 1 981 | lru.evict('1'); 982 | t.is(lru.size, 2); 983 | 984 | // Undefined should use default of 1 985 | lru.evict(undefined); 986 | t.is(lru.size, 1); 987 | t.true(lru.has('c')); 988 | }); 989 | 990 | test('.evict() keeps at least one item with expired entries', async t => { 991 | const {setTimeout: delay} = await import('node:timers/promises'); 992 | const lru = new QuickLRU({maxSize: 5}); 993 | 994 | lru.set('a', 1, {maxAge: 1}); // Will expire 995 | lru.set('b', 2, {maxAge: 1}); // Will expire 996 | lru.set('c', 3); // Will not expire 997 | 998 | // Wait for expiration 999 | await delay(10); 1000 | 1001 | // Try to evict everything - should keep the one live item 1002 | lru.evict(Number.POSITIVE_INFINITY); 1003 | 1004 | t.is(lru.size, 1); 1005 | t.false(lru.has('a')); // Expired 1006 | t.false(lru.has('b')); // Expired 1007 | t.true(lru.has('c')); // Only live item left 1008 | }); 1009 | 1010 | test('.evict() handles all items expired gracefully', async t => { 1011 | const {setTimeout: delay} = await import('node:timers/promises'); 1012 | const lru = new QuickLRU({maxSize: 5}); 1013 | 1014 | lru.set('a', 1, {maxAge: 1}); // Will expire 1015 | lru.set('b', 2, {maxAge: 1}); // Will expire 1016 | lru.set('c', 3, {maxAge: 1}); // Will expire 1017 | 1018 | // Wait for all to expire 1019 | await delay(10); 1020 | 1021 | // When all items are expired, evict should be a no-op 1022 | lru.evict(1); 1023 | 1024 | t.is(lru.size, 0); 1025 | }); 1026 | 1027 | test('.evict() with mixed expiry and access patterns', async t => { 1028 | const {setTimeout: delay} = await import('node:timers/promises'); 1029 | const lru = new QuickLRU({maxSize: 5}); 1030 | 1031 | lru.set('fast1', 1, {maxAge: 5}); // Expires quickly 1032 | lru.set('slow1', 2, {maxAge: 100}); // Expires slowly 1033 | lru.set('fast2', 3, {maxAge: 5}); // Expires quickly 1034 | lru.set('slow2', 4, {maxAge: 100}); // Expires slowly 1035 | 1036 | // Wait for fast items to expire 1037 | await delay(10); 1038 | 1039 | // Access remaining items to change order 1040 | lru.get('slow1'); 1041 | lru.get('slow2'); 1042 | 1043 | // Should evict the oldest remaining live item 1044 | lru.evict(1); 1045 | 1046 | t.is(lru.size, 1); 1047 | t.false(lru.has('fast1')); // Expired 1048 | t.false(lru.has('fast2')); // Expired 1049 | t.false(lru.has('slow1')); // Evicted (was oldest live) 1050 | t.true(lru.has('slow2')); // Remaining 1051 | }); 1052 | 1053 | test('.evict() with extremely large count values', t => { 1054 | const lru = new QuickLRU({maxSize: 3}); 1055 | 1056 | lru.set('a', 1); 1057 | lru.set('b', 2); 1058 | lru.set('c', 3); 1059 | 1060 | // MAX_SAFE_INTEGER should work 1061 | lru.evict(Number.MAX_SAFE_INTEGER); 1062 | 1063 | t.is(lru.size, 1); 1064 | t.true(lru.has('c')); // Most recent item kept 1065 | }); 1066 | 1067 | test('.evict() works with complex object values', t => { 1068 | const evicted = []; 1069 | const lru = new QuickLRU({ 1070 | maxSize: 4, 1071 | onEviction(key, value) { 1072 | evicted.push({key, value}); 1073 | }, 1074 | }); 1075 | 1076 | const object1 = {data: 'test1', nested: {value: 1}}; 1077 | const object2 = {data: 'test2', nested: {value: 2}}; 1078 | const array1 = [1, 2, {nested: true}]; 1079 | const array2 = [4, 5, {nested: false}]; 1080 | 1081 | lru.set('obj1', object1); 1082 | lru.set('obj2', object2); 1083 | lru.set('arr1', array1); 1084 | lru.set('arr2', array2); 1085 | 1086 | lru.evict(2); 1087 | 1088 | t.is(lru.size, 2); 1089 | t.is(evicted.length, 2); 1090 | t.deepEqual(evicted[0], {key: 'obj1', value: object1}); 1091 | t.deepEqual(evicted[1], {key: 'obj2', value: object2}); 1092 | t.true(lru.has('arr1')); 1093 | t.true(lru.has('arr2')); 1094 | }); 1095 | 1096 | test('.evict() with WeakMap/WeakSet values', t => { 1097 | const lru = new QuickLRU({maxSize: 3}); 1098 | 1099 | const weakMap = new WeakMap(); 1100 | const weakSet = new WeakSet(); 1101 | const object = {}; 1102 | 1103 | lru.set('weakmap', weakMap); 1104 | lru.set('weakset', weakSet); 1105 | lru.set('object', object); 1106 | 1107 | lru.evict(1); 1108 | 1109 | t.is(lru.size, 2); 1110 | t.false(lru.has('weakmap')); 1111 | t.true(lru.has('weakset')); 1112 | t.true(lru.has('object')); 1113 | }); 1114 | 1115 | test('.evict() maintains cache integrity after multiple operations', t => { 1116 | const lru = new QuickLRU({maxSize: 10}); 1117 | 1118 | // Fill cache 1119 | for (let i = 0; i < 8; i++) { 1120 | lru.set(i, i); 1121 | } 1122 | 1123 | // Mix of operations 1124 | lru.delete(3); 1125 | lru.get(1); 1126 | lru.set(9, 9); 1127 | lru.peek(5); 1128 | lru.evict(3); 1129 | 1130 | // Verify cache is still functional 1131 | t.true(lru.size > 0); 1132 | 1133 | // Test all methods still work 1134 | lru.set('new', 'value'); 1135 | t.true(lru.has('new')); 1136 | t.is(lru.get('new'), 'value'); 1137 | 1138 | const keys = [...lru.keys()]; 1139 | const values = [...lru.values()]; 1140 | const entries = [...lru.entries()]; 1141 | 1142 | t.is(keys.length, lru.size); 1143 | t.is(values.length, lru.size); 1144 | t.is(entries.length, lru.size); 1145 | }); 1146 | 1147 | test('.evict() with non-integer count coercion edge cases', t => { 1148 | const lru = new QuickLRU({maxSize: 5}); 1149 | 1150 | lru.set('a', 1); 1151 | lru.set('b', 2); 1152 | lru.set('c', 3); 1153 | lru.set('d', 4); 1154 | 1155 | // Test various coercion cases 1156 | const initialSize = lru.size; 1157 | 1158 | // Boolean true -> 1 1159 | lru.evict(true); 1160 | t.is(lru.size, initialSize - 1); 1161 | 1162 | // Boolean false -> 0 (no-op) 1163 | lru.evict(false); 1164 | t.is(lru.size, initialSize - 1); 1165 | 1166 | // String with spaces 1167 | lru.evict(' 2 '); 1168 | t.is(lru.size, 1); 1169 | 1170 | // Should keep at least one 1171 | t.true(lru.size > 0); 1172 | }); 1173 | 1174 | test('.evict() with maxSize of 1', t => { 1175 | const lru = new QuickLRU({maxSize: 1}); 1176 | 1177 | lru.set('only', 'item'); 1178 | t.is(lru.size, 1); 1179 | 1180 | // Should keep the one item 1181 | lru.evict(1); 1182 | t.is(lru.size, 1); 1183 | t.true(lru.has('only')); 1184 | 1185 | // Evict 0 should be no-op 1186 | lru.evict(0); 1187 | t.is(lru.size, 1); 1188 | t.true(lru.has('only')); 1189 | 1190 | // Even large numbers should keep the one item 1191 | lru.evict(999_999); 1192 | t.is(lru.size, 1); 1193 | t.true(lru.has('only')); 1194 | }); 1195 | 1196 | test('.evict() during iteration maintains stability', t => { 1197 | const lru = new QuickLRU({maxSize: 5}); 1198 | 1199 | for (let i = 0; i < 5; i++) { 1200 | lru.set(i, i); 1201 | } 1202 | 1203 | const keys = []; 1204 | let iterationCount = 0; 1205 | 1206 | for (const [key] of lru) { 1207 | keys.push(key); 1208 | if (iterationCount === 2) { 1209 | // Evict during iteration 1210 | lru.evict(2); 1211 | } 1212 | 1213 | iterationCount++; 1214 | // Safety check to prevent infinite loop 1215 | if (iterationCount > 10) { 1216 | break; 1217 | } 1218 | } 1219 | 1220 | // Should have completed iteration 1221 | t.is(keys.length, 5); 1222 | t.is(lru.size, 3); 1223 | }); 1224 | 1225 | test('.evict() rapid successive calls', t => { 1226 | const evicted = []; 1227 | const lru = new QuickLRU({ 1228 | maxSize: 10, 1229 | onEviction(key, value) { 1230 | evicted.push({key, value}); 1231 | }, 1232 | }); 1233 | 1234 | for (let i = 0; i < 10; i++) { 1235 | lru.set(i, i); 1236 | } 1237 | 1238 | // Multiple rapid evictions 1239 | lru.evict(1); 1240 | lru.evict(1); 1241 | lru.evict(1); 1242 | lru.evict(2); 1243 | 1244 | t.is(lru.size, 5); 1245 | t.is(evicted.length, 5); 1246 | 1247 | // Verify eviction order (should be 0, 1, 2, 3, 4) 1248 | for (let i = 0; i < 5; i++) { 1249 | t.is(evicted[i].key, i); 1250 | t.is(evicted[i].value, i); 1251 | } 1252 | }); 1253 | 1254 | test('.evict() with circular references', t => { 1255 | const lru = new QuickLRU({maxSize: 3}); 1256 | 1257 | const circular1 = {name: 'obj1'}; 1258 | circular1.self = circular1; 1259 | 1260 | const circular2 = {name: 'obj2'}; 1261 | circular2.ref = circular1; 1262 | circular1.ref = circular2; 1263 | 1264 | lru.set('circular1', circular1); 1265 | lru.set('circular2', circular2); 1266 | lru.set('normal', 'value'); 1267 | 1268 | // Should handle circular references without issues 1269 | t.notThrows(() => { 1270 | lru.evict(1); 1271 | }); 1272 | 1273 | t.is(lru.size, 2); 1274 | }); 1275 | 1276 | test('.evict() with symbols as keys', t => { 1277 | const lru = new QuickLRU({maxSize: 4}); 1278 | 1279 | const sym1 = Symbol('first'); 1280 | const sym2 = Symbol('second'); 1281 | const sym3 = Symbol('third'); 1282 | const sym4 = Symbol('fourth'); 1283 | 1284 | lru.set(sym1, 'value1'); 1285 | lru.set(sym2, 'value2'); 1286 | lru.set(sym3, 'value3'); 1287 | lru.set(sym4, 'value4'); 1288 | 1289 | lru.evict(2); 1290 | 1291 | t.is(lru.size, 2); 1292 | t.false(lru.has(sym1)); 1293 | t.false(lru.has(sym2)); 1294 | t.true(lru.has(sym3)); 1295 | t.true(lru.has(sym4)); 1296 | }); 1297 | 1298 | test('.evict() interaction with resize method', t => { 1299 | const evicted = []; 1300 | const lru = new QuickLRU({ 1301 | maxSize: 10, 1302 | onEviction(key, value) { 1303 | evicted.push({key, value}); 1304 | }, 1305 | }); 1306 | 1307 | for (let i = 0; i < 10; i++) { 1308 | lru.set(i, i); 1309 | } 1310 | 1311 | // First resize down 1312 | lru.resize(6); 1313 | t.is(lru.size, 6); 1314 | t.is(evicted.length, 4); 1315 | 1316 | // Then evict more 1317 | lru.evict(3); 1318 | t.is(lru.size, 3); 1319 | t.is(evicted.length, 7); 1320 | 1321 | // Resize back up 1322 | lru.resize(8); 1323 | t.is(lru.maxSize, 8); 1324 | t.is(lru.size, 3); 1325 | 1326 | // Add more items 1327 | lru.set('new1', 'val1'); 1328 | lru.set('new2', 'val2'); 1329 | t.is(lru.size, 5); 1330 | }); 1331 | 1332 | test('.evict() with custom object toString methods', t => { 1333 | const lru = new QuickLRU({maxSize: 3}); 1334 | 1335 | const customObject = { 1336 | value: 42, 1337 | toString() { 1338 | return 'CustomStringRepresentation'; 1339 | }, 1340 | }; 1341 | 1342 | const anotherObject = { 1343 | data: 'test', 1344 | toString() { 1345 | throw new Error('toString should not be called during eviction'); 1346 | }, 1347 | }; 1348 | 1349 | lru.set('custom', customObject); 1350 | lru.set('another', anotherObject); 1351 | lru.set('normal', 'normalValue'); 1352 | 1353 | // Should not call toString during eviction 1354 | t.notThrows(() => { 1355 | lru.evict(1); 1356 | }); 1357 | 1358 | t.is(lru.size, 2); 1359 | }); 1360 | 1361 | test('.evict() stress test with many items', t => { 1362 | const lru = new QuickLRU({maxSize: 1000}); 1363 | 1364 | // Fill with many items 1365 | for (let i = 0; i < 1000; i++) { 1366 | lru.set(i, `value${i}`); 1367 | } 1368 | 1369 | t.is(lru.size, 1000); 1370 | 1371 | // Evict large batch 1372 | const start = Date.now(); 1373 | lru.evict(500); 1374 | const duration = Date.now() - start; 1375 | 1376 | t.is(lru.size, 500); 1377 | t.true(duration < 100); // Should be fast (< 100ms) 1378 | 1379 | // Verify remaining items are the most recent 1380 | for (let i = 500; i < 1000; i++) { 1381 | t.true(lru.has(i)); 1382 | } 1383 | 1384 | for (let i = 0; i < 500; i++) { 1385 | t.false(lru.has(i)); 1386 | } 1387 | }); 1388 | 1389 | test('.evict() with alternating get/set/evict operations', t => { 1390 | const lru = new QuickLRU({maxSize: 5}); 1391 | 1392 | // Complex sequence of operations 1393 | lru.set('a', 1); 1394 | lru.set('b', 2); 1395 | lru.set('c', 3); 1396 | 1397 | lru.get('a'); // Make 'a' recent (but still moves to recent cache) 1398 | lru.evict(1); // Should evict oldest, which is 'a' (the get moved it but it's still oldest in the recent cache) 1399 | 1400 | t.false(lru.has('a')); 1401 | t.true(lru.has('b')); 1402 | t.true(lru.has('c')); 1403 | 1404 | lru.set('d', 4); 1405 | lru.set('e', 5); 1406 | lru.peek('c'); // Peek doesn't change order 1407 | lru.evict(2); // Should evict 'b' and 'c' (oldest) 1408 | 1409 | t.false(lru.has('b')); 1410 | t.false(lru.has('c')); 1411 | t.true(lru.has('d')); 1412 | t.true(lru.has('e')); 1413 | }); 1414 | 1415 | test('.evict() with fractional and edge numeric values', t => { 1416 | const lru = new QuickLRU({maxSize: 5}); 1417 | 1418 | for (let i = 0; i < 5; i++) { 1419 | lru.set(i, i); 1420 | } 1421 | 1422 | // Fractional values 1423 | lru.evict(2.7); // Should truncate to 2 1424 | t.is(lru.size, 3); 1425 | 1426 | lru.set(10, 10); 1427 | lru.set(11, 11); 1428 | 1429 | // Negative infinity 1430 | lru.evict(Number.NEGATIVE_INFINITY); 1431 | t.is(lru.size, 5); // Should be no-op 1432 | 1433 | // Very small positive number 1434 | lru.evict(0.1); // Should truncate to 0, no-op 1435 | t.is(lru.size, 5); 1436 | 1437 | // Scientific notation 1438 | lru.evict(2e2); // 200, should evict all but one 1439 | t.is(lru.size, 1); 1440 | }); 1441 | 1442 | test('.evict() callback execution order and state', t => { 1443 | const events = []; 1444 | const lru = new QuickLRU({ 1445 | maxSize: 5, 1446 | onEviction(key, value) { 1447 | // Record the state when callback is called 1448 | events.push({ 1449 | type: 'eviction', 1450 | key, 1451 | value, 1452 | cacheSize: lru.size, // This should be the OLD size 1453 | cacheHasKey: lru.has(key), // Should still exist during callback 1454 | }); 1455 | }, 1456 | }); 1457 | 1458 | lru.set('a', 1); 1459 | lru.set('b', 2); 1460 | lru.set('c', 3); 1461 | 1462 | lru.evict(2); 1463 | 1464 | t.is(events.length, 2); 1465 | t.deepEqual(events[0], { 1466 | type: 'eviction', 1467 | key: 'a', 1468 | value: 1, 1469 | cacheSize: 3, // Size before eviction 1470 | cacheHasKey: true, // Item still exists during callback 1471 | }); 1472 | t.deepEqual(events[1], { 1473 | type: 'eviction', 1474 | key: 'b', 1475 | value: 2, 1476 | cacheSize: 3, 1477 | cacheHasKey: true, 1478 | }); 1479 | 1480 | // After eviction, items should be gone 1481 | t.false(lru.has('a')); 1482 | t.false(lru.has('b')); 1483 | t.true(lru.has('c')); 1484 | }); 1485 | 1486 | test('.evict() interaction with all iterator methods', t => { 1487 | const lru = new QuickLRU({maxSize: 6}); 1488 | 1489 | for (let i = 0; i < 6; i++) { 1490 | lru.set(i, `value${i}`); 1491 | } 1492 | 1493 | // Test with keys() 1494 | const keysBeforeEvict = [...lru.keys()]; 1495 | lru.evict(2); 1496 | const keysAfterEvict = [...lru.keys()]; 1497 | 1498 | t.is(keysBeforeEvict.length, 6); 1499 | t.is(keysAfterEvict.length, 4); 1500 | t.false(keysAfterEvict.includes(0)); 1501 | t.false(keysAfterEvict.includes(1)); 1502 | 1503 | // Test with values() 1504 | const values = [...lru.values()]; 1505 | t.is(values.length, 4); 1506 | t.true(values.includes('value2')); 1507 | t.true(values.includes('value5')); 1508 | 1509 | // Test with entries() 1510 | const entries = [...lru.entries()]; 1511 | t.is(entries.length, 4); 1512 | t.deepEqual(entries[0], [2, 'value2']); 1513 | 1514 | // Test with entriesAscending() 1515 | const ascending = [...lru.entriesAscending()]; 1516 | t.is(ascending.length, 4); 1517 | t.deepEqual(ascending[0], [2, 'value2']); 1518 | 1519 | // Test with entriesDescending() 1520 | const descending = [...lru.entriesDescending()]; 1521 | t.is(descending.length, 4); 1522 | t.deepEqual(descending[0], [5, 'value5']); 1523 | }); 1524 | 1525 | test('.evict() with forEach method interaction', t => { 1526 | const lru = new QuickLRU({maxSize: 5}); 1527 | 1528 | for (let i = 0; i < 5; i++) { 1529 | lru.set(i, i * 10); 1530 | } 1531 | 1532 | lru.evict(2); 1533 | 1534 | const forEachResults = []; 1535 | for (const [key, value] of lru) { 1536 | forEachResults.push({key, value, isThisCorrect: true}); 1537 | } 1538 | 1539 | t.is(forEachResults.length, 3); 1540 | t.deepEqual(forEachResults[0], {key: 2, value: 20, isThisCorrect: true}); 1541 | t.deepEqual(forEachResults[1], {key: 3, value: 30, isThisCorrect: true}); 1542 | t.deepEqual(forEachResults[2], {key: 4, value: 40, isThisCorrect: true}); 1543 | }); 1544 | 1545 | test('.evict() concurrent with cache capacity triggers', t => { 1546 | const evicted = []; 1547 | const lru = new QuickLRU({ 1548 | maxSize: 3, 1549 | onEviction(key, value) { 1550 | evicted.push({key, value}); 1551 | }, 1552 | }); 1553 | 1554 | // Fill cache 1555 | lru.set('a', 1); 1556 | lru.set('b', 2); 1557 | lru.set('c', 3); 1558 | 1559 | // Manual evict 1560 | lru.evict(1); 1561 | 1562 | // Should have one manual eviction 1563 | t.is(evicted.length, 1); 1564 | 1565 | // Add more items - these won't trigger auto-eviction since cache isn't at capacity 1566 | lru.set('d', 4); 1567 | t.is(lru.size, 3); // Still at capacity 1568 | t.is(evicted.length, 1); // No new evictions 1569 | 1570 | // Force auto-eviction by filling beyond maxSize 1571 | lru.set('e', 5); 1572 | lru.set('f', 6); // This should trigger auto-eviction 1573 | 1574 | // Should now have more evictions from auto-eviction 1575 | t.true(evicted.length > 1); 1576 | }); 1577 | 1578 | test('.evict() with peek interactions preserves LRU order', t => { 1579 | const lru = new QuickLRU({maxSize: 5}); 1580 | 1581 | lru.set('a', 1); 1582 | lru.set('b', 2); 1583 | lru.set('c', 3); 1584 | lru.set('d', 4); 1585 | lru.set('e', 5); 1586 | 1587 | // Peek doesn't change LRU order 1588 | t.is(lru.peek('a'), 1); 1589 | t.is(lru.peek('b'), 2); 1590 | 1591 | // Evict should still remove 'a' first (oldest) 1592 | lru.evict(1); 1593 | 1594 | t.false(lru.has('a')); 1595 | t.true(lru.has('b')); 1596 | t.true(lru.has('c')); 1597 | t.true(lru.has('d')); 1598 | t.true(lru.has('e')); 1599 | }); 1600 | 1601 | test('.evict() maintains consistency with size property', t => { 1602 | const lru = new QuickLRU({maxSize: 10}); 1603 | 1604 | // Fill with varying operations 1605 | for (let i = 0; i < 8; i++) { 1606 | lru.set(i, i); 1607 | } 1608 | 1609 | lru.delete(3); 1610 | lru.delete(5); 1611 | t.is(lru.size, 6); 1612 | 1613 | lru.evict(3); 1614 | t.is(lru.size, 3); 1615 | 1616 | // Add items back 1617 | lru.set('new1', 'val1'); 1618 | lru.set('new2', 'val2'); 1619 | t.is(lru.size, 5); 1620 | 1621 | // Size should always match actual iteration count 1622 | const iterationCount = [...lru].length; 1623 | t.is(lru.size, iterationCount); 1624 | }); 1625 | 1626 | test('.evict() edge case: exactly maxSize items with expiry', async t => { 1627 | const {setTimeout: delay} = await import('node:timers/promises'); 1628 | const lru = new QuickLRU({maxSize: 3}); 1629 | 1630 | // Fill exactly to maxSize with mixed expiry 1631 | lru.set('temp1', 1, {maxAge: 5}); // Will expire 1632 | lru.set('temp2', 2, {maxAge: 5}); // Will expire 1633 | lru.set('perm', 3); // Will not expire 1634 | 1635 | await delay(10); 1636 | 1637 | // All temp items expired, only perm remains 1638 | // But cache thinks it has 3 items (size property) 1639 | lru.evict(1); 1640 | 1641 | // Should handle gracefully 1642 | t.true(lru.size <= 1); 1643 | t.true(lru.has('perm')); 1644 | }); 1645 | 1646 | test('.evict() type coercion with special objects', t => { 1647 | const lru = new QuickLRU({maxSize: 5}); 1648 | 1649 | for (let i = 0; i < 5; i++) { 1650 | lru.set(i, i); 1651 | } 1652 | 1653 | // Test with Date object - Number(new Date(2)) is 2 1654 | lru.evict(new Date(2)); // Should coerce to 2 1655 | t.is(lru.size, 3); 1656 | 1657 | // Test with object with valueOf 1658 | const customObject = { 1659 | valueOf() { 1660 | return 1; 1661 | }, 1662 | }; 1663 | lru.evict(customObject); // Should coerce to 1 1664 | t.is(lru.size, 2); 1665 | 1666 | // Test with array 1667 | lru.evict([1]); // Should coerce to 1 1668 | t.is(lru.size, 1); 1669 | }); 1670 | 1671 | test('.evict() performance with large maxAge and many expired items', async t => { 1672 | const {setTimeout: delay} = await import('node:timers/promises'); 1673 | const lru = new QuickLRU({maxSize: 100}); 1674 | 1675 | // Add many items with short expiry 1676 | for (let i = 0; i < 50; i++) { 1677 | lru.set(`expired${i}`, i, {maxAge: 1}); 1678 | } 1679 | 1680 | // Add some permanent items 1681 | for (let i = 0; i < 10; i++) { 1682 | lru.set(`perm${i}`, i); 1683 | } 1684 | 1685 | await delay(10); // Let expired items expire 1686 | 1687 | // Performance test: should be fast even with many expired items 1688 | const start = Date.now(); 1689 | lru.evict(5); 1690 | const duration = Date.now() - start; 1691 | 1692 | t.true(duration < 50); // Should be very fast 1693 | t.true(lru.size <= 10); // Should have evicted some permanent items too 1694 | }); 1695 | 1696 | test('.evict() boundary: evict count equals live items minus one', t => { 1697 | const lru = new QuickLRU({maxSize: 5}); 1698 | 1699 | lru.set('a', 1); 1700 | lru.set('b', 2); 1701 | lru.set('c', 3); 1702 | 1703 | // Evict exactly size - 1 (should leave 1 item) 1704 | lru.evict(2); 1705 | 1706 | t.is(lru.size, 1); 1707 | t.true(lru.has('c')); // Most recent should remain 1708 | }); 1709 | 1710 | test('.evict() with custom maxAge and global maxAge interaction', async t => { 1711 | const {setTimeout: delay} = await import('node:timers/promises'); 1712 | const lru = new QuickLRU({maxSize: 5, maxAge: 100}); // Global maxAge 1713 | 1714 | lru.set('global1', 1); // Uses global maxAge 1715 | lru.set('custom1', 2, {maxAge: 5}); // Custom short maxAge 1716 | lru.set('global2', 3); // Uses global maxAge 1717 | lru.set('custom2', 4, {maxAge: 5}); // Custom short maxAge 1718 | lru.set('noglobal', 5); // Uses global maxAge 1719 | 1720 | await delay(10); // Custom items expire 1721 | 1722 | lru.evict(2); 1723 | 1724 | // Should evict oldest live items 1725 | t.is(lru.size, 1); 1726 | t.true(lru.has('noglobal')); // Most recent should remain 1727 | }); 1728 | --------------------------------------------------------------------------------