diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d919c8c..f648207 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,9 @@ name: ci on: + push: + branches: + - master pull_request: branches: - master diff --git a/AGENTS.md b/AGENTS.md index 2ac9f75..3f35aed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,9 @@ Source code is in `src/`. ├── tests/ # Test files ├── benchmarks/ # Performance benchmarks ├── dist/ # Built distribution files +│ ├── tiny-lru.js # ES Modules +│ ├── tiny-lru.cjs # CommonJS +│ └── tiny-lru.min.js # Minified ESM ├── types/ # TypeScript definitions ├── docs/ # Documentation ├── rollup.config.js # Build configuration @@ -56,16 +59,19 @@ Source code is in `src/`. - `lru(max, ttl, resetTtl)` - Factory function to create cache - `LRU` class - Direct instantiation with `new LRU(max, ttl, resetTtl)` -- Key methods: `get()`, `set()`, `delete()`, `has()`, `clear()`, `evict()` +- Core methods: `get()`, `set()`, `peek()`, `delete()`, `has()`, `clear()`, `evict()` - Array methods: `keys()`, `values()`, `entries()` +- Utility methods: `forEach()`, `getMany()`, `hasAll()`, `hasAny()`, `cleanup()`, `toJSON()`, `stats()`, `onEvict()`, `sizeByTTL()`, `keysByTTL()`, `valuesByTTL()` - Properties: `first`, `last`, `max`, `size`, `ttl`, `resetTtl` +- `peek(key)` - Retrieve value without moving it (no LRU update, no TTL check) ## Testing - Framework: Node.js built-in test runner (`node --test`) -- Coverage: 100% +- Tests: 149 tests across 26 suites +- Coverage: 100% lines, 99.28% branches, 100% functions - Test pattern: `tests/**/*.js` -- All tests must pass with 100% coverage before merging +- All tests must pass with 100% line coverage before merging - Run: `npm test` (lint + tests) or `npm run coverage` for coverage report ## Common Issues to Avoid diff --git a/README.md b/README.md index 57114b2..63f6854 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,25 @@ [![npm version](https://img.shields.io/npm/v/tiny-lru.svg)](https://www.npmjs.com/package/tiny-lru) [![npm downloads](https://img.shields.io/npm/dm/tiny-lru.svg)](https://www.npmjs.com/package/tiny-lru) -[![Build Status](https://github.com/avoidwork/tiny-lru/actions/workflows/ci.yml/badge.svg)](https://github.com/avoidwork/tiny-lru/actions) -[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/avoidwork/tiny-lru) +[![License](https://img.shields.io/npm/l/tiny-lru.svg)](https://github.com/avoidwork/tiny-lru/blob/master/LICENSE) +[![Node.js version](https://img.shields.io/node/v/tiny-lru.svg)](https://www.npmjs.com/package/tiny-lru) +[![Build Status](https://github.com/avoidwork/tiny-lru/actions/workflows/ci.yml/badge.svg)](https://github.com/avoidwork/tiny-lru/actions?query=workflow%3Aci) +[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://app.codecov.io/gh/avoidwork/tiny-lru) -A fast, lightweight LRU (Least Recently Used) cache for JavaScript with O(1) operations and optional TTL support. +A high-performance, lightweight LRU (Least Recently Used) cache for JavaScript with O(1) operations and optional TTL support. ## What is an LRU Cache? Think of an LRU cache like a limited-size bookshelf. When you add a new book and the shelf is full, you remove the **least recently used** book to make room. Every time you read a book, it moves to the front. This pattern is perfect for caching where you want to keep the most frequently accessed items. +The tiny-lru library provides: +- **O(1)** operations for get, set, delete, and has +- Optional **TTL (Time-To-Live)** support for automatic expiration +- **Zero dependencies** - pure JavaScript +- **100% test coverage** - fully tested and reliable +- **TypeScript support** - full type definitions included +- **~2.2 KB** minified and gzipped (compared to ~12 KB for lru-cache) + ## Installation ```bash @@ -40,6 +50,21 @@ cache.size; // 3 cache.keys(); // ['a', 'b', 'c'] (LRU order) ``` +### TypeScript + +```typescript +import { LRU } from "tiny-lru"; + +interface User { + id: number; + name: string; +} + +const cache = new LRU(100); +cache.set("user:1", { id: 1, name: "Alice" }); +const user: User | undefined = cache.get("user:1"); +``` + ## With TTL (Time-to-Live) Items can automatically expire after a set time: @@ -69,6 +94,8 @@ cache.set("key", "new value"); // TTL resets - Function memoization - Session storage with expiration - Rate limiting +- LLM response caching +- Database query result caching - Any scenario where you want to limit memory usage **Not ideal for:** @@ -80,6 +107,8 @@ cache.set("key", "new value"); // TTL resets ### Factory Function: `lru(max?, ttl?, resetTtl?)` +Creates a new LRU cache instance with parameter validation. + ```javascript import { lru } from "tiny-lru"; @@ -89,54 +118,74 @@ const cache3 = lru(100, 30000); // 100 items, 30s TTL const cache4 = lru(100, 60000, true); // with resetTtl enabled ``` +**Parameters:** + +| Name | Type | Default | Description | +| ---------- | --------- | ------- | ---------------------------------------------------------------- | +| `max` | `number` | `1000` | Maximum items. `0` = unlimited. Must be >= 0. | +| `ttl` | `number` | `0` | Time-to-live in milliseconds. `0` = no expiration. Must be >= 0. | +| `resetTTL` | `boolean` | `false` | Reset TTL when updating existing items via `set()` | + +**Returns:** `LRU` - New cache instance + +**Throws:** `TypeError` if parameters are invalid + ### Class: `new LRU(max?, ttl?, resetTtl?)` +Creates an LRU cache instance without parameter validation. + ```javascript import { LRU } from "tiny-lru"; const cache = new LRU(100, 5000); ``` -### TypeScript +**Parameters:** -```typescript -import { LRU } from "tiny-lru"; +| Name | Type | Default | Description | +| ---------- | --------- | ------- | -------------------------------------------------- | +| `max` | `number` | `0` | Maximum items. `0` = unlimited. | +| `ttl` | `number` | `0` | Time-to-live in milliseconds. `0` = no expiration. | +| `resetTTL` | `boolean` | `false` | Reset TTL when updating via `set()` | -interface User { - id: number; - name: string; -} +### Properties -const cache = new LRU(100); -cache.set("user:1", { id: 1, name: "Alice" }); -const user: User | undefined = cache.get("user:1"); -``` +| Property | Type | Description | +| --------- | ---------------- | ------------------------------------------ | +| `first` | `object` \| `null` | Least recently used item (node with `key`, `value`, `prev`, `next`, `expiry`) | +| `last` | `object` \| `null` | Most recently used item (node with `key`, `value`, `prev`, `next`, `expiry`) | +| `max` | `number` | Maximum items allowed | +| `resetTTL`| `boolean` | Whether TTL resets on `set()` updates | +| `size` | `number` | Current number of items | +| `ttl` | `number` | Time-to-live in milliseconds | ### Methods -| Method | Description | -| ----------------------- | ---------------------------------------------- | -| `clear()` | Remove all items. Returns `this` for chaining. | -| `delete(key)` | Remove an item. Returns `this` for chaining. | -| `entries(keys?)` | Get `[key, value]` pairs in LRU order. | -| `evict()` | Remove the least recently used item. | -| `expiresAt(key)` | Get expiration timestamp for a key. | -| `get(key)` | Retrieve a value. Moves item to most recent. | -| `has(key)` | Check if key exists and is not expired. | -| `keys()` | Get all keys in LRU order (oldest first). | -| `set(key, value)` | Store a value. Returns `this` for chaining. | -| `setWithEvicted(key, value)` | Store value, return evicted item if full. | -| `values(keys?)` | Get all values, or values for specific keys. | - -### Properties - -| Property | Type | Description | -| -------- | ------ | ---------------------------- | -| `first` | object | Least recently used item | -| `last` | object | Most recently used item | -| `max` | number | Maximum items allowed | -| `size` | number | Current number of items | -| `ttl` | number | Time-to-live in milliseconds | +| Method | Description | +| --------------------------- | ---------------------------------------------- | +| `cleanup()` | Remove expired items without LRU update. Returns count of removed items. | +| `clear()` | Remove all items. Returns `this` for chaining. | +| `delete(key)` | Remove an item by key. Returns `this` for chaining. | +| `entries(keys?)` | Get `[key, value]` pairs. Without keys: LRU order. With keys: input array order. | +| `evict()` | Remove the least recently used item. Returns `this` for chaining. | +| `expiresAt(key)` | Get expiration timestamp for a key. Returns `number | undefined`. | +| `forEach(callback, thisArg?)` | Iterate over items in LRU order. Returns `this` for chaining. | +| `get(key)` | Retrieve a value. Moves item to most recent. Returns value or `undefined`. | +| `getMany(keys)` | Batch retrieve multiple items. Returns object mapping keys to values. | +| `has(key)` | Check if key exists and is not expired. Returns `boolean`. | +| `hasAll(keys)` | Check if ALL keys exist. Returns `boolean`. | +| `hasAny(keys)` | Check if ANY key exists. Returns `boolean`. | +| `keys()` | Get all keys in LRU order (oldest first). Returns `string[]`. | +| `keysByTTL()` | Get keys by TTL status. Returns `{valid, expired, noTTL}`. | +| `onEvict(callback)` | Register eviction callback (triggers on `evict()` or when `set()`/`setWithEvicted()` evicts). Returns `this` for chaining. | +| `peek(key)` | Retrieve a value without LRU update. Returns value or `undefined`. | +| `set(key, value)` | Store a value. Returns `this` for chaining. | +| `setWithEvicted(key, value)` | Store value, return evicted item if full. Returns `{key, value, expiry} | null`. | +| `sizeByTTL()` | Get counts by TTL status. Returns `{valid, expired, noTTL}`. | +| `stats()` | Get cache statistics. Returns `{hits, misses, sets, deletes, evictions}`. | +| `toJSON()` | Serialize cache to JSON format. Returns array of items. | +| `values(keys?)` | Get all values, or values for specific keys. Returns array of values. | +| `valuesByTTL()` | Get values by TTL status. Returns `{valid, expired, noTTL}`. | ## Common Patterns @@ -144,19 +193,19 @@ const user: User | undefined = cache.get("user:1"); ```javascript function memoize(fn, maxSize = 100) { - const cache = lru(maxSize); + const cache = lru(maxSize); - return function (...args) { - const key = JSON.stringify(args); + return function (...args) { + const key = JSON.stringify(args); - if (cache.has(key)) { - return cache.get(key); - } + if (cache.has(key)) { + return cache.get(key); + } - const result = fn(...args); - cache.set(key, result); - return result; - }; + const result = fn(...args); + cache.set(key, result); + return result; + }; } // Cache expensive computations @@ -168,22 +217,23 @@ fib(100); // even faster - from cache ### Cache-Aside Pattern ```javascript -async function getUser(userId) { - const cache = lru(1000, 60000); // 1 minute cache +// Cache instance shared across calls (outside the function) +const cache = lru(1000, 60000); // 1 minute cache - // Check cache first - const cached = cache.get(`user:${userId}`); - if (cached) { - return cached; - } +async function getUser(userId) { + // Check cache first + const cached = cache.get(`user:${userId}`); + if (cached) { + return cached; + } - // Fetch from database - const user = await db.users.findById(userId); + // Fetch from database + const user = await db.users.findById(userId); - // Store in cache - cache.set(`user:${userId}`, user); + // Store in cache + cache.set(`user:${userId}`, user); - return user; + return user; } ``` @@ -226,15 +276,105 @@ const slowFunc = _.memoize(expensiveOperation); slowFunc.cache.max = 100; // Configure cache size ``` +### Session and Authentication Caching + +```javascript +import { LRU } from "tiny-lru"; + +class AuthCache { + constructor() { + // Session cache: 30 minutes with TTL reset on update + this.sessions = new LRU(10000, 1800000, true); + // Token validation cache: 5 minutes, no reset + this.tokens = new LRU(5000, 300000, false); + // Permission cache: 15 minutes + this.permissions = new LRU(5000, 900000); + } + + cacheSession(sessionId, userData, domain = "app") { + const key = `${domain}:session:${sessionId}`; + this.sessions.set(key, { + userId: userData.userId, + permissions: userData.permissions, + loginTime: Date.now(), + lastActivity: Date.now(), + }); + } + + getSession(sessionId, domain = "app") { + const key = `${domain}:session:${sessionId}`; + return this.sessions.get(key); + } +} +``` + +### LLM Response Caching + +```javascript +import { LRU } from "tiny-lru"; + +class LLMCache { + constructor() { + // Cache up to 1000 responses for 1 hour + this.cache = new LRU(1000, 3600000); // 1 hour TTL + } + + async getResponse(model, prompt, params = {}) { + const key = this.generateKey(model, prompt, params); + + // Check cache first + const cached = this.cache.get(key); + if (cached) { + return { ...cached, fromCache: true }; + } + + // Make expensive API call + const response = await this.callLLMAPI(model, prompt, params); + + // Cache the response + this.cache.set(key, { + response: response.text, + tokens: response.tokens, + timestamp: Date.now(), + }); + + return { ...response, fromCache: false }; + } + + generateKey(model, prompt, params = {}) { + const paramsHash = this.hashObject(params); + const promptHash = this.hashString(prompt); + return `llm:${model}:${promptHash}:${paramsHash}`; + } + + hashString(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return Math.abs(hash).toString(36); + } + + hashObject(obj) { + return this.hashString(JSON.stringify(obj, Object.keys(obj).sort())); + } +} +``` + ## Why Tiny LRU? -| Feature | tiny-lru | lru-cache | -| ---------------- | ------------ | --------- | -| Bundle size | ~2.2 KB | ~15 KB | -| O(1) operations | ✅ | ✅ | -| TTL support | ✅ | ✅ | -| TypeScript | ✅ | ✅ | -| Zero dependencies| ✅ | ❌ | +| Feature | tiny-lru | lru-cache | quick-lru | +| ---------------- | ------------ | ----------- | ----------- | +| Bundle size | ~2.2 KB | ~12 KB | ~1.5 KB | +| O(1) operations | ✅ | ✅ | ✅ | +| TTL support | ✅ | ✅ | ✅ | +| TypeScript | ✅ | ✅ | ✅ | +| Zero dependencies| ✅ | ❌ | ✅ | +| Pure LRU | ✅ | ❌* | ✅ | + +\* lru-cache uses a hybrid design that can hold 2× the specified size for performance ## Performance @@ -245,6 +385,16 @@ All core operations are O(1): - **Delete**: Remove items - **Has**: Quick existence check +### Benchmarks + +Run our comprehensive benchmark suite to see performance characteristics: + +```bash +npm run benchmark:all +``` + +See [benchmarks/README.md](https://github.com/avoidwork/tiny-lru/blob/master/benchmarks/README.md) for more details. + ## Development ```bash @@ -256,12 +406,28 @@ npm run build # Build distribution files npm run coverage # Generate test coverage report ``` +### Build Output + +Build produces multiple module formats. When you install from npm, you'll get: +- `dist/tiny-lru.js` - ES Modules +- `dist/tiny-lru.cjs` - CommonJS +- `types/lru.d.ts` - TypeScript definitions + +The minified version (`dist/tiny-lru.min.js`) is available in the repository for local testing but is not shipped via npm. + +## Tests + +| Metric | Count | +| --------- | ----- | +| Tests | 149 | +| Suites | 26 | + ## Test Coverage | Metric | Coverage | | --------- | -------- | | Lines | 100% | -| Branches | 95% | +| Branches | 99.28% | | Functions | 100% | ## Contributing @@ -273,6 +439,32 @@ npm run coverage # Generate test coverage report 5. Push to the branch (`git push origin feature/amazing-feature`) 6. Open a Pull Request +## Security + +### Multi-Domain Key Convention + +Implement a hierarchical key naming convention to prevent cross-domain data leakage: + +``` +{domain}:{service}:{resource}:{identifier}[:{version}] +``` + +Example domains: +- User-related: `usr:profile:data:12345` +- Authentication: `auth:login:session:abc123` +- External API: `api:response:endpoint:hash` +- Database: `db:query:sqlhash:paramshash` +- Application: `app:cache:feature:value` +- System: `sys:config:feature:version` +- Analytics: `analytics:event:user:session` +- ML/AI: `ml:llm:response:gpt4-hash` + +## Documentation + +- [API Reference](https://github.com/avoidwork/tiny-lru/blob/master/docs/API.md) - Complete API documentation +- [Technical Documentation](https://github.com/avoidwork/tiny-lru/blob/master/docs/TECHNICAL_DOCUMENTATION.md) - Architecture, performance, and security +- [Code Style Guide](https://github.com/avoidwork/tiny-lru/blob/master/docs/CODE_STYLE_GUIDE.md) - Contributing guidelines + ## License Copyright (c) 2026, Jason Mulligan diff --git a/coverage.txt b/coverage.txt index b235dc8..355f23e 100644 --- a/coverage.txt +++ b/coverage.txt @@ -3,8 +3,8 @@ ℹ file | line % | branch % | funcs % | uncovered lines ℹ ---------------------------------------------------------- ℹ src | | | | -ℹ lru.js | 100.00 | 97.30 | 100.00 | +ℹ lru.js | 100.00 | 99.28 | 100.00 | ℹ ---------------------------------------------------------- -ℹ all files | 100.00 | 97.30 | 100.00 | +ℹ all files | 100.00 | 99.28 | 100.00 | ℹ ---------------------------------------------------------- ℹ end of coverage report diff --git a/dist/tiny-lru.cjs b/dist/tiny-lru.cjs index 63747db..2a540dd 100644 --- a/dist/tiny-lru.cjs +++ b/dist/tiny-lru.cjs @@ -15,6 +15,9 @@ * @class LRU */ class LRU { + #stats; + #onEvict; + /** * Creates a new LRU cache instance. * Note: Constructor does not validate parameters. Use lru() factory function for parameter validation. @@ -22,16 +25,18 @@ class LRU { * @constructor * @param {number} [max=0] - Maximum number of items to store. 0 means unlimited. * @param {number} [ttl=0] - Time to live in milliseconds. 0 means no expiration. - * @param {boolean} [resetTtl=false] - Whether to reset TTL when updating existing items via set(). + * @param {boolean} [resetTTL=false] - Whether to reset TTL when updating existing items via set(). */ - constructor(max = 0, ttl = 0, resetTtl = false) { + constructor(max = 0, ttl = 0, resetTTL = false) { this.first = null; this.items = Object.create(null); this.last = null; this.max = max; - this.resetTtl = resetTtl; + this.resetTTL = resetTTL; this.size = 0; this.ttl = ttl; + this.#stats = { hits: 0, misses: 0, sets: 0, deletes: 0, evictions: 0 }; + this.#onEvict = null; } /** @@ -40,10 +45,22 @@ class LRU { * @returns {LRU} The LRU instance for method chaining. */ clear() { + for (let x = this.first; x !== null; ) { + const next = x.next; + x.prev = null; + x.next = null; + x = next; + } + this.first = null; this.items = Object.create(null); this.last = null; this.size = 0; + this.#stats.hits = 0; + this.#stats.misses = 0; + this.#stats.sets = 0; + this.#stats.deletes = 0; + this.#stats.evictions = 0; return this; } @@ -60,6 +77,7 @@ class LRU { if (item !== undefined) { delete this.items[key]; this.size--; + this.#stats.deletes++; this.#unlink(item); @@ -106,6 +124,7 @@ class LRU { const item = this.first; delete this.items[item.key]; + this.#stats.evictions++; if (--this.size === 0) { this.first = null; @@ -114,7 +133,15 @@ class LRU { this.#unlink(item); } + item.prev = null; item.next = null; + if (this.#onEvict !== null) { + this.#onEvict({ + key: item.key, + value: item.value, + expiry: item.expiry, + }); + } return this; } @@ -130,6 +157,33 @@ class LRU { return item !== undefined ? item.expiry : undefined; } + /** + * Checks if an item has expired. + * + * @param {Object} item - The cache item to check. + * @returns {boolean} True if the item has expired, false otherwise. + * @private + */ + #isExpired(item) { + if (this.ttl === 0 || item.expiry === 0) { + return false; + } + + return item.expiry <= Date.now(); + } + + /** + * Retrieves a value from the cache by key without updating LRU order. + * Note: Does not perform TTL checks or remove expired items. + * + * @param {string} key - The key to retrieve. + * @returns {*} The value associated with the key, or undefined if not found. + */ + peek(key) { + const item = this.items[key]; + return item !== undefined ? item.value : undefined; + } + /** * Retrieves a value from the cache by key. Updates the item's position to most recently used. * @@ -140,21 +194,18 @@ class LRU { const item = this.items[key]; if (item !== undefined) { - // Check TTL only if enabled to avoid unnecessary Date.now() calls - if (this.ttl > 0) { - if (item.expiry <= Date.now()) { - this.delete(key); - - return undefined; - } + if (!this.#isExpired(item)) { + this.moveToEnd(item); + this.#stats.hits++; + return item.value; } - // Fast LRU update without full set() overhead - this.moveToEnd(item); - - return item.value; + this.delete(key); + this.#stats.misses++; + return undefined; } + this.#stats.misses++; return undefined; } @@ -162,11 +213,11 @@ class LRU { * Checks if a key exists in the cache. * * @param {string} key - The key to check for. - * @returns {boolean} True if the key exists, false otherwise. + * @returns {boolean} True if the key exists and is not expired, false otherwise. */ has(key) { const item = this.items[key]; - return item !== undefined && (this.ttl === 0 || item.expiry > Date.now()); + return item !== undefined && !this.#isExpired(item); } /** @@ -246,7 +297,7 @@ class LRU { if (item !== undefined) { item.value = value; - if (this.resetTtl) { + if (this.resetTTL) { item.expiry = this.ttl > 0 ? Date.now() + this.ttl : this.ttl; } this.moveToEnd(item); @@ -277,6 +328,7 @@ class LRU { this.last = item; } + this.#stats.sets++; return evicted; } @@ -293,7 +345,7 @@ class LRU { if (item !== undefined) { item.value = value; - if (this.resetTtl) { + if (this.resetTTL) { item.expiry = this.ttl > 0 ? Date.now() + this.ttl : this.ttl; } @@ -320,6 +372,8 @@ class LRU { this.last = item; } + this.#stats.sets++; + return this; } @@ -328,12 +382,17 @@ class LRU { * When no keys provided, returns all values in LRU order. * When keys provided, order matches the input array. * - * @param {string[]} [keys=this.keys()] - Array of keys to get values for. Defaults to all keys. + * @param {string[]} [keys] - Array of keys to get values for. Defaults to all keys. * @returns {Array<*>} Array of values corresponding to the keys. */ values(keys) { if (keys === undefined) { - keys = this.keys(); + const result = Array.from({ length: this.size }); + let i = 0; + for (let x = this.first; x !== null; x = x.next) { + result[i++] = x.value; + } + return result; } const result = Array.from({ length: keys.length }); @@ -344,6 +403,256 @@ class LRU { return result; } + + /** + * Iterate over cache items in LRU order (least to most recent). + * Note: This method directly accesses items from the linked list without calling + * get() or peek(), so it does not update LRU order or check TTL expiration during iteration. + * + * @param {function(*, any, LRU): void} callback - Function to call for each item. Signature: callback(value, key, cache) + * @param {Object} [thisArg] - Value to use as `this` when executing callback. + * @returns {LRU} The LRU instance for method chaining. + */ + forEach(callback, thisArg) { + for (let x = this.first; x !== null; x = x.next) { + callback.call(thisArg, x.value, x.key, this); + } + + return this; + } + + /** + * Batch retrieve multiple items. + * + * @param {string[]} keys - Array of keys to retrieve. + * @returns {Object} Object mapping keys to values (undefined for missing/expired keys). + */ + getMany(keys) { + const result = Object.create(null); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + result[key] = this.get(key); + } + + return result; + } + + /** + * Batch existence check - returns true if ALL keys exist. + * + * @param {string[]} keys - Array of keys to check. + * @returns {boolean} True if all keys exist and are not expired. + */ + hasAll(keys) { + for (let i = 0; i < keys.length; i++) { + if (!this.has(keys[i])) { + return false; + } + } + + return true; + } + + /** + * Batch existence check - returns true if ANY key exists. + * + * @param {string[]} keys - Array of keys to check. + * @returns {boolean} True if any key exists and is not expired. + */ + hasAny(keys) { + for (let i = 0; i < keys.length; i++) { + if (this.has(keys[i])) { + return true; + } + } + + return false; + } + + /** + * Remove expired items without affecting LRU order. + * Unlike get(), this does not move items to the end. + * + * @returns {number} Number of expired items removed. + */ + cleanup() { + if (this.ttl === 0 || this.size === 0) { + return 0; + } + + let removed = 0; + + for (let x = this.first; x !== null; ) { + const next = x.next; + if (this.#isExpired(x)) { + const key = x.key; + if (this.items[key] !== undefined) { + delete this.items[key]; + this.size--; + removed++; + this.#unlink(x); + x.prev = null; + x.next = null; + } + } + x = next; + } + + if (removed > 0) { + this.#rebuildList(); + } + + return removed; + } + + /** + * Serialize cache to JSON-compatible format. + * + * @returns {Array<{key: any, value: *, expiry: number}>} Array of cache items. + */ + toJSON() { + const result = []; + for (let x = this.first; x !== null; x = x.next) { + result.push({ + key: x.key, + value: x.value, + expiry: x.expiry, + }); + } + + return result; + } + + /** + * Get cache statistics. + * + * @returns {Object} Statistics object with hits, misses, sets, deletes, evictions counts. + */ + stats() { + return { ...this.#stats }; + } + + /** + * Register callback for evicted items. + * + * @param {function(Object): void} callback - Function called when item is evicted. Receives {key, value, expiry}. + * @returns {LRU} The LRU instance for method chaining. + */ + onEvict(callback) { + if (typeof callback !== "function") { + throw new TypeError("onEvict callback must be a function"); + } + + this.#onEvict = callback; + + return this; + } + + /** + * Get counts of items by TTL status. + * + * @returns {Object} Object with valid, expired, and noTTL counts. + */ + sizeByTTL() { + if (this.ttl === 0) { + return { valid: this.size, expired: 0, noTTL: this.size }; + } + + const now = Date.now(); + let valid = 0; + let expired = 0; + let noTTL = 0; + + for (let x = this.first; x !== null; x = x.next) { + if (x.expiry === 0) { + noTTL++; + valid++; + } else if (x.expiry > now) { + valid++; + } else { + expired++; + } + } + + return { valid, expired, noTTL }; + } + + /** + * Get keys filtered by TTL status. + * + * @returns {Object} Object with valid, expired, and noTTL arrays of keys. + */ + keysByTTL() { + if (this.ttl === 0) { + return { valid: this.keys(), expired: [], noTTL: this.keys() }; + } + + const now = Date.now(); + const valid = []; + const expired = []; + const noTTL = []; + + for (let x = this.first; x !== null; x = x.next) { + if (x.expiry === 0) { + valid.push(x.key); + noTTL.push(x.key); + } else if (x.expiry > now) { + valid.push(x.key); + } else { + expired.push(x.key); + } + } + + return { valid, expired, noTTL }; + } + + /** + * Get values filtered by TTL status. + * + * @returns {Object} Object with valid, expired, and noTTL arrays of values. + */ + valuesByTTL() { + const keysByTTL = this.keysByTTL(); + + return { + valid: this.values(keysByTTL.valid), + expired: this.values(keysByTTL.expired), + noTTL: this.values(keysByTTL.noTTL), + }; + } + + /** + * Rebuild the doubly-linked list after cleanup by deleting expired items. + * This removes nodes that were deleted during cleanup. + * + * @private + */ + #rebuildList() { + if (this.size === 0) { + this.first = null; + this.last = null; + return; + } + + const keys = this.keys(); + this.first = null; + this.last = null; + + for (let i = 0; i < keys.length; i++) { + const item = this.items[keys[i]]; + if (item !== null && item !== undefined) { + if (this.first === null) { + this.first = item; + item.prev = null; + } else { + item.prev = this.last; + this.last.next = item; + } + item.next = null; + this.last = item; + } + } + } } /** @@ -352,11 +661,11 @@ class LRU { * @function lru * @param {number} [max=1000] - Maximum number of items to store. Must be >= 0. Use 0 for unlimited size. * @param {number} [ttl=0] - Time to live in milliseconds. Must be >= 0. Use 0 for no expiration. - * @param {boolean} [resetTtl=false] - Whether to reset TTL when accessing existing items via get(). + * @param {boolean} [resetTTL=false] - Whether to reset TTL when updating existing items via set(). * @returns {LRU} A new LRU cache instance. * @throws {TypeError} When parameters are invalid (negative numbers or wrong types). */ -function lru(max = 1000, ttl = 0, resetTtl = false) { +function lru(max = 1000, ttl = 0, resetTTL = false) { if (isNaN(max) || max < 0) { throw new TypeError("Invalid max value"); } @@ -365,11 +674,11 @@ function lru(max = 1000, ttl = 0, resetTtl = false) { throw new TypeError("Invalid ttl value"); } - if (typeof resetTtl !== "boolean") { - throw new TypeError("Invalid resetTtl value"); + if (typeof resetTTL !== "boolean") { + throw new TypeError("Invalid resetTTL value"); } - return new LRU(max, ttl, resetTtl); + return new LRU(max, ttl, resetTTL); } exports.LRU = LRU; diff --git a/dist/tiny-lru.js b/dist/tiny-lru.js index c3acd31..24fd987 100644 --- a/dist/tiny-lru.js +++ b/dist/tiny-lru.js @@ -13,6 +13,9 @@ * @class LRU */ class LRU { + #stats; + #onEvict; + /** * Creates a new LRU cache instance. * Note: Constructor does not validate parameters. Use lru() factory function for parameter validation. @@ -20,16 +23,18 @@ class LRU { * @constructor * @param {number} [max=0] - Maximum number of items to store. 0 means unlimited. * @param {number} [ttl=0] - Time to live in milliseconds. 0 means no expiration. - * @param {boolean} [resetTtl=false] - Whether to reset TTL when updating existing items via set(). + * @param {boolean} [resetTTL=false] - Whether to reset TTL when updating existing items via set(). */ - constructor(max = 0, ttl = 0, resetTtl = false) { + constructor(max = 0, ttl = 0, resetTTL = false) { this.first = null; this.items = Object.create(null); this.last = null; this.max = max; - this.resetTtl = resetTtl; + this.resetTTL = resetTTL; this.size = 0; this.ttl = ttl; + this.#stats = { hits: 0, misses: 0, sets: 0, deletes: 0, evictions: 0 }; + this.#onEvict = null; } /** @@ -38,10 +43,22 @@ class LRU { * @returns {LRU} The LRU instance for method chaining. */ clear() { + for (let x = this.first; x !== null; ) { + const next = x.next; + x.prev = null; + x.next = null; + x = next; + } + this.first = null; this.items = Object.create(null); this.last = null; this.size = 0; + this.#stats.hits = 0; + this.#stats.misses = 0; + this.#stats.sets = 0; + this.#stats.deletes = 0; + this.#stats.evictions = 0; return this; } @@ -58,6 +75,7 @@ class LRU { if (item !== undefined) { delete this.items[key]; this.size--; + this.#stats.deletes++; this.#unlink(item); @@ -104,6 +122,7 @@ class LRU { const item = this.first; delete this.items[item.key]; + this.#stats.evictions++; if (--this.size === 0) { this.first = null; @@ -112,7 +131,15 @@ class LRU { this.#unlink(item); } + item.prev = null; item.next = null; + if (this.#onEvict !== null) { + this.#onEvict({ + key: item.key, + value: item.value, + expiry: item.expiry, + }); + } return this; } @@ -128,6 +155,33 @@ class LRU { return item !== undefined ? item.expiry : undefined; } + /** + * Checks if an item has expired. + * + * @param {Object} item - The cache item to check. + * @returns {boolean} True if the item has expired, false otherwise. + * @private + */ + #isExpired(item) { + if (this.ttl === 0 || item.expiry === 0) { + return false; + } + + return item.expiry <= Date.now(); + } + + /** + * Retrieves a value from the cache by key without updating LRU order. + * Note: Does not perform TTL checks or remove expired items. + * + * @param {string} key - The key to retrieve. + * @returns {*} The value associated with the key, or undefined if not found. + */ + peek(key) { + const item = this.items[key]; + return item !== undefined ? item.value : undefined; + } + /** * Retrieves a value from the cache by key. Updates the item's position to most recently used. * @@ -138,21 +192,18 @@ class LRU { const item = this.items[key]; if (item !== undefined) { - // Check TTL only if enabled to avoid unnecessary Date.now() calls - if (this.ttl > 0) { - if (item.expiry <= Date.now()) { - this.delete(key); - - return undefined; - } + if (!this.#isExpired(item)) { + this.moveToEnd(item); + this.#stats.hits++; + return item.value; } - // Fast LRU update without full set() overhead - this.moveToEnd(item); - - return item.value; + this.delete(key); + this.#stats.misses++; + return undefined; } + this.#stats.misses++; return undefined; } @@ -160,11 +211,11 @@ class LRU { * Checks if a key exists in the cache. * * @param {string} key - The key to check for. - * @returns {boolean} True if the key exists, false otherwise. + * @returns {boolean} True if the key exists and is not expired, false otherwise. */ has(key) { const item = this.items[key]; - return item !== undefined && (this.ttl === 0 || item.expiry > Date.now()); + return item !== undefined && !this.#isExpired(item); } /** @@ -244,7 +295,7 @@ class LRU { if (item !== undefined) { item.value = value; - if (this.resetTtl) { + if (this.resetTTL) { item.expiry = this.ttl > 0 ? Date.now() + this.ttl : this.ttl; } this.moveToEnd(item); @@ -275,6 +326,7 @@ class LRU { this.last = item; } + this.#stats.sets++; return evicted; } @@ -291,7 +343,7 @@ class LRU { if (item !== undefined) { item.value = value; - if (this.resetTtl) { + if (this.resetTTL) { item.expiry = this.ttl > 0 ? Date.now() + this.ttl : this.ttl; } @@ -318,6 +370,8 @@ class LRU { this.last = item; } + this.#stats.sets++; + return this; } @@ -326,12 +380,17 @@ class LRU { * When no keys provided, returns all values in LRU order. * When keys provided, order matches the input array. * - * @param {string[]} [keys=this.keys()] - Array of keys to get values for. Defaults to all keys. + * @param {string[]} [keys] - Array of keys to get values for. Defaults to all keys. * @returns {Array<*>} Array of values corresponding to the keys. */ values(keys) { if (keys === undefined) { - keys = this.keys(); + const result = Array.from({ length: this.size }); + let i = 0; + for (let x = this.first; x !== null; x = x.next) { + result[i++] = x.value; + } + return result; } const result = Array.from({ length: keys.length }); @@ -342,6 +401,256 @@ class LRU { return result; } + + /** + * Iterate over cache items in LRU order (least to most recent). + * Note: This method directly accesses items from the linked list without calling + * get() or peek(), so it does not update LRU order or check TTL expiration during iteration. + * + * @param {function(*, any, LRU): void} callback - Function to call for each item. Signature: callback(value, key, cache) + * @param {Object} [thisArg] - Value to use as `this` when executing callback. + * @returns {LRU} The LRU instance for method chaining. + */ + forEach(callback, thisArg) { + for (let x = this.first; x !== null; x = x.next) { + callback.call(thisArg, x.value, x.key, this); + } + + return this; + } + + /** + * Batch retrieve multiple items. + * + * @param {string[]} keys - Array of keys to retrieve. + * @returns {Object} Object mapping keys to values (undefined for missing/expired keys). + */ + getMany(keys) { + const result = Object.create(null); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + result[key] = this.get(key); + } + + return result; + } + + /** + * Batch existence check - returns true if ALL keys exist. + * + * @param {string[]} keys - Array of keys to check. + * @returns {boolean} True if all keys exist and are not expired. + */ + hasAll(keys) { + for (let i = 0; i < keys.length; i++) { + if (!this.has(keys[i])) { + return false; + } + } + + return true; + } + + /** + * Batch existence check - returns true if ANY key exists. + * + * @param {string[]} keys - Array of keys to check. + * @returns {boolean} True if any key exists and is not expired. + */ + hasAny(keys) { + for (let i = 0; i < keys.length; i++) { + if (this.has(keys[i])) { + return true; + } + } + + return false; + } + + /** + * Remove expired items without affecting LRU order. + * Unlike get(), this does not move items to the end. + * + * @returns {number} Number of expired items removed. + */ + cleanup() { + if (this.ttl === 0 || this.size === 0) { + return 0; + } + + let removed = 0; + + for (let x = this.first; x !== null; ) { + const next = x.next; + if (this.#isExpired(x)) { + const key = x.key; + if (this.items[key] !== undefined) { + delete this.items[key]; + this.size--; + removed++; + this.#unlink(x); + x.prev = null; + x.next = null; + } + } + x = next; + } + + if (removed > 0) { + this.#rebuildList(); + } + + return removed; + } + + /** + * Serialize cache to JSON-compatible format. + * + * @returns {Array<{key: any, value: *, expiry: number}>} Array of cache items. + */ + toJSON() { + const result = []; + for (let x = this.first; x !== null; x = x.next) { + result.push({ + key: x.key, + value: x.value, + expiry: x.expiry, + }); + } + + return result; + } + + /** + * Get cache statistics. + * + * @returns {Object} Statistics object with hits, misses, sets, deletes, evictions counts. + */ + stats() { + return { ...this.#stats }; + } + + /** + * Register callback for evicted items. + * + * @param {function(Object): void} callback - Function called when item is evicted. Receives {key, value, expiry}. + * @returns {LRU} The LRU instance for method chaining. + */ + onEvict(callback) { + if (typeof callback !== "function") { + throw new TypeError("onEvict callback must be a function"); + } + + this.#onEvict = callback; + + return this; + } + + /** + * Get counts of items by TTL status. + * + * @returns {Object} Object with valid, expired, and noTTL counts. + */ + sizeByTTL() { + if (this.ttl === 0) { + return { valid: this.size, expired: 0, noTTL: this.size }; + } + + const now = Date.now(); + let valid = 0; + let expired = 0; + let noTTL = 0; + + for (let x = this.first; x !== null; x = x.next) { + if (x.expiry === 0) { + noTTL++; + valid++; + } else if (x.expiry > now) { + valid++; + } else { + expired++; + } + } + + return { valid, expired, noTTL }; + } + + /** + * Get keys filtered by TTL status. + * + * @returns {Object} Object with valid, expired, and noTTL arrays of keys. + */ + keysByTTL() { + if (this.ttl === 0) { + return { valid: this.keys(), expired: [], noTTL: this.keys() }; + } + + const now = Date.now(); + const valid = []; + const expired = []; + const noTTL = []; + + for (let x = this.first; x !== null; x = x.next) { + if (x.expiry === 0) { + valid.push(x.key); + noTTL.push(x.key); + } else if (x.expiry > now) { + valid.push(x.key); + } else { + expired.push(x.key); + } + } + + return { valid, expired, noTTL }; + } + + /** + * Get values filtered by TTL status. + * + * @returns {Object} Object with valid, expired, and noTTL arrays of values. + */ + valuesByTTL() { + const keysByTTL = this.keysByTTL(); + + return { + valid: this.values(keysByTTL.valid), + expired: this.values(keysByTTL.expired), + noTTL: this.values(keysByTTL.noTTL), + }; + } + + /** + * Rebuild the doubly-linked list after cleanup by deleting expired items. + * This removes nodes that were deleted during cleanup. + * + * @private + */ + #rebuildList() { + if (this.size === 0) { + this.first = null; + this.last = null; + return; + } + + const keys = this.keys(); + this.first = null; + this.last = null; + + for (let i = 0; i < keys.length; i++) { + const item = this.items[keys[i]]; + if (item !== null && item !== undefined) { + if (this.first === null) { + this.first = item; + item.prev = null; + } else { + item.prev = this.last; + this.last.next = item; + } + item.next = null; + this.last = item; + } + } + } } /** @@ -350,11 +659,11 @@ class LRU { * @function lru * @param {number} [max=1000] - Maximum number of items to store. Must be >= 0. Use 0 for unlimited size. * @param {number} [ttl=0] - Time to live in milliseconds. Must be >= 0. Use 0 for no expiration. - * @param {boolean} [resetTtl=false] - Whether to reset TTL when accessing existing items via get(). + * @param {boolean} [resetTTL=false] - Whether to reset TTL when updating existing items via set(). * @returns {LRU} A new LRU cache instance. * @throws {TypeError} When parameters are invalid (negative numbers or wrong types). */ -function lru(max = 1000, ttl = 0, resetTtl = false) { +function lru(max = 1000, ttl = 0, resetTTL = false) { if (isNaN(max) || max < 0) { throw new TypeError("Invalid max value"); } @@ -363,9 +672,9 @@ function lru(max = 1000, ttl = 0, resetTtl = false) { throw new TypeError("Invalid ttl value"); } - if (typeof resetTtl !== "boolean") { - throw new TypeError("Invalid resetTtl value"); + if (typeof resetTTL !== "boolean") { + throw new TypeError("Invalid resetTTL value"); } - return new LRU(max, ttl, resetTtl); + return new LRU(max, ttl, resetTTL); }export{LRU,lru}; \ No newline at end of file diff --git a/dist/tiny-lru.min.js b/dist/tiny-lru.min.js index 6b48b21..3dc6009 100644 --- a/dist/tiny-lru.min.js +++ b/dist/tiny-lru.min.js @@ -2,4 +2,4 @@ 2026 Jason Mulligan @version 12.0.0 */ -class t{constructor(t=0,i=0,s=!1){this.first=null,this.items=Object.create(null),this.last=null,this.max=t,this.resetTtl=s,this.size=0,this.ttl=i}clear(){return this.first=null,this.items=Object.create(null),this.last=null,this.size=0,this}delete(t){const i=this.items[t];return void 0!==i&&(delete this.items[t],this.size--,this.#t(i),i.prev=null,i.next=null),this}entries(t){void 0===t&&(t=this.keys());const i=Array.from({length:t.length});for(let s=0;s0&&i.expiry<=Date.now()?void this.delete(t):(this.moveToEnd(i),i.value)}has(t){const i=this.items[t];return void 0!==i&&(0===this.ttl||i.expiry>Date.now())}#t(t){null!==t.prev&&(t.prev.next=t.next),null!==t.next&&(t.next.prev=t.prev),this.first===t&&(this.first=t.next),this.last===t&&(this.last=t.prev)}moveToEnd(t){this.last!==t&&(this.#t(t),t.prev=this.last,t.next=null,this.last.next=t,this.last=t)}keys(){const t=Array.from({length:this.size});let i=this.first,s=0;for(;null!==i;)t[s++]=i.key,i=i.next;return t}setWithEvicted(t,i){let s=null,e=this.items[t];return void 0!==e?(e.value=i,this.resetTtl&&(e.expiry=this.ttl>0?Date.now()+this.ttl:this.ttl),this.moveToEnd(e)):(this.max>0&&this.size===this.max&&(s={key:this.first.key,value:this.first.value,expiry:this.first.expiry},this.evict()),e=this.items[t]={expiry:this.ttl>0?Date.now()+this.ttl:this.ttl,key:t,prev:this.last,next:null,value:i},1==++this.size?this.first=e:this.last.next=e,this.last=e),s}set(t,i){let s=this.items[t];return void 0!==s?(s.value=i,this.resetTtl&&(s.expiry=this.ttl>0?Date.now()+this.ttl:this.ttl),this.moveToEnd(s)):(this.max>0&&this.size===this.max&&this.evict(),s=this.items[t]={expiry:this.ttl>0?Date.now()+this.ttl:this.ttl,key:t,prev:this.last,next:null,value:i},1==++this.size?this.first=s:this.last.next=s,this.last=s),this}values(t){void 0===t&&(t=this.keys());const i=Array.from({length:t.length});for(let s=0;s0?Date.now()+this.ttl:this.ttl),this.moveToEnd(e)):(this.max>0&&this.size===this.max&&(i={key:this.first.key,value:this.first.value,expiry:this.first.expiry},this.evict()),e=this.items[t]={expiry:this.ttl>0?Date.now()+this.ttl:this.ttl,key:t,prev:this.last,next:null,value:s},1==++this.size?this.first=e:this.last.next=e,this.last=e),this.#t.sets++,i}set(t,s){let i=this.items[t];return void 0!==i?(i.value=s,this.resetTTL&&(i.expiry=this.ttl>0?Date.now()+this.ttl:this.ttl),this.moveToEnd(i)):(this.max>0&&this.size===this.max&&this.evict(),i=this.items[t]={expiry:this.ttl>0?Date.now()+this.ttl:this.ttl,key:t,prev:this.last,next:null,value:s},1==++this.size?this.first=i:this.last.next=i,this.last=i),this.#t.sets++,this}values(t){if(void 0===t){const t=Array.from({length:this.size});let s=0;for(let i=this.first;null!==i;i=i.next)t[s++]=i.value;return t}const s=Array.from({length:t.length});for(let i=0;i0&&this.#l(),t}toJSON(){const t=[];for(let s=this.first;null!==s;s=s.next)t.push({key:s.key,value:s.value,expiry:s.expiry});return t}stats(){return{...this.#t}}onEvict(t){if("function"!=typeof t)throw new TypeError("onEvict callback must be a function");return this.#s=t,this}sizeByTTL(){if(0===this.ttl)return{valid:this.size,expired:0,noTTL:this.size};const t=Date.now();let s=0,i=0,e=0;for(let l=this.first;null!==l;l=l.next)0===l.expiry?(e++,s++):l.expiry>t?s++:i++;return{valid:s,expired:i,noTTL:e}}keysByTTL(){if(0===this.ttl)return{valid:this.keys(),expired:[],noTTL:this.keys()};const t=Date.now(),s=[],i=[],e=[];for(let l=this.first;null!==l;l=l.next)0===l.expiry?(s.push(l.key),e.push(l.key)):l.expiry>t?s.push(l.key):i.push(l.key);return{valid:s,expired:i,noTTL:e}}valuesByTTL(){const t=this.keysByTTL();return{valid:this.values(t.valid),expired:this.values(t.expired),noTTL:this.values(t.noTTL)}}#l(){if(0===this.size)return this.first=null,void(this.last=null);const t=this.keys();this.first=null,this.last=null;for(let s=0;s>} Array of [key, value] pairs.\n\t */\n\tentries(keys) {\n\t\tif (keys === undefined) {\n\t\t\tkeys = this.keys();\n\t\t}\n\n\t\tconst result = Array.from({ length: keys.length });\n\t\tfor (let i = 0; i < keys.length; i++) {\n\t\t\tconst key = keys[i];\n\t\t\tconst item = this.items[key];\n\t\t\tresult[i] = [key, item !== undefined ? item.value : undefined];\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/**\n\t * Removes the least recently used item from the cache.\n\t *\n\t * @returns {LRU} The LRU instance for method chaining.\n\t */\n\tevict() {\n\t\tif (this.size === 0) {\n\t\t\treturn this;\n\t\t}\n\n\t\tconst item = this.first;\n\n\t\tdelete this.items[item.key];\n\n\t\tif (--this.size === 0) {\n\t\t\tthis.first = null;\n\t\t\tthis.last = null;\n\t\t} else {\n\t\t\tthis.#unlink(item);\n\t\t}\n\n\t\titem.next = null;\n\n\t\treturn this;\n\t}\n\n\t/**\n\t * Returns the expiration timestamp for a given key.\n\t *\n\t * @param {string} key - The key to check expiration for.\n\t * @returns {number|undefined} The expiration timestamp in milliseconds, or undefined if key doesn't exist.\n\t */\n\texpiresAt(key) {\n\t\tconst item = this.items[key];\n\t\treturn item !== undefined ? item.expiry : undefined;\n\t}\n\n\t/**\n\t * Retrieves a value from the cache by key. Updates the item's position to most recently used.\n\t *\n\t * @param {string} key - The key to retrieve.\n\t * @returns {*} The value associated with the key, or undefined if not found or expired.\n\t */\n\tget(key) {\n\t\tconst item = this.items[key];\n\n\t\tif (item !== undefined) {\n\t\t\t// Check TTL only if enabled to avoid unnecessary Date.now() calls\n\t\t\tif (this.ttl > 0) {\n\t\t\t\tif (item.expiry <= Date.now()) {\n\t\t\t\t\tthis.delete(key);\n\n\t\t\t\t\treturn undefined;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Fast LRU update without full set() overhead\n\t\t\tthis.moveToEnd(item);\n\n\t\t\treturn item.value;\n\t\t}\n\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Checks if a key exists in the cache.\n\t *\n\t * @param {string} key - The key to check for.\n\t * @returns {boolean} True if the key exists, false otherwise.\n\t */\n\thas(key) {\n\t\tconst item = this.items[key];\n\t\treturn item !== undefined && (this.ttl === 0 || item.expiry > Date.now());\n\t}\n\n\t/**\n\t * Unlinks an item from the doubly-linked list.\n\t * Updates first/last pointers if needed.\n\t * Does NOT clear the item's prev/next pointers or delete from items map.\n\t *\n\t * @private\n\t */\n\t#unlink(item) {\n\t\tif (item.prev !== null) {\n\t\t\titem.prev.next = item.next;\n\t\t}\n\n\t\tif (item.next !== null) {\n\t\t\titem.next.prev = item.prev;\n\t\t}\n\n\t\tif (this.first === item) {\n\t\t\tthis.first = item.next;\n\t\t}\n\n\t\tif (this.last === item) {\n\t\t\tthis.last = item.prev;\n\t\t}\n\t}\n\n\t/**\n\t * Efficiently moves an item to the end of the LRU list (most recently used position).\n\t * This is an internal optimization method that avoids the overhead of the full set() operation\n\t * when only LRU position needs to be updated.\n\t *\n\t * @param {Object} item - The cache item with prev/next pointers to reposition.\n\t * @private\n\t */\n\tmoveToEnd(item) {\n\t\tif (this.last === item) {\n\t\t\treturn;\n\t\t}\n\n\t\tthis.#unlink(item);\n\n\t\titem.prev = this.last;\n\t\titem.next = null;\n\t\tthis.last.next = item;\n\t\tthis.last = item;\n\t}\n\n\t/**\n\t * Returns an array of all keys in the cache, ordered from least to most recently used.\n\t *\n\t * @returns {string[]} Array of keys in LRU order.\n\t */\n\tkeys() {\n\t\tconst result = Array.from({ length: this.size });\n\t\tlet x = this.first;\n\t\tlet i = 0;\n\n\t\twhile (x !== null) {\n\t\t\tresult[i++] = x.key;\n\t\t\tx = x.next;\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/**\n\t * Sets a value in the cache and returns any evicted item.\n\t *\n\t * @param {string} key - The key to set.\n\t * @param {*} value - The value to store.\n\t * @returns {Object|null} The evicted item (if any) with shape {key, value, expiry}, or null.\n\t */\n\tsetWithEvicted(key, value) {\n\t\tlet evicted = null;\n\t\tlet item = this.items[key];\n\n\t\tif (item !== undefined) {\n\t\t\titem.value = value;\n\t\t\tif (this.resetTtl) {\n\t\t\t\titem.expiry = this.ttl > 0 ? Date.now() + this.ttl : this.ttl;\n\t\t\t}\n\t\t\tthis.moveToEnd(item);\n\t\t} else {\n\t\t\tif (this.max > 0 && this.size === this.max) {\n\t\t\t\tevicted = {\n\t\t\t\t\tkey: this.first.key,\n\t\t\t\t\tvalue: this.first.value,\n\t\t\t\t\texpiry: this.first.expiry,\n\t\t\t\t};\n\t\t\t\tthis.evict();\n\t\t\t}\n\n\t\t\titem = this.items[key] = {\n\t\t\t\texpiry: this.ttl > 0 ? Date.now() + this.ttl : this.ttl,\n\t\t\t\tkey: key,\n\t\t\t\tprev: this.last,\n\t\t\t\tnext: null,\n\t\t\t\tvalue,\n\t\t\t};\n\n\t\t\tif (++this.size === 1) {\n\t\t\t\tthis.first = item;\n\t\t\t} else {\n\t\t\t\tthis.last.next = item;\n\t\t\t}\n\n\t\t\tthis.last = item;\n\t\t}\n\n\t\treturn evicted;\n\t}\n\n\t/**\n\t * Sets a value in the cache. Updates the item's position to most recently used.\n\t *\n\t * @param {string} key - The key to set.\n\t * @param {*} value - The value to store.\n\t * @returns {LRU} The LRU instance for method chaining.\n\t */\n\tset(key, value) {\n\t\tlet item = this.items[key];\n\n\t\tif (item !== undefined) {\n\t\t\titem.value = value;\n\n\t\t\tif (this.resetTtl) {\n\t\t\t\titem.expiry = this.ttl > 0 ? Date.now() + this.ttl : this.ttl;\n\t\t\t}\n\n\t\t\tthis.moveToEnd(item);\n\t\t} else {\n\t\t\tif (this.max > 0 && this.size === this.max) {\n\t\t\t\tthis.evict();\n\t\t\t}\n\n\t\t\titem = this.items[key] = {\n\t\t\t\texpiry: this.ttl > 0 ? Date.now() + this.ttl : this.ttl,\n\t\t\t\tkey: key,\n\t\t\t\tprev: this.last,\n\t\t\t\tnext: null,\n\t\t\t\tvalue,\n\t\t\t};\n\n\t\t\tif (++this.size === 1) {\n\t\t\t\tthis.first = item;\n\t\t\t} else {\n\t\t\t\tthis.last.next = item;\n\t\t\t}\n\n\t\t\tthis.last = item;\n\t\t}\n\n\t\treturn this;\n\t}\n\n\t/**\n\t * Returns an array of all values in the cache for the specified keys.\n\t * When no keys provided, returns all values in LRU order.\n\t * When keys provided, order matches the input array.\n\t *\n\t * @param {string[]} [keys=this.keys()] - Array of keys to get values for. Defaults to all keys.\n\t * @returns {Array<*>} Array of values corresponding to the keys.\n\t */\n\tvalues(keys) {\n\t\tif (keys === undefined) {\n\t\t\tkeys = this.keys();\n\t\t}\n\n\t\tconst result = Array.from({ length: keys.length });\n\t\tfor (let i = 0; i < keys.length; i++) {\n\t\t\tconst item = this.items[keys[i]];\n\t\t\tresult[i] = item !== undefined ? item.value : undefined;\n\t\t}\n\n\t\treturn result;\n\t}\n}\n\n/**\n * Factory function to create a new LRU cache instance with parameter validation.\n *\n * @function lru\n * @param {number} [max=1000] - Maximum number of items to store. Must be >= 0. Use 0 for unlimited size.\n * @param {number} [ttl=0] - Time to live in milliseconds. Must be >= 0. Use 0 for no expiration.\n * @param {boolean} [resetTtl=false] - Whether to reset TTL when accessing existing items via get().\n * @returns {LRU} A new LRU cache instance.\n * @throws {TypeError} When parameters are invalid (negative numbers or wrong types).\n */\nexport function lru(max = 1000, ttl = 0, resetTtl = false) {\n\tif (isNaN(max) || max < 0) {\n\t\tthrow new TypeError(\"Invalid max value\");\n\t}\n\n\tif (isNaN(ttl) || ttl < 0) {\n\t\tthrow new TypeError(\"Invalid ttl value\");\n\t}\n\n\tif (typeof resetTtl !== \"boolean\") {\n\t\tthrow new TypeError(\"Invalid resetTtl value\");\n\t}\n\n\treturn new LRU(max, ttl, resetTtl);\n}\n"],"names":["LRU","constructor","max","ttl","resetTtl","this","first","items","Object","create","last","size","clear","key","item","undefined","unlink","prev","next","entries","keys","result","Array","from","length","i","value","evict","expiresAt","expiry","get","Date","now","delete","moveToEnd","has","x","setWithEvicted","evicted","set","values","lru","isNaN","TypeError"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"tiny-lru.min.js","sources":["../src/lru.js"],"sourcesContent":["/**\n * A high-performance Least Recently Used (LRU) cache implementation with optional TTL support.\n * Items are automatically evicted when the cache reaches its maximum size,\n * removing the least recently used items first. All core operations (get, set, delete) are O(1).\n *\n * @class LRU\n */\nexport class LRU {\n\t#stats;\n\t#onEvict;\n\n\t/**\n\t * Creates a new LRU cache instance.\n\t * Note: Constructor does not validate parameters. Use lru() factory function for parameter validation.\n\t *\n\t * @constructor\n\t * @param {number} [max=0] - Maximum number of items to store. 0 means unlimited.\n\t * @param {number} [ttl=0] - Time to live in milliseconds. 0 means no expiration.\n\t * @param {boolean} [resetTTL=false] - Whether to reset TTL when updating existing items via set().\n\t */\n\tconstructor(max = 0, ttl = 0, resetTTL = false) {\n\t\tthis.first = null;\n\t\tthis.items = Object.create(null);\n\t\tthis.last = null;\n\t\tthis.max = max;\n\t\tthis.resetTTL = resetTTL;\n\t\tthis.size = 0;\n\t\tthis.ttl = ttl;\n\t\tthis.#stats = { hits: 0, misses: 0, sets: 0, deletes: 0, evictions: 0 };\n\t\tthis.#onEvict = null;\n\t}\n\n\t/**\n\t * Removes all items from the cache.\n\t *\n\t * @returns {LRU} The LRU instance for method chaining.\n\t */\n\tclear() {\n\t\tfor (let x = this.first; x !== null; ) {\n\t\t\tconst next = x.next;\n\t\t\tx.prev = null;\n\t\t\tx.next = null;\n\t\t\tx = next;\n\t\t}\n\n\t\tthis.first = null;\n\t\tthis.items = Object.create(null);\n\t\tthis.last = null;\n\t\tthis.size = 0;\n\t\tthis.#stats.hits = 0;\n\t\tthis.#stats.misses = 0;\n\t\tthis.#stats.sets = 0;\n\t\tthis.#stats.deletes = 0;\n\t\tthis.#stats.evictions = 0;\n\n\t\treturn this;\n\t}\n\n\t/**\n\t * Removes an item from the cache by key.\n\t *\n\t * @param {string} key - The key of the item to delete.\n\t * @returns {LRU} The LRU instance for method chaining.\n\t */\n\tdelete(key) {\n\t\tconst item = this.items[key];\n\n\t\tif (item !== undefined) {\n\t\t\tdelete this.items[key];\n\t\t\tthis.size--;\n\t\t\tthis.#stats.deletes++;\n\n\t\t\tthis.#unlink(item);\n\n\t\t\titem.prev = null;\n\t\t\titem.next = null;\n\t\t}\n\n\t\treturn this;\n\t}\n\n\t/**\n\t * Returns an array of [key, value] pairs for the specified keys.\n\t * When no keys provided, returns all entries in LRU order.\n\t * When keys provided, order matches the input array.\n\t *\n\t * @param {string[]} [keys=this.keys()] - Array of keys to get entries for. Defaults to all keys.\n\t * @returns {Array>} Array of [key, value] pairs.\n\t */\n\tentries(keys) {\n\t\tif (keys === undefined) {\n\t\t\tkeys = this.keys();\n\t\t}\n\n\t\tconst result = Array.from({ length: keys.length });\n\t\tfor (let i = 0; i < keys.length; i++) {\n\t\t\tconst key = keys[i];\n\t\t\tconst item = this.items[key];\n\t\t\tresult[i] = [key, item !== undefined ? item.value : undefined];\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/**\n\t * Removes the least recently used item from the cache.\n\t *\n\t * @returns {LRU} The LRU instance for method chaining.\n\t */\n\tevict() {\n\t\tif (this.size === 0) {\n\t\t\treturn this;\n\t\t}\n\n\t\tconst item = this.first;\n\n\t\tdelete this.items[item.key];\n\t\tthis.#stats.evictions++;\n\n\t\tif (--this.size === 0) {\n\t\t\tthis.first = null;\n\t\t\tthis.last = null;\n\t\t} else {\n\t\t\tthis.#unlink(item);\n\t\t}\n\n\t\titem.prev = null;\n\t\titem.next = null;\n\t\tif (this.#onEvict !== null) {\n\t\t\tthis.#onEvict({\n\t\t\t\tkey: item.key,\n\t\t\t\tvalue: item.value,\n\t\t\t\texpiry: item.expiry,\n\t\t\t});\n\t\t}\n\n\t\treturn this;\n\t}\n\n\t/**\n\t * Returns the expiration timestamp for a given key.\n\t *\n\t * @param {string} key - The key to check expiration for.\n\t * @returns {number|undefined} The expiration timestamp in milliseconds, or undefined if key doesn't exist.\n\t */\n\texpiresAt(key) {\n\t\tconst item = this.items[key];\n\t\treturn item !== undefined ? item.expiry : undefined;\n\t}\n\n\t/**\n\t * Checks if an item has expired.\n\t *\n\t * @param {Object} item - The cache item to check.\n\t * @returns {boolean} True if the item has expired, false otherwise.\n\t * @private\n\t */\n\t#isExpired(item) {\n\t\tif (this.ttl === 0 || item.expiry === 0) {\n\t\t\treturn false;\n\t\t}\n\n\t\treturn item.expiry <= Date.now();\n\t}\n\n\t/**\n\t * Retrieves a value from the cache by key without updating LRU order.\n\t * Note: Does not perform TTL checks or remove expired items.\n\t *\n\t * @param {string} key - The key to retrieve.\n\t * @returns {*} The value associated with the key, or undefined if not found.\n\t */\n\tpeek(key) {\n\t\tconst item = this.items[key];\n\t\treturn item !== undefined ? item.value : undefined;\n\t}\n\n\t/**\n\t * Retrieves a value from the cache by key. Updates the item's position to most recently used.\n\t *\n\t * @param {string} key - The key to retrieve.\n\t * @returns {*} The value associated with the key, or undefined if not found or expired.\n\t */\n\tget(key) {\n\t\tconst item = this.items[key];\n\n\t\tif (item !== undefined) {\n\t\t\tif (!this.#isExpired(item)) {\n\t\t\t\tthis.moveToEnd(item);\n\t\t\t\tthis.#stats.hits++;\n\t\t\t\treturn item.value;\n\t\t\t}\n\n\t\t\tthis.delete(key);\n\t\t\tthis.#stats.misses++;\n\t\t\treturn undefined;\n\t\t}\n\n\t\tthis.#stats.misses++;\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Checks if a key exists in the cache.\n\t *\n\t * @param {string} key - The key to check for.\n\t * @returns {boolean} True if the key exists and is not expired, false otherwise.\n\t */\n\thas(key) {\n\t\tconst item = this.items[key];\n\t\treturn item !== undefined && !this.#isExpired(item);\n\t}\n\n\t/**\n\t * Unlinks an item from the doubly-linked list.\n\t * Updates first/last pointers if needed.\n\t * Does NOT clear the item's prev/next pointers or delete from items map.\n\t *\n\t * @private\n\t */\n\t#unlink(item) {\n\t\tif (item.prev !== null) {\n\t\t\titem.prev.next = item.next;\n\t\t}\n\n\t\tif (item.next !== null) {\n\t\t\titem.next.prev = item.prev;\n\t\t}\n\n\t\tif (this.first === item) {\n\t\t\tthis.first = item.next;\n\t\t}\n\n\t\tif (this.last === item) {\n\t\t\tthis.last = item.prev;\n\t\t}\n\t}\n\n\t/**\n\t * Efficiently moves an item to the end of the LRU list (most recently used position).\n\t * This is an internal optimization method that avoids the overhead of the full set() operation\n\t * when only LRU position needs to be updated.\n\t *\n\t * @param {Object} item - The cache item with prev/next pointers to reposition.\n\t * @private\n\t */\n\tmoveToEnd(item) {\n\t\tif (this.last === item) {\n\t\t\treturn;\n\t\t}\n\n\t\tthis.#unlink(item);\n\n\t\titem.prev = this.last;\n\t\titem.next = null;\n\t\tthis.last.next = item;\n\t\tthis.last = item;\n\t}\n\n\t/**\n\t * Returns an array of all keys in the cache, ordered from least to most recently used.\n\t *\n\t * @returns {string[]} Array of keys in LRU order.\n\t */\n\tkeys() {\n\t\tconst result = Array.from({ length: this.size });\n\t\tlet x = this.first;\n\t\tlet i = 0;\n\n\t\twhile (x !== null) {\n\t\t\tresult[i++] = x.key;\n\t\t\tx = x.next;\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/**\n\t * Sets a value in the cache and returns any evicted item.\n\t *\n\t * @param {string} key - The key to set.\n\t * @param {*} value - The value to store.\n\t * @returns {Object|null} The evicted item (if any) with shape {key, value, expiry}, or null.\n\t */\n\tsetWithEvicted(key, value) {\n\t\tlet evicted = null;\n\t\tlet item = this.items[key];\n\n\t\tif (item !== undefined) {\n\t\t\titem.value = value;\n\t\t\tif (this.resetTTL) {\n\t\t\t\titem.expiry = this.ttl > 0 ? Date.now() + this.ttl : this.ttl;\n\t\t\t}\n\t\t\tthis.moveToEnd(item);\n\t\t} else {\n\t\t\tif (this.max > 0 && this.size === this.max) {\n\t\t\t\tevicted = {\n\t\t\t\t\tkey: this.first.key,\n\t\t\t\t\tvalue: this.first.value,\n\t\t\t\t\texpiry: this.first.expiry,\n\t\t\t\t};\n\t\t\t\tthis.evict();\n\t\t\t}\n\n\t\t\titem = this.items[key] = {\n\t\t\t\texpiry: this.ttl > 0 ? Date.now() + this.ttl : this.ttl,\n\t\t\t\tkey: key,\n\t\t\t\tprev: this.last,\n\t\t\t\tnext: null,\n\t\t\t\tvalue,\n\t\t\t};\n\n\t\t\tif (++this.size === 1) {\n\t\t\t\tthis.first = item;\n\t\t\t} else {\n\t\t\t\tthis.last.next = item;\n\t\t\t}\n\n\t\t\tthis.last = item;\n\t\t}\n\n\t\tthis.#stats.sets++;\n\t\treturn evicted;\n\t}\n\n\t/**\n\t * Sets a value in the cache. Updates the item's position to most recently used.\n\t *\n\t * @param {string} key - The key to set.\n\t * @param {*} value - The value to store.\n\t * @returns {LRU} The LRU instance for method chaining.\n\t */\n\tset(key, value) {\n\t\tlet item = this.items[key];\n\n\t\tif (item !== undefined) {\n\t\t\titem.value = value;\n\n\t\t\tif (this.resetTTL) {\n\t\t\t\titem.expiry = this.ttl > 0 ? Date.now() + this.ttl : this.ttl;\n\t\t\t}\n\n\t\t\tthis.moveToEnd(item);\n\t\t} else {\n\t\t\tif (this.max > 0 && this.size === this.max) {\n\t\t\t\tthis.evict();\n\t\t\t}\n\n\t\t\titem = this.items[key] = {\n\t\t\t\texpiry: this.ttl > 0 ? Date.now() + this.ttl : this.ttl,\n\t\t\t\tkey: key,\n\t\t\t\tprev: this.last,\n\t\t\t\tnext: null,\n\t\t\t\tvalue,\n\t\t\t};\n\n\t\t\tif (++this.size === 1) {\n\t\t\t\tthis.first = item;\n\t\t\t} else {\n\t\t\t\tthis.last.next = item;\n\t\t\t}\n\n\t\t\tthis.last = item;\n\t\t}\n\n\t\tthis.#stats.sets++;\n\n\t\treturn this;\n\t}\n\n\t/**\n\t * Returns an array of all values in the cache for the specified keys.\n\t * When no keys provided, returns all values in LRU order.\n\t * When keys provided, order matches the input array.\n\t *\n\t * @param {string[]} [keys] - Array of keys to get values for. Defaults to all keys.\n\t * @returns {Array<*>} Array of values corresponding to the keys.\n\t */\n\tvalues(keys) {\n\t\tif (keys === undefined) {\n\t\t\tconst result = Array.from({ length: this.size });\n\t\t\tlet i = 0;\n\t\t\tfor (let x = this.first; x !== null; x = x.next) {\n\t\t\t\tresult[i++] = x.value;\n\t\t\t}\n\t\t\treturn result;\n\t\t}\n\n\t\tconst result = Array.from({ length: keys.length });\n\t\tfor (let i = 0; i < keys.length; i++) {\n\t\t\tconst item = this.items[keys[i]];\n\t\t\tresult[i] = item !== undefined ? item.value : undefined;\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/**\n\t * Iterate over cache items in LRU order (least to most recent).\n\t * Note: This method directly accesses items from the linked list without calling\n\t * get() or peek(), so it does not update LRU order or check TTL expiration during iteration.\n\t *\n\t * @param {function(*, any, LRU): void} callback - Function to call for each item. Signature: callback(value, key, cache)\n\t * @param {Object} [thisArg] - Value to use as `this` when executing callback.\n\t * @returns {LRU} The LRU instance for method chaining.\n\t */\n\tforEach(callback, thisArg) {\n\t\tfor (let x = this.first; x !== null; x = x.next) {\n\t\t\tcallback.call(thisArg, x.value, x.key, this);\n\t\t}\n\n\t\treturn this;\n\t}\n\n\t/**\n\t * Batch retrieve multiple items.\n\t *\n\t * @param {string[]} keys - Array of keys to retrieve.\n\t * @returns {Object} Object mapping keys to values (undefined for missing/expired keys).\n\t */\n\tgetMany(keys) {\n\t\tconst result = Object.create(null);\n\t\tfor (let i = 0; i < keys.length; i++) {\n\t\t\tconst key = keys[i];\n\t\t\tresult[key] = this.get(key);\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/**\n\t * Batch existence check - returns true if ALL keys exist.\n\t *\n\t * @param {string[]} keys - Array of keys to check.\n\t * @returns {boolean} True if all keys exist and are not expired.\n\t */\n\thasAll(keys) {\n\t\tfor (let i = 0; i < keys.length; i++) {\n\t\t\tif (!this.has(keys[i])) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\n\t\treturn true;\n\t}\n\n\t/**\n\t * Batch existence check - returns true if ANY key exists.\n\t *\n\t * @param {string[]} keys - Array of keys to check.\n\t * @returns {boolean} True if any key exists and is not expired.\n\t */\n\thasAny(keys) {\n\t\tfor (let i = 0; i < keys.length; i++) {\n\t\t\tif (this.has(keys[i])) {\n\t\t\t\treturn true;\n\t\t\t}\n\t\t}\n\n\t\treturn false;\n\t}\n\n\t/**\n\t * Remove expired items without affecting LRU order.\n\t * Unlike get(), this does not move items to the end.\n\t *\n\t * @returns {number} Number of expired items removed.\n\t */\n\tcleanup() {\n\t\tif (this.ttl === 0 || this.size === 0) {\n\t\t\treturn 0;\n\t\t}\n\n\t\tlet removed = 0;\n\n\t\tfor (let x = this.first; x !== null; ) {\n\t\t\tconst next = x.next;\n\t\t\tif (this.#isExpired(x)) {\n\t\t\t\tconst key = x.key;\n\t\t\t\tif (this.items[key] !== undefined) {\n\t\t\t\t\tdelete this.items[key];\n\t\t\t\t\tthis.size--;\n\t\t\t\t\tremoved++;\n\t\t\t\t\tthis.#unlink(x);\n\t\t\t\t\tx.prev = null;\n\t\t\t\t\tx.next = null;\n\t\t\t\t}\n\t\t\t}\n\t\t\tx = next;\n\t\t}\n\n\t\tif (removed > 0) {\n\t\t\tthis.#rebuildList();\n\t\t}\n\n\t\treturn removed;\n\t}\n\n\t/**\n\t * Serialize cache to JSON-compatible format.\n\t *\n\t * @returns {Array<{key: any, value: *, expiry: number}>} Array of cache items.\n\t */\n\ttoJSON() {\n\t\tconst result = [];\n\t\tfor (let x = this.first; x !== null; x = x.next) {\n\t\t\tresult.push({\n\t\t\t\tkey: x.key,\n\t\t\t\tvalue: x.value,\n\t\t\t\texpiry: x.expiry,\n\t\t\t});\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/**\n\t * Get cache statistics.\n\t *\n\t * @returns {Object} Statistics object with hits, misses, sets, deletes, evictions counts.\n\t */\n\tstats() {\n\t\treturn { ...this.#stats };\n\t}\n\n\t/**\n\t * Register callback for evicted items.\n\t *\n\t * @param {function(Object): void} callback - Function called when item is evicted. Receives {key, value, expiry}.\n\t * @returns {LRU} The LRU instance for method chaining.\n\t */\n\tonEvict(callback) {\n\t\tif (typeof callback !== \"function\") {\n\t\t\tthrow new TypeError(\"onEvict callback must be a function\");\n\t\t}\n\n\t\tthis.#onEvict = callback;\n\n\t\treturn this;\n\t}\n\n\t/**\n\t * Get counts of items by TTL status.\n\t *\n\t * @returns {Object} Object with valid, expired, and noTTL counts.\n\t */\n\tsizeByTTL() {\n\t\tif (this.ttl === 0) {\n\t\t\treturn { valid: this.size, expired: 0, noTTL: this.size };\n\t\t}\n\n\t\tconst now = Date.now();\n\t\tlet valid = 0;\n\t\tlet expired = 0;\n\t\tlet noTTL = 0;\n\n\t\tfor (let x = this.first; x !== null; x = x.next) {\n\t\t\tif (x.expiry === 0) {\n\t\t\t\tnoTTL++;\n\t\t\t\tvalid++;\n\t\t\t} else if (x.expiry > now) {\n\t\t\t\tvalid++;\n\t\t\t} else {\n\t\t\t\texpired++;\n\t\t\t}\n\t\t}\n\n\t\treturn { valid, expired, noTTL };\n\t}\n\n\t/**\n\t * Get keys filtered by TTL status.\n\t *\n\t * @returns {Object} Object with valid, expired, and noTTL arrays of keys.\n\t */\n\tkeysByTTL() {\n\t\tif (this.ttl === 0) {\n\t\t\treturn { valid: this.keys(), expired: [], noTTL: this.keys() };\n\t\t}\n\n\t\tconst now = Date.now();\n\t\tconst valid = [];\n\t\tconst expired = [];\n\t\tconst noTTL = [];\n\n\t\tfor (let x = this.first; x !== null; x = x.next) {\n\t\t\tif (x.expiry === 0) {\n\t\t\t\tvalid.push(x.key);\n\t\t\t\tnoTTL.push(x.key);\n\t\t\t} else if (x.expiry > now) {\n\t\t\t\tvalid.push(x.key);\n\t\t\t} else {\n\t\t\t\texpired.push(x.key);\n\t\t\t}\n\t\t}\n\n\t\treturn { valid, expired, noTTL };\n\t}\n\n\t/**\n\t * Get values filtered by TTL status.\n\t *\n\t * @returns {Object} Object with valid, expired, and noTTL arrays of values.\n\t */\n\tvaluesByTTL() {\n\t\tconst keysByTTL = this.keysByTTL();\n\n\t\treturn {\n\t\t\tvalid: this.values(keysByTTL.valid),\n\t\t\texpired: this.values(keysByTTL.expired),\n\t\t\tnoTTL: this.values(keysByTTL.noTTL),\n\t\t};\n\t}\n\n\t/**\n\t * Rebuild the doubly-linked list after cleanup by deleting expired items.\n\t * This removes nodes that were deleted during cleanup.\n\t *\n\t * @private\n\t */\n\t#rebuildList() {\n\t\tif (this.size === 0) {\n\t\t\tthis.first = null;\n\t\t\tthis.last = null;\n\t\t\treturn;\n\t\t}\n\n\t\tconst keys = this.keys();\n\t\tthis.first = null;\n\t\tthis.last = null;\n\n\t\tfor (let i = 0; i < keys.length; i++) {\n\t\t\tconst item = this.items[keys[i]];\n\t\t\tif (item !== null && item !== undefined) {\n\t\t\t\tif (this.first === null) {\n\t\t\t\t\tthis.first = item;\n\t\t\t\t\titem.prev = null;\n\t\t\t\t} else {\n\t\t\t\t\titem.prev = this.last;\n\t\t\t\t\tthis.last.next = item;\n\t\t\t\t}\n\t\t\t\titem.next = null;\n\t\t\t\tthis.last = item;\n\t\t\t}\n\t\t}\n\t}\n}\n\n/**\n * Factory function to create a new LRU cache instance with parameter validation.\n *\n * @function lru\n * @param {number} [max=1000] - Maximum number of items to store. Must be >= 0. Use 0 for unlimited size.\n * @param {number} [ttl=0] - Time to live in milliseconds. Must be >= 0. Use 0 for no expiration.\n * @param {boolean} [resetTTL=false] - Whether to reset TTL when updating existing items via set().\n * @returns {LRU} A new LRU cache instance.\n * @throws {TypeError} When parameters are invalid (negative numbers or wrong types).\n */\nexport function lru(max = 1000, ttl = 0, resetTTL = false) {\n\tif (isNaN(max) || max < 0) {\n\t\tthrow new TypeError(\"Invalid max value\");\n\t}\n\n\tif (isNaN(ttl) || ttl < 0) {\n\t\tthrow new TypeError(\"Invalid ttl value\");\n\t}\n\n\tif (typeof resetTTL !== \"boolean\") {\n\t\tthrow new TypeError(\"Invalid resetTTL value\");\n\t}\n\n\treturn new LRU(max, ttl, resetTTL);\n}\n"],"names":["LRU","stats","onEvict","constructor","max","ttl","resetTTL","this","first","items","Object","create","last","size","hits","misses","sets","deletes","evictions","clear","x","next","prev","key","item","undefined","unlink","entries","keys","result","Array","from","length","i","value","evict","expiry","expiresAt","isExpired","Date","now","peek","get","delete","moveToEnd","has","setWithEvicted","evicted","set","values","forEach","callback","thisArg","call","getMany","hasAll","hasAny","cleanup","removed","rebuildList","toJSON","push","TypeError","sizeByTTL","valid","expired","noTTL","keysByTTL","valuesByTTL","lru","isNaN"],"mappings":"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"} \ No newline at end of file diff --git a/dist/tiny-lru.umd.js b/dist/tiny-lru.umd.js deleted file mode 100644 index 9ab27e8..0000000 --- a/dist/tiny-lru.umd.js +++ /dev/null @@ -1,371 +0,0 @@ -/** - * tiny-lru - * - * @copyright 2026 Jason Mulligan - * @license BSD-3-Clause - * @version 12.0.0 - */ -(function(g,f){typeof exports==='object'&&typeof module!=='undefined'?f(exports):typeof define==='function'&&define.amd?define(['exports'],f):(g=typeof globalThis!=='undefined'?globalThis:g||self,f(g.lru={}));})(this,(function(exports){'use strict';/** - * A high-performance Least Recently Used (LRU) cache implementation with optional TTL support. - * Items are automatically evicted when the cache reaches its maximum size, - * removing the least recently used items first. All core operations (get, set, delete) are O(1). - * - * @class LRU - */ -class LRU { - /** - * Creates a new LRU cache instance. - * Note: Constructor does not validate parameters. Use lru() factory function for parameter validation. - * - * @constructor - * @param {number} [max=0] - Maximum number of items to store. 0 means unlimited. - * @param {number} [ttl=0] - Time to live in milliseconds. 0 means no expiration. - * @param {boolean} [resetTtl=false] - Whether to reset TTL when updating existing items via set(). - */ - constructor(max = 0, ttl = 0, resetTtl = false) { - this.first = null; - this.items = Object.create(null); - this.last = null; - this.max = max; - this.resetTtl = resetTtl; - this.size = 0; - this.ttl = ttl; - } - - /** - * Removes all items from the cache. - * - * @returns {LRU} The LRU instance for method chaining. - */ - clear() { - this.first = null; - this.items = Object.create(null); - this.last = null; - this.size = 0; - - return this; - } - - /** - * Removes an item from the cache by key. - * - * @param {string} key - The key of the item to delete. - * @returns {LRU} The LRU instance for method chaining. - */ - delete(key) { - const item = this.items[key]; - - if (item !== undefined) { - delete this.items[key]; - this.size--; - - this.#unlink(item); - - item.prev = null; - item.next = null; - } - - return this; - } - - /** - * Returns an array of [key, value] pairs for the specified keys. - * When no keys provided, returns all entries in LRU order. - * When keys provided, order matches the input array. - * - * @param {string[]} [keys=this.keys()] - Array of keys to get entries for. Defaults to all keys. - * @returns {Array>} Array of [key, value] pairs. - */ - entries(keys) { - if (keys === undefined) { - keys = this.keys(); - } - - const result = Array.from({ length: keys.length }); - for (let i = 0; i < keys.length; i++) { - const key = keys[i]; - const item = this.items[key]; - result[i] = [key, item !== undefined ? item.value : undefined]; - } - - return result; - } - - /** - * Removes the least recently used item from the cache. - * - * @returns {LRU} The LRU instance for method chaining. - */ - evict() { - if (this.size === 0) { - return this; - } - - const item = this.first; - - delete this.items[item.key]; - - if (--this.size === 0) { - this.first = null; - this.last = null; - } else { - this.#unlink(item); - } - - item.next = null; - - return this; - } - - /** - * Returns the expiration timestamp for a given key. - * - * @param {string} key - The key to check expiration for. - * @returns {number|undefined} The expiration timestamp in milliseconds, or undefined if key doesn't exist. - */ - expiresAt(key) { - const item = this.items[key]; - return item !== undefined ? item.expiry : undefined; - } - - /** - * Retrieves a value from the cache by key. Updates the item's position to most recently used. - * - * @param {string} key - The key to retrieve. - * @returns {*} The value associated with the key, or undefined if not found or expired. - */ - get(key) { - const item = this.items[key]; - - if (item !== undefined) { - // Check TTL only if enabled to avoid unnecessary Date.now() calls - if (this.ttl > 0) { - if (item.expiry <= Date.now()) { - this.delete(key); - - return undefined; - } - } - - // Fast LRU update without full set() overhead - this.moveToEnd(item); - - return item.value; - } - - return undefined; - } - - /** - * Checks if a key exists in the cache. - * - * @param {string} key - The key to check for. - * @returns {boolean} True if the key exists, false otherwise. - */ - has(key) { - const item = this.items[key]; - return item !== undefined && (this.ttl === 0 || item.expiry > Date.now()); - } - - /** - * Unlinks an item from the doubly-linked list. - * Updates first/last pointers if needed. - * Does NOT clear the item's prev/next pointers or delete from items map. - * - * @private - */ - #unlink(item) { - if (item.prev !== null) { - item.prev.next = item.next; - } - - if (item.next !== null) { - item.next.prev = item.prev; - } - - if (this.first === item) { - this.first = item.next; - } - - if (this.last === item) { - this.last = item.prev; - } - } - - /** - * Efficiently moves an item to the end of the LRU list (most recently used position). - * This is an internal optimization method that avoids the overhead of the full set() operation - * when only LRU position needs to be updated. - * - * @param {Object} item - The cache item with prev/next pointers to reposition. - * @private - */ - moveToEnd(item) { - if (this.last === item) { - return; - } - - this.#unlink(item); - - item.prev = this.last; - item.next = null; - this.last.next = item; - this.last = item; - } - - /** - * Returns an array of all keys in the cache, ordered from least to most recently used. - * - * @returns {string[]} Array of keys in LRU order. - */ - keys() { - const result = Array.from({ length: this.size }); - let x = this.first; - let i = 0; - - while (x !== null) { - result[i++] = x.key; - x = x.next; - } - - return result; - } - - /** - * Sets a value in the cache and returns any evicted item. - * - * @param {string} key - The key to set. - * @param {*} value - The value to store. - * @returns {Object|null} The evicted item (if any) with shape {key, value, expiry}, or null. - */ - setWithEvicted(key, value) { - let evicted = null; - let item = this.items[key]; - - if (item !== undefined) { - item.value = value; - if (this.resetTtl) { - item.expiry = this.ttl > 0 ? Date.now() + this.ttl : this.ttl; - } - this.moveToEnd(item); - } else { - if (this.max > 0 && this.size === this.max) { - evicted = { - key: this.first.key, - value: this.first.value, - expiry: this.first.expiry, - }; - this.evict(); - } - - item = this.items[key] = { - expiry: this.ttl > 0 ? Date.now() + this.ttl : this.ttl, - key: key, - prev: this.last, - next: null, - value, - }; - - if (++this.size === 1) { - this.first = item; - } else { - this.last.next = item; - } - - this.last = item; - } - - return evicted; - } - - /** - * Sets a value in the cache. Updates the item's position to most recently used. - * - * @param {string} key - The key to set. - * @param {*} value - The value to store. - * @returns {LRU} The LRU instance for method chaining. - */ - set(key, value) { - let item = this.items[key]; - - if (item !== undefined) { - item.value = value; - - if (this.resetTtl) { - item.expiry = this.ttl > 0 ? Date.now() + this.ttl : this.ttl; - } - - this.moveToEnd(item); - } else { - if (this.max > 0 && this.size === this.max) { - this.evict(); - } - - item = this.items[key] = { - expiry: this.ttl > 0 ? Date.now() + this.ttl : this.ttl, - key: key, - prev: this.last, - next: null, - value, - }; - - if (++this.size === 1) { - this.first = item; - } else { - this.last.next = item; - } - - this.last = item; - } - - return this; - } - - /** - * Returns an array of all values in the cache for the specified keys. - * When no keys provided, returns all values in LRU order. - * When keys provided, order matches the input array. - * - * @param {string[]} [keys=this.keys()] - Array of keys to get values for. Defaults to all keys. - * @returns {Array<*>} Array of values corresponding to the keys. - */ - values(keys) { - if (keys === undefined) { - keys = this.keys(); - } - - const result = Array.from({ length: keys.length }); - for (let i = 0; i < keys.length; i++) { - const item = this.items[keys[i]]; - result[i] = item !== undefined ? item.value : undefined; - } - - return result; - } -} - -/** - * Factory function to create a new LRU cache instance with parameter validation. - * - * @function lru - * @param {number} [max=1000] - Maximum number of items to store. Must be >= 0. Use 0 for unlimited size. - * @param {number} [ttl=0] - Time to live in milliseconds. Must be >= 0. Use 0 for no expiration. - * @param {boolean} [resetTtl=false] - Whether to reset TTL when accessing existing items via get(). - * @returns {LRU} A new LRU cache instance. - * @throws {TypeError} When parameters are invalid (negative numbers or wrong types). - */ -function lru(max = 1000, ttl = 0, resetTtl = false) { - if (isNaN(max) || max < 0) { - throw new TypeError("Invalid max value"); - } - - if (isNaN(ttl) || ttl < 0) { - throw new TypeError("Invalid ttl value"); - } - - if (typeof resetTtl !== "boolean") { - throw new TypeError("Invalid resetTtl value"); - } - - return new LRU(max, ttl, resetTtl); -}exports.LRU=LRU;exports.lru=lru;})); \ No newline at end of file diff --git a/dist/tiny-lru.umd.min.js b/dist/tiny-lru.umd.min.js deleted file mode 100644 index b76e912..0000000 --- a/dist/tiny-lru.umd.min.js +++ /dev/null @@ -1,5 +0,0 @@ -/*! - 2026 Jason Mulligan - @version 12.0.0 -*/ -!function(t,i){"object"==typeof exports&&"undefined"!=typeof module?i(exports):"function"==typeof define&&define.amd?define(["exports"],i):i((t="undefined"!=typeof globalThis?globalThis:t||self).lru={})}(this,(function(t){"use strict";class i{constructor(t=0,i=0,e=!1){this.first=null,this.items=Object.create(null),this.last=null,this.max=t,this.resetTtl=e,this.size=0,this.ttl=i}clear(){return this.first=null,this.items=Object.create(null),this.last=null,this.size=0,this}delete(t){const i=this.items[t];return void 0!==i&&(delete this.items[t],this.size--,this.#t(i),i.prev=null,i.next=null),this}entries(t){void 0===t&&(t=this.keys());const i=Array.from({length:t.length});for(let e=0;e0&&i.expiry<=Date.now()?void this.delete(t):(this.moveToEnd(i),i.value)}has(t){const i=this.items[t];return void 0!==i&&(0===this.ttl||i.expiry>Date.now())}#t(t){null!==t.prev&&(t.prev.next=t.next),null!==t.next&&(t.next.prev=t.prev),this.first===t&&(this.first=t.next),this.last===t&&(this.last=t.prev)}moveToEnd(t){this.last!==t&&(this.#t(t),t.prev=this.last,t.next=null,this.last.next=t,this.last=t)}keys(){const t=Array.from({length:this.size});let i=this.first,e=0;for(;null!==i;)t[e++]=i.key,i=i.next;return t}setWithEvicted(t,i){let e=null,s=this.items[t];return void 0!==s?(s.value=i,this.resetTtl&&(s.expiry=this.ttl>0?Date.now()+this.ttl:this.ttl),this.moveToEnd(s)):(this.max>0&&this.size===this.max&&(e={key:this.first.key,value:this.first.value,expiry:this.first.expiry},this.evict()),s=this.items[t]={expiry:this.ttl>0?Date.now()+this.ttl:this.ttl,key:t,prev:this.last,next:null,value:i},1==++this.size?this.first=s:this.last.next=s,this.last=s),e}set(t,i){let e=this.items[t];return void 0!==e?(e.value=i,this.resetTtl&&(e.expiry=this.ttl>0?Date.now()+this.ttl:this.ttl),this.moveToEnd(e)):(this.max>0&&this.size===this.max&&this.evict(),e=this.items[t]={expiry:this.ttl>0?Date.now()+this.ttl:this.ttl,key:t,prev:this.last,next:null,value:i},1==++this.size?this.first=e:this.last.next=e,this.last=e),this}values(t){void 0===t&&(t=this.keys());const i=Array.from({length:t.length});for(let e=0;e>} Array of [key, value] pairs.\n\t */\n\tentries(keys) {\n\t\tif (keys === undefined) {\n\t\t\tkeys = this.keys();\n\t\t}\n\n\t\tconst result = Array.from({ length: keys.length });\n\t\tfor (let i = 0; i < keys.length; i++) {\n\t\t\tconst key = keys[i];\n\t\t\tconst item = this.items[key];\n\t\t\tresult[i] = [key, item !== undefined ? item.value : undefined];\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/**\n\t * Removes the least recently used item from the cache.\n\t *\n\t * @returns {LRU} The LRU instance for method chaining.\n\t */\n\tevict() {\n\t\tif (this.size === 0) {\n\t\t\treturn this;\n\t\t}\n\n\t\tconst item = this.first;\n\n\t\tdelete this.items[item.key];\n\n\t\tif (--this.size === 0) {\n\t\t\tthis.first = null;\n\t\t\tthis.last = null;\n\t\t} else {\n\t\t\tthis.#unlink(item);\n\t\t}\n\n\t\titem.next = null;\n\n\t\treturn this;\n\t}\n\n\t/**\n\t * Returns the expiration timestamp for a given key.\n\t *\n\t * @param {string} key - The key to check expiration for.\n\t * @returns {number|undefined} The expiration timestamp in milliseconds, or undefined if key doesn't exist.\n\t */\n\texpiresAt(key) {\n\t\tconst item = this.items[key];\n\t\treturn item !== undefined ? item.expiry : undefined;\n\t}\n\n\t/**\n\t * Retrieves a value from the cache by key. Updates the item's position to most recently used.\n\t *\n\t * @param {string} key - The key to retrieve.\n\t * @returns {*} The value associated with the key, or undefined if not found or expired.\n\t */\n\tget(key) {\n\t\tconst item = this.items[key];\n\n\t\tif (item !== undefined) {\n\t\t\t// Check TTL only if enabled to avoid unnecessary Date.now() calls\n\t\t\tif (this.ttl > 0) {\n\t\t\t\tif (item.expiry <= Date.now()) {\n\t\t\t\t\tthis.delete(key);\n\n\t\t\t\t\treturn undefined;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Fast LRU update without full set() overhead\n\t\t\tthis.moveToEnd(item);\n\n\t\t\treturn item.value;\n\t\t}\n\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Checks if a key exists in the cache.\n\t *\n\t * @param {string} key - The key to check for.\n\t * @returns {boolean} True if the key exists, false otherwise.\n\t */\n\thas(key) {\n\t\tconst item = this.items[key];\n\t\treturn item !== undefined && (this.ttl === 0 || item.expiry > Date.now());\n\t}\n\n\t/**\n\t * Unlinks an item from the doubly-linked list.\n\t * Updates first/last pointers if needed.\n\t * Does NOT clear the item's prev/next pointers or delete from items map.\n\t *\n\t * @private\n\t */\n\t#unlink(item) {\n\t\tif (item.prev !== null) {\n\t\t\titem.prev.next = item.next;\n\t\t}\n\n\t\tif (item.next !== null) {\n\t\t\titem.next.prev = item.prev;\n\t\t}\n\n\t\tif (this.first === item) {\n\t\t\tthis.first = item.next;\n\t\t}\n\n\t\tif (this.last === item) {\n\t\t\tthis.last = item.prev;\n\t\t}\n\t}\n\n\t/**\n\t * Efficiently moves an item to the end of the LRU list (most recently used position).\n\t * This is an internal optimization method that avoids the overhead of the full set() operation\n\t * when only LRU position needs to be updated.\n\t *\n\t * @param {Object} item - The cache item with prev/next pointers to reposition.\n\t * @private\n\t */\n\tmoveToEnd(item) {\n\t\tif (this.last === item) {\n\t\t\treturn;\n\t\t}\n\n\t\tthis.#unlink(item);\n\n\t\titem.prev = this.last;\n\t\titem.next = null;\n\t\tthis.last.next = item;\n\t\tthis.last = item;\n\t}\n\n\t/**\n\t * Returns an array of all keys in the cache, ordered from least to most recently used.\n\t *\n\t * @returns {string[]} Array of keys in LRU order.\n\t */\n\tkeys() {\n\t\tconst result = Array.from({ length: this.size });\n\t\tlet x = this.first;\n\t\tlet i = 0;\n\n\t\twhile (x !== null) {\n\t\t\tresult[i++] = x.key;\n\t\t\tx = x.next;\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/**\n\t * Sets a value in the cache and returns any evicted item.\n\t *\n\t * @param {string} key - The key to set.\n\t * @param {*} value - The value to store.\n\t * @returns {Object|null} The evicted item (if any) with shape {key, value, expiry}, or null.\n\t */\n\tsetWithEvicted(key, value) {\n\t\tlet evicted = null;\n\t\tlet item = this.items[key];\n\n\t\tif (item !== undefined) {\n\t\t\titem.value = value;\n\t\t\tif (this.resetTtl) {\n\t\t\t\titem.expiry = this.ttl > 0 ? Date.now() + this.ttl : this.ttl;\n\t\t\t}\n\t\t\tthis.moveToEnd(item);\n\t\t} else {\n\t\t\tif (this.max > 0 && this.size === this.max) {\n\t\t\t\tevicted = {\n\t\t\t\t\tkey: this.first.key,\n\t\t\t\t\tvalue: this.first.value,\n\t\t\t\t\texpiry: this.first.expiry,\n\t\t\t\t};\n\t\t\t\tthis.evict();\n\t\t\t}\n\n\t\t\titem = this.items[key] = {\n\t\t\t\texpiry: this.ttl > 0 ? Date.now() + this.ttl : this.ttl,\n\t\t\t\tkey: key,\n\t\t\t\tprev: this.last,\n\t\t\t\tnext: null,\n\t\t\t\tvalue,\n\t\t\t};\n\n\t\t\tif (++this.size === 1) {\n\t\t\t\tthis.first = item;\n\t\t\t} else {\n\t\t\t\tthis.last.next = item;\n\t\t\t}\n\n\t\t\tthis.last = item;\n\t\t}\n\n\t\treturn evicted;\n\t}\n\n\t/**\n\t * Sets a value in the cache. Updates the item's position to most recently used.\n\t *\n\t * @param {string} key - The key to set.\n\t * @param {*} value - The value to store.\n\t * @returns {LRU} The LRU instance for method chaining.\n\t */\n\tset(key, value) {\n\t\tlet item = this.items[key];\n\n\t\tif (item !== undefined) {\n\t\t\titem.value = value;\n\n\t\t\tif (this.resetTtl) {\n\t\t\t\titem.expiry = this.ttl > 0 ? Date.now() + this.ttl : this.ttl;\n\t\t\t}\n\n\t\t\tthis.moveToEnd(item);\n\t\t} else {\n\t\t\tif (this.max > 0 && this.size === this.max) {\n\t\t\t\tthis.evict();\n\t\t\t}\n\n\t\t\titem = this.items[key] = {\n\t\t\t\texpiry: this.ttl > 0 ? Date.now() + this.ttl : this.ttl,\n\t\t\t\tkey: key,\n\t\t\t\tprev: this.last,\n\t\t\t\tnext: null,\n\t\t\t\tvalue,\n\t\t\t};\n\n\t\t\tif (++this.size === 1) {\n\t\t\t\tthis.first = item;\n\t\t\t} else {\n\t\t\t\tthis.last.next = item;\n\t\t\t}\n\n\t\t\tthis.last = item;\n\t\t}\n\n\t\treturn this;\n\t}\n\n\t/**\n\t * Returns an array of all values in the cache for the specified keys.\n\t * When no keys provided, returns all values in LRU order.\n\t * When keys provided, order matches the input array.\n\t *\n\t * @param {string[]} [keys=this.keys()] - Array of keys to get values for. Defaults to all keys.\n\t * @returns {Array<*>} Array of values corresponding to the keys.\n\t */\n\tvalues(keys) {\n\t\tif (keys === undefined) {\n\t\t\tkeys = this.keys();\n\t\t}\n\n\t\tconst result = Array.from({ length: keys.length });\n\t\tfor (let i = 0; i < keys.length; i++) {\n\t\t\tconst item = this.items[keys[i]];\n\t\t\tresult[i] = item !== undefined ? item.value : undefined;\n\t\t}\n\n\t\treturn result;\n\t}\n}\n\n/**\n * Factory function to create a new LRU cache instance with parameter validation.\n *\n * @function lru\n * @param {number} [max=1000] - Maximum number of items to store. Must be >= 0. Use 0 for unlimited size.\n * @param {number} [ttl=0] - Time to live in milliseconds. Must be >= 0. Use 0 for no expiration.\n * @param {boolean} [resetTtl=false] - Whether to reset TTL when accessing existing items via get().\n * @returns {LRU} A new LRU cache instance.\n * @throws {TypeError} When parameters are invalid (negative numbers or wrong types).\n */\nexport function lru(max = 1000, ttl = 0, resetTtl = false) {\n\tif (isNaN(max) || max < 0) {\n\t\tthrow new TypeError(\"Invalid max value\");\n\t}\n\n\tif (isNaN(ttl) || ttl < 0) {\n\t\tthrow new TypeError(\"Invalid ttl value\");\n\t}\n\n\tif (typeof resetTtl !== \"boolean\") {\n\t\tthrow new TypeError(\"Invalid resetTtl value\");\n\t}\n\n\treturn new LRU(max, ttl, resetTtl);\n}\n"],"names":["g","f","exports","module","define","amd","globalThis","self","lru","this","LRU","constructor","max","ttl","resetTtl","first","items","Object","create","last","size","clear","key","item","undefined","unlink","prev","next","entries","keys","result","Array","from","length","i","value","evict","expiresAt","expiry","get","Date","now","delete","moveToEnd","has","x","setWithEvicted","evicted","set","values","isNaN","TypeError"],"mappings":"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"} \ No newline at end of file diff --git a/docs/API.md b/docs/API.md index 0d2e110..e5b4c48 100644 --- a/docs/API.md +++ b/docs/API.md @@ -7,7 +7,36 @@ Complete API documentation for tiny-lru. - [Factory Function](#factory-function) - [LRU Class](#lru-class) - [Properties](#properties) + - [first](#first) + - [last](#last) + - [max](#max) + - [resetTTL](#resetttl) + - [size](#size) + - [ttl](#ttl) - [Methods](#methods) + - [cleanup()](#cleanup) + - [clear()](#clear) + - [delete(key)](#deletekey) + - [entries(keys?)](#entrieskeys) + - [evict()](#evict) + - [expiresAt(key)](#expiresatkey) + - [forEach(callback, thisArg?)](#foreachcallback-thisarg) + - [get(key)](#getkey) + - [getMany(keys)](#getmanykeys) + - [has(key)](#haskey) + - [hasAll(keys)](#hasallkeys) + - [hasAny(keys)](#hasanykeys) + - [keys()](#keys) + - [keysByTTL()](#keysbyttl) + - [onEvict(callback)](#onevictcallback) + - [peek(key)](#peekkey) + - [set(key, value)](#setkey-value) + - [setWithEvicted(key, value)](#setwithevictedkey-value) + - [sizeByTTL()](#sizebyttl) + - [stats()](#stats) + - [toJSON()](#tojson) + - [values(keys?)](#valueskeys) + - [valuesByTTL()](#valuesbyttl) --- @@ -32,7 +61,7 @@ const cache = lru(100, 5000, true); | ---------- | --------- | ------- | ---------------------------------------------------------------- | | `max` | `number` | `1000` | Maximum items. `0` = unlimited. Must be >= 0. | | `ttl` | `number` | `0` | Time-to-live in milliseconds. `0` = no expiration. Must be >= 0. | -| `resetTtl` | `boolean` | `false` | Reset TTL when updating existing items via `set()` | +| `resetTTL` | `boolean` | `false` | Reset TTL when updating existing items via `set()` | **Returns:** `LRU` - New cache instance @@ -64,41 +93,43 @@ const cache = new LRU(100, 5000, true); | ---------- | --------- | ------- | -------------------------------------------------- | | `max` | `number` | `0` | Maximum items. `0` = unlimited. | | `ttl` | `number` | `0` | Time-to-live in milliseconds. `0` = no expiration. | -| `resetTtl` | `boolean` | `false` | Reset TTL when updating via `set()` | +| `resetTTL` | `boolean` | `false` | Reset TTL when updating via `set()` | --- ## Properties -### `size` +### `first` -`number` - Current number of items in cache. +`Object | null` - Least recently used item (node with `key`, `value`, `prev`, `next`, `expiry`). ```javascript -const cache = lru(10); +const cache = lru(2); cache.set("a", 1).set("b", 2); -console.log(cache.size); // 2 +console.log(cache.first.key); // "a" +console.log(cache.first.value); // 1 ``` -### `max` +### `last` -`number` - Maximum number of items allowed. +`Object | null` - Most recently used item. ```javascript -const cache = lru(100); -console.log(cache.max); // 100 +const cache = lru(2); +cache.set("a", 1).set("b", 2); +console.log(cache.last.key); // "b" ``` -### `ttl` +### `max` -`number` - Time-to-live in milliseconds. `0` = no expiration. +`number` - Maximum number of items allowed. ```javascript -const cache = lru(100, 60000); -console.log(cache.ttl); // 60000 +const cache = lru(100); +console.log(cache.max); // 100 ``` -### `resetTtl` +### `resetTTL` `boolean` - Whether TTL resets on `set()` updates. @@ -107,31 +138,46 @@ const cache = lru(100, 5000, true); console.log(cache.resetTtl); // true ``` -### `first` +### `size` -`Object | null` - Least recently used item (node with `key`, `value`, `prev`, `next`, `expiry`). +`number` - Current number of items in cache. ```javascript -const cache = lru(2); +const cache = lru(10); cache.set("a", 1).set("b", 2); -console.log(cache.first.key); // "a" -console.log(cache.first.value); // 1 +console.log(cache.size); // 2 ``` -### `last` +### `ttl` -`Object | null` - Most recently used item. +`number` - Time-to-live in milliseconds. `0` = no expiration. ```javascript -const cache = lru(2); -cache.set("a", 1).set("b", 2); -console.log(cache.last.key); // "b" +const cache = lru(100, 60000); +console.log(cache.ttl); // 60000 ``` --- ## Methods +### `cleanup()` + +Removes expired items without affecting LRU order. Silently removes expired items without triggering the `onEvict()` callback. + +```javascript +cache.set("a", 1).set("b", 2); +// ... wait for items to expire +const removed = cache.cleanup(); +console.log(removed); // 2 (number of items removed) +``` + +**Returns:** `number` - Number of expired items removed + +**Note:** Only removes items when TTL is enabled (`ttl > 0`). Does not support method chaining (returns `number`). + +--- + ### `clear()` Removes all items from cache. @@ -169,7 +215,7 @@ console.log(cache.size); // 1 ### `entries(keys?)` -Returns `[key, value]` pairs in LRU order. +Returns `[key, value]` pairs. Without `keys`: returns all entries in LRU order. With `keys`: order matches the input array. ```javascript cache.set("a", 1).set("b", 2).set("c", 3); @@ -177,7 +223,7 @@ console.log(cache.entries()); // [['a', 1], ['b', 2], ['c', 3]] console.log(cache.entries(["c", "a"])); -// [['c', 3], ['a', 1]] - respects LRU order +// [['c', 3], ['a', 1]] - order matches input array ``` **Parameters:** @@ -186,7 +232,7 @@ console.log(cache.entries(["c", "a"])); | ------ | ---------- | ---------------------------------- | | `keys` | `string[]` | Optional specific keys to retrieve | -**Returns:** `Array<[string, *]>` - Array of key-value pairs +**Returns:** `[string, *][]` - Array of 2-element tuples [key, value] --- @@ -226,6 +272,34 @@ console.log(cache.expiresAt("nonexistent")); // undefined --- +### `forEach(callback, thisArg?)` + +Iterates over cache items in LRU order (least to most recent). + +```javascript +cache.set("a", 1).set("b", 2).set("c", 3); +cache.forEach((value, key, cache) => { + console.log(key, value); +}); +// Output: +// a 1 +// b 2 +// c 3 +``` + +**Parameters:** + +| Name | Type | Description | +| --------- | ---------- | --------------------------------------- | +| `callback` | `function` | Function to call for each item. Signature: `callback(value, key, cache)` | +| `thisArg` | `*` | Value to use as `this` when executing callback | + +**Returns:** `LRU` - this instance (for chaining) + +**Note:** This method traverses the linked list directly and does not update LRU order or check TTL expiration during iteration. Cache modifications during iteration may cause unexpected behavior. + +--- + ### `get(key)` Retrieves value and promotes to most recently used. @@ -248,6 +322,28 @@ Expired items are deleted and return `undefined`. --- +### `getMany(keys)` + +Batch retrieves multiple items. Calls `get()` for each key, so it updates LRU order and may remove expired items. + +```javascript +cache.set("a", 1).set("b", 2).set("c", 3); +const result = cache.getMany(["a", "c"]); +console.log(result); // { a: 1, c: 3 } +``` + +**Parameters:** + +| Name | Type | Description | +| ------ | ---------- | -------------------- | +| `keys` | `string[]` | Array of keys to get | + +**Returns:** `Object` - Object mapping keys to values (undefined for missing/expired keys) + +**Note:** Returns `undefined` for non-existent or expired keys. This method is NOT read-only - it updates LRU order (items move to most recently used) and may delete expired items, affecting `hits`, `misses`, and `deletes` stats. + +--- + ### `has(key)` Checks if key exists and is not expired. @@ -268,6 +364,48 @@ cache.has("nonexistent"); // false --- +### `hasAll(keys)` + +Batch existence check - returns true if ALL keys exist and are not expired. + +```javascript +cache.set("a", 1).set("b", 2); +const result = cache.hasAll(["a", "b"]); +console.log(result); // true + +cache.hasAll(["a", "nonexistent"]); // false +``` + +**Parameters:** + +| Name | Type | Description | +| ------ | ---------- | -------------------- | +| `keys` | `string[]` | Array of keys to check | + +**Returns:** `boolean` - True if all keys exist and are not expired. Returns `true` for empty arrays (vacuous truth). + +--- + +### `hasAny(keys)` + +Batch existence check - returns true if ANY key exists and is not expired. + +```javascript +cache.set("a", 1).set("b", 2); +cache.hasAny(["nonexistent", "a"]); // true +cache.hasAny(["nonexistent1", "nonexistent2"]); // false +``` + +**Parameters:** + +| Name | Type | Description | +| ------ | ---------- | -------------------- | +| `keys` | `string[]` | Array of keys to check | + +**Returns:** `boolean` - True if any key exists and is not expired. Returns `false` for empty arrays. + +--- + ### `keys()` Returns all keys in LRU order (oldest first). @@ -282,6 +420,75 @@ console.log(cache.keys()); // ['b', 'c', 'a'] --- +### `keysByTTL()` + +Returns keys grouped by TTL status. + +```javascript +cache.set("a", 1).set("b", 2); +console.log(cache.keysByTTL()); +// { valid: ["a", "b"], expired: [], noTTL: ["a", "b"] } +``` + +**Returns:** `Object` - Object with three properties: +- `valid` - Array of valid (non-expired) keys +- `expired` - Array of expired keys +- `noTTL` - Array of keys without TTL (`expiry === 0`) + +--- + +### `onEvict(callback)` + +Registers a callback function to be called when items are evicted. + +```javascript +cache.onEvict((item) => { + console.log("Evicted:", item.key, item.value); +}); + +cache.set("a", 1).set("b", 2).set("c", 3).set("d", 4); +// Evicted: a 1 +``` + +**Parameters:** + +| Name | Type | Description | +| --------- | ---------- | ----------------------------------------------------- | +| `callback` | `function` | Function called with evicted item. Receives `{key, value, expiry}` | + +**Returns:** `LRU` - this instance (for chaining) + +**Throws:** `TypeError` if callback is not a function + +**Note:** Only the last registered callback will be used. Triggers on: +- Explicit eviction via `evict()` +- Implicit eviction via `set()`/`setWithEvicted()` when cache is at max capacity +Does NOT trigger on TTL expiry (items are silently removed). + +--- + +### `peek(key)` + +Retrieves value without updating LRU order. + +```javascript +cache.set("a", 1).set("b", 2); +cache.peek("a"); // 1 +console.log(cache.keys()); // ['b', 'a'] - order unchanged +``` + +**Parameters:** + +| Name | Type | Description | +| ----- | -------- | --------------- | +| `key` | `string` | Key to retrieve | + +**Returns:** `* | undefined` - Value or undefined if not found + +**Note:** Does not perform TTL expiration checks or update LRU order. + +--- + ### `set(key, value)` Stores value and moves to most recently used. @@ -322,7 +529,77 @@ console.log(cache.keys()); // ['b', 'c'] | `key` | `string` | Item key | | `value` | `*` | Item value | -**Returns:** `{ key, value, expiry } | null` - Evicted item or null +**Returns:** `{ key, value, expiry } | null` - Evicted item (if any) or `null` when no eviction occurs + +**Note:** When updating an existing key with `resetTtl=true`, the TTL is reset but no eviction occurs (returns `null`). + +--- + +### `sizeByTTL()` + +Returns counts of items by TTL status. + +```javascript +cache.set("a", 1).set("b", 2); +console.log(cache.sizeByTTL()); +// { valid: 2, expired: 0, noTTL: 0 } +``` + +**Returns:** `Object` - Object with three properties: +- `valid` - Number of items that haven't expired +- `expired` - Number of expired items +- `noTTL` - Number of items without TTL (`expiry === 0`) + +**Note:** Items without TTL (expiry === 0) count as both valid and noTTL. + +--- + +### `stats()` + +Returns cache statistics. + +```javascript +cache.set("a", 1).set("b", 2); +cache.get("a"); +cache.get("nonexistent"); + +console.log(cache.stats()); +// { +// hits: 1, +// misses: 1, +// sets: 2, +// deletes: 0, +// evictions: 0 +// } +``` + +**Returns:** `Object` - Statistics object with the following properties: +- `hits` - Number of successful get() calls +- `misses` - Number of failed get() calls +- `sets` - Number of set() and setWithEvicted() calls +- `deletes` - Number of delete() calls plus internal removal of expired items by get() +- `evictions` - Number of evicted items + +--- + +### `toJSON()` + +Serializes cache to JSON-compatible format. + +```javascript +cache.set("a", 1).set("b", 2); +const json = cache.toJSON(); +console.log(json); +// [ +// { key: "a", value: 1, expiry: 0 }, +// { key: "b", value: 2, expiry: 0 } +// ] + +// Works with JSON.stringify: +const jsonString = JSON.stringify(cache); +``` + +**Returns:** `Array<{key, value, expiry}>` - Array of cache items --- @@ -336,7 +613,7 @@ console.log(cache.values()); // [1, 2, 3] console.log(cache.values(["c", "a"])); -// [3, 1] - respects LRU order +// [3, 1] - order matches input array ``` **Parameters:** @@ -349,6 +626,23 @@ console.log(cache.values(["c", "a"])); --- +### `valuesByTTL()` + +Returns values grouped by TTL status. + +```javascript +cache.set("a", 1).set("b", 2); +console.log(cache.valuesByTTL()); +// { valid: [1, 2], expired: [], noTTL: [1, 2] } +``` + +**Returns:** `Object` - Object with three properties: +- `valid` - Array of valid (non-expired) values +- `expired` - Array of expired values +- `noTTL` - Array of values without TTL (`expiry === 0`, regardless of cache `ttl` setting) + +--- + ## Evicted Item Shape When `setWithEvicted` returns an evicted item: @@ -365,7 +659,7 @@ When `setWithEvicted` returns an evicted item: ## Method Chaining -All mutation methods return `this` for chaining: +All mutation methods return `this` for chaining (except `cleanup()` which returns `number`): ```javascript cache.set("a", 1).set("b", 2).set("c", 3).delete("a").evict(); diff --git a/docs/CODE_STYLE_GUIDE.md b/docs/CODE_STYLE_GUIDE.md index c129134..85530c4 100644 --- a/docs/CODE_STYLE_GUIDE.md +++ b/docs/CODE_STYLE_GUIDE.md @@ -185,6 +185,13 @@ if (isNaN(max) || max < 0) { } ``` +## Testing and Coverage + +- All tests must pass before merging +- 100% line coverage required +- Run tests with `npm test` (includes linting) +- Generate coverage report with `npm run coverage` + ## Lint Configuration The project uses oxlint. Run `npm run lint` to check code style. diff --git a/docs/TECHNICAL_DOCUMENTATION.md b/docs/TECHNICAL_DOCUMENTATION.md index 4ff417b..c2d1057 100644 --- a/docs/TECHNICAL_DOCUMENTATION.md +++ b/docs/TECHNICAL_DOCUMENTATION.md @@ -56,7 +56,7 @@ graph TD - `max`: Maximum cache size (0 = unlimited) - `size`: Current number of items - `ttl`: Time-to-live in milliseconds (0 = no expiration) - - `resetTtl`: Whether to reset TTL on `set()` and `setWithEvicted()` operations (not on `get()`) + - `resetTTL`: Whether to reset TTL on `set()` and `setWithEvicted()` operations (not on `get()`) ## Data Flow @@ -127,7 +127,8 @@ sequenceDiagram | `setWithEvicted(key, value)` | O(1) | O(1) | O(1) | Store value, return evicted item | | `delete(key)` | O(1) | O(1) | O(1) | Remove item from cache | | `has(key)` | O(1) | O(1) | O(1) | Check key existence | -| `clear()` | O(1) | O(1) | O(1) | Reset all pointers | +| `peek(key)` | O(1) | O(1) | O(1) | Retrieve value without LRU update | +| `clear()` | O(n) | O(n) | O(1) | Reset all pointers, nullify node links | | `evict()` | O(1) | O(1) | O(1) | Remove least recently used item | | `expiresAt(key)` | O(1) | O(1) | O(1) | Get expiration timestamp | | `moveToEnd(item)` | O(1) | O(1) | O(1) | Internal: optimize LRU positioning | @@ -338,6 +339,7 @@ export class LRU { get(key: any): T | undefined; has(key: any): boolean; keys(): any[]; + peek(key: any): T | undefined; set(key: any, value: T): this; setWithEvicted(key: any, value: T): EvictedItem | null; values(keys?: any[]): (T | undefined)[]; @@ -1082,10 +1084,6 @@ export default [ { format: "esm", file: "dist/tiny-lru.js" }, // Minified ES Modules { format: "esm", file: "dist/tiny-lru.min.js", plugins: [terser()] }, - // UMD for browsers - { format: "umd", file: "dist/tiny-lru.umd.js", name: "lru" }, - // Minified UMD - { format: "umd", file: "dist/tiny-lru.umd.min.js", name: "lru", plugins: [terser()] }, ], }, ]; @@ -1111,8 +1109,7 @@ export default [ - **ESM**: `dist/tiny-lru.js` - ES Modules for modern bundlers - **CommonJS**: `dist/tiny-lru.cjs` - Node.js compatible format -- **UMD**: `dist/tiny-lru.umd.js` - Universal format for browsers -- **Minified**: All formats available with `.min.js` extension +- **Minified**: `dist/tiny-lru.min.js` - Minified ESM - **TypeScript**: `types/lru.d.ts` - Complete type definitions ### Development Commands @@ -1142,9 +1139,7 @@ tiny-lru/ ├── dist/ # Built distributions (generated) │ ├── tiny-lru.js # ES Modules │ ├── tiny-lru.cjs # CommonJS -│ ├── tiny-lru.min.js # Minified ESM -│ ├── tiny-lru.umd.js # UMD -│ └── tiny-lru.umd.min.js # Minified UMD +│ └── tiny-lru.min.js # Minified ESM ├── tests/ │ ├── unit/ # Unit tests with Node.js built-in test runner │ └── integration/ # Integration tests diff --git a/rollup.config.js b/rollup.config.js index 05849ef..2331b3c 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -17,7 +17,6 @@ const bannerShort = `/*! const defaultOutBase = { compact: true, banner: bannerLong, name: pkg.name }; const cjOutBase = { ...defaultOutBase, compact: false, format: "cjs", exports: "named" }; const esmOutBase = { ...defaultOutBase, format: "esm" }; -const umdOutBase = { ...defaultOutBase, format: "umd" }; const minOutBase = { banner: bannerShort, name: pkg.name, plugins: [terser()], sourcemap: true }; export default [ @@ -37,17 +36,6 @@ export default [ ...minOutBase, file: `dist/${pkg.name}.min.js`, }, - { - ...umdOutBase, - file: `dist/${pkg.name}.umd.js`, - name: "lru", - }, - { - ...umdOutBase, - ...minOutBase, - file: `dist/${pkg.name}.umd.min.js`, - name: "lru", - }, ], }, ]; diff --git a/src/lru.js b/src/lru.js index 8f73490..00cb72f 100644 --- a/src/lru.js +++ b/src/lru.js @@ -6,6 +6,9 @@ * @class LRU */ export class LRU { + #stats; + #onEvict; + /** * Creates a new LRU cache instance. * Note: Constructor does not validate parameters. Use lru() factory function for parameter validation. @@ -13,16 +16,18 @@ export class LRU { * @constructor * @param {number} [max=0] - Maximum number of items to store. 0 means unlimited. * @param {number} [ttl=0] - Time to live in milliseconds. 0 means no expiration. - * @param {boolean} [resetTtl=false] - Whether to reset TTL when updating existing items via set(). + * @param {boolean} [resetTTL=false] - Whether to reset TTL when updating existing items via set(). */ - constructor(max = 0, ttl = 0, resetTtl = false) { + constructor(max = 0, ttl = 0, resetTTL = false) { this.first = null; this.items = Object.create(null); this.last = null; this.max = max; - this.resetTtl = resetTtl; + this.resetTTL = resetTTL; this.size = 0; this.ttl = ttl; + this.#stats = { hits: 0, misses: 0, sets: 0, deletes: 0, evictions: 0 }; + this.#onEvict = null; } /** @@ -31,10 +36,22 @@ export class LRU { * @returns {LRU} The LRU instance for method chaining. */ clear() { + for (let x = this.first; x !== null; ) { + const next = x.next; + x.prev = null; + x.next = null; + x = next; + } + this.first = null; this.items = Object.create(null); this.last = null; this.size = 0; + this.#stats.hits = 0; + this.#stats.misses = 0; + this.#stats.sets = 0; + this.#stats.deletes = 0; + this.#stats.evictions = 0; return this; } @@ -51,6 +68,7 @@ export class LRU { if (item !== undefined) { delete this.items[key]; this.size--; + this.#stats.deletes++; this.#unlink(item); @@ -97,6 +115,7 @@ export class LRU { const item = this.first; delete this.items[item.key]; + this.#stats.evictions++; if (--this.size === 0) { this.first = null; @@ -105,7 +124,15 @@ export class LRU { this.#unlink(item); } + item.prev = null; item.next = null; + if (this.#onEvict !== null) { + this.#onEvict({ + key: item.key, + value: item.value, + expiry: item.expiry, + }); + } return this; } @@ -121,6 +148,33 @@ export class LRU { return item !== undefined ? item.expiry : undefined; } + /** + * Checks if an item has expired. + * + * @param {Object} item - The cache item to check. + * @returns {boolean} True if the item has expired, false otherwise. + * @private + */ + #isExpired(item) { + if (this.ttl === 0 || item.expiry === 0) { + return false; + } + + return item.expiry <= Date.now(); + } + + /** + * Retrieves a value from the cache by key without updating LRU order. + * Note: Does not perform TTL checks or remove expired items. + * + * @param {string} key - The key to retrieve. + * @returns {*} The value associated with the key, or undefined if not found. + */ + peek(key) { + const item = this.items[key]; + return item !== undefined ? item.value : undefined; + } + /** * Retrieves a value from the cache by key. Updates the item's position to most recently used. * @@ -131,21 +185,18 @@ export class LRU { const item = this.items[key]; if (item !== undefined) { - // Check TTL only if enabled to avoid unnecessary Date.now() calls - if (this.ttl > 0) { - if (item.expiry <= Date.now()) { - this.delete(key); - - return undefined; - } + if (!this.#isExpired(item)) { + this.moveToEnd(item); + this.#stats.hits++; + return item.value; } - // Fast LRU update without full set() overhead - this.moveToEnd(item); - - return item.value; + this.delete(key); + this.#stats.misses++; + return undefined; } + this.#stats.misses++; return undefined; } @@ -153,11 +204,11 @@ export class LRU { * Checks if a key exists in the cache. * * @param {string} key - The key to check for. - * @returns {boolean} True if the key exists, false otherwise. + * @returns {boolean} True if the key exists and is not expired, false otherwise. */ has(key) { const item = this.items[key]; - return item !== undefined && (this.ttl === 0 || item.expiry > Date.now()); + return item !== undefined && !this.#isExpired(item); } /** @@ -237,7 +288,7 @@ export class LRU { if (item !== undefined) { item.value = value; - if (this.resetTtl) { + if (this.resetTTL) { item.expiry = this.ttl > 0 ? Date.now() + this.ttl : this.ttl; } this.moveToEnd(item); @@ -268,6 +319,7 @@ export class LRU { this.last = item; } + this.#stats.sets++; return evicted; } @@ -284,7 +336,7 @@ export class LRU { if (item !== undefined) { item.value = value; - if (this.resetTtl) { + if (this.resetTTL) { item.expiry = this.ttl > 0 ? Date.now() + this.ttl : this.ttl; } @@ -311,6 +363,8 @@ export class LRU { this.last = item; } + this.#stats.sets++; + return this; } @@ -319,12 +373,17 @@ export class LRU { * When no keys provided, returns all values in LRU order. * When keys provided, order matches the input array. * - * @param {string[]} [keys=this.keys()] - Array of keys to get values for. Defaults to all keys. + * @param {string[]} [keys] - Array of keys to get values for. Defaults to all keys. * @returns {Array<*>} Array of values corresponding to the keys. */ values(keys) { if (keys === undefined) { - keys = this.keys(); + const result = Array.from({ length: this.size }); + let i = 0; + for (let x = this.first; x !== null; x = x.next) { + result[i++] = x.value; + } + return result; } const result = Array.from({ length: keys.length }); @@ -335,6 +394,256 @@ export class LRU { return result; } + + /** + * Iterate over cache items in LRU order (least to most recent). + * Note: This method directly accesses items from the linked list without calling + * get() or peek(), so it does not update LRU order or check TTL expiration during iteration. + * + * @param {function(*, any, LRU): void} callback - Function to call for each item. Signature: callback(value, key, cache) + * @param {Object} [thisArg] - Value to use as `this` when executing callback. + * @returns {LRU} The LRU instance for method chaining. + */ + forEach(callback, thisArg) { + for (let x = this.first; x !== null; x = x.next) { + callback.call(thisArg, x.value, x.key, this); + } + + return this; + } + + /** + * Batch retrieve multiple items. + * + * @param {string[]} keys - Array of keys to retrieve. + * @returns {Object} Object mapping keys to values (undefined for missing/expired keys). + */ + getMany(keys) { + const result = Object.create(null); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + result[key] = this.get(key); + } + + return result; + } + + /** + * Batch existence check - returns true if ALL keys exist. + * + * @param {string[]} keys - Array of keys to check. + * @returns {boolean} True if all keys exist and are not expired. + */ + hasAll(keys) { + for (let i = 0; i < keys.length; i++) { + if (!this.has(keys[i])) { + return false; + } + } + + return true; + } + + /** + * Batch existence check - returns true if ANY key exists. + * + * @param {string[]} keys - Array of keys to check. + * @returns {boolean} True if any key exists and is not expired. + */ + hasAny(keys) { + for (let i = 0; i < keys.length; i++) { + if (this.has(keys[i])) { + return true; + } + } + + return false; + } + + /** + * Remove expired items without affecting LRU order. + * Unlike get(), this does not move items to the end. + * + * @returns {number} Number of expired items removed. + */ + cleanup() { + if (this.ttl === 0 || this.size === 0) { + return 0; + } + + let removed = 0; + + for (let x = this.first; x !== null; ) { + const next = x.next; + if (this.#isExpired(x)) { + const key = x.key; + if (this.items[key] !== undefined) { + delete this.items[key]; + this.size--; + removed++; + this.#unlink(x); + x.prev = null; + x.next = null; + } + } + x = next; + } + + if (removed > 0) { + this.#rebuildList(); + } + + return removed; + } + + /** + * Serialize cache to JSON-compatible format. + * + * @returns {Array<{key: any, value: *, expiry: number}>} Array of cache items. + */ + toJSON() { + const result = []; + for (let x = this.first; x !== null; x = x.next) { + result.push({ + key: x.key, + value: x.value, + expiry: x.expiry, + }); + } + + return result; + } + + /** + * Get cache statistics. + * + * @returns {Object} Statistics object with hits, misses, sets, deletes, evictions counts. + */ + stats() { + return { ...this.#stats }; + } + + /** + * Register callback for evicted items. + * + * @param {function(Object): void} callback - Function called when item is evicted. Receives {key, value, expiry}. + * @returns {LRU} The LRU instance for method chaining. + */ + onEvict(callback) { + if (typeof callback !== "function") { + throw new TypeError("onEvict callback must be a function"); + } + + this.#onEvict = callback; + + return this; + } + + /** + * Get counts of items by TTL status. + * + * @returns {Object} Object with valid, expired, and noTTL counts. + */ + sizeByTTL() { + if (this.ttl === 0) { + return { valid: this.size, expired: 0, noTTL: this.size }; + } + + const now = Date.now(); + let valid = 0; + let expired = 0; + let noTTL = 0; + + for (let x = this.first; x !== null; x = x.next) { + if (x.expiry === 0) { + noTTL++; + valid++; + } else if (x.expiry > now) { + valid++; + } else { + expired++; + } + } + + return { valid, expired, noTTL }; + } + + /** + * Get keys filtered by TTL status. + * + * @returns {Object} Object with valid, expired, and noTTL arrays of keys. + */ + keysByTTL() { + if (this.ttl === 0) { + return { valid: this.keys(), expired: [], noTTL: this.keys() }; + } + + const now = Date.now(); + const valid = []; + const expired = []; + const noTTL = []; + + for (let x = this.first; x !== null; x = x.next) { + if (x.expiry === 0) { + valid.push(x.key); + noTTL.push(x.key); + } else if (x.expiry > now) { + valid.push(x.key); + } else { + expired.push(x.key); + } + } + + return { valid, expired, noTTL }; + } + + /** + * Get values filtered by TTL status. + * + * @returns {Object} Object with valid, expired, and noTTL arrays of values. + */ + valuesByTTL() { + const keysByTTL = this.keysByTTL(); + + return { + valid: this.values(keysByTTL.valid), + expired: this.values(keysByTTL.expired), + noTTL: this.values(keysByTTL.noTTL), + }; + } + + /** + * Rebuild the doubly-linked list after cleanup by deleting expired items. + * This removes nodes that were deleted during cleanup. + * + * @private + */ + #rebuildList() { + if (this.size === 0) { + this.first = null; + this.last = null; + return; + } + + const keys = this.keys(); + this.first = null; + this.last = null; + + for (let i = 0; i < keys.length; i++) { + const item = this.items[keys[i]]; + if (item !== null && item !== undefined) { + if (this.first === null) { + this.first = item; + item.prev = null; + } else { + item.prev = this.last; + this.last.next = item; + } + item.next = null; + this.last = item; + } + } + } } /** @@ -343,11 +652,11 @@ export class LRU { * @function lru * @param {number} [max=1000] - Maximum number of items to store. Must be >= 0. Use 0 for unlimited size. * @param {number} [ttl=0] - Time to live in milliseconds. Must be >= 0. Use 0 for no expiration. - * @param {boolean} [resetTtl=false] - Whether to reset TTL when accessing existing items via get(). + * @param {boolean} [resetTTL=false] - Whether to reset TTL when updating existing items via set(). * @returns {LRU} A new LRU cache instance. * @throws {TypeError} When parameters are invalid (negative numbers or wrong types). */ -export function lru(max = 1000, ttl = 0, resetTtl = false) { +export function lru(max = 1000, ttl = 0, resetTTL = false) { if (isNaN(max) || max < 0) { throw new TypeError("Invalid max value"); } @@ -356,9 +665,9 @@ export function lru(max = 1000, ttl = 0, resetTtl = false) { throw new TypeError("Invalid ttl value"); } - if (typeof resetTtl !== "boolean") { - throw new TypeError("Invalid resetTtl value"); + if (typeof resetTTL !== "boolean") { + throw new TypeError("Invalid resetTTL value"); } - return new LRU(max, ttl, resetTtl); + return new LRU(max, ttl, resetTTL); } diff --git a/tests/unit/lru.test.js b/tests/unit/lru.test.js index 2c22683..6f3d7d4 100644 --- a/tests/unit/lru.test.js +++ b/tests/unit/lru.test.js @@ -8,7 +8,7 @@ describe("LRU Cache", function () { const cache = new LRU(); assert.equal(cache.max, 0); assert.equal(cache.ttl, 0); - assert.equal(cache.resetTtl, false); + assert.equal(cache.resetTTL, false); assert.equal(cache.size, 0); assert.equal(cache.first, null); assert.equal(cache.last, null); @@ -20,7 +20,7 @@ describe("LRU Cache", function () { const cache = new LRU(10, 5000, true); assert.equal(cache.max, 10); assert.equal(cache.ttl, 5000); - assert.equal(cache.resetTtl, true); + assert.equal(cache.resetTTL, true); assert.equal(cache.size, 0); }); }); @@ -30,14 +30,14 @@ describe("LRU Cache", function () { const cache = lru(); assert.equal(cache.max, 1000); assert.equal(cache.ttl, 0); - assert.equal(cache.resetTtl, false); + assert.equal(cache.resetTTL, false); }); it("should create an LRU instance with custom parameters", function () { const cache = lru(50, 1000, true); assert.equal(cache.max, 50); assert.equal(cache.ttl, 1000); - assert.equal(cache.resetTtl, true); + assert.equal(cache.resetTTL, true); }); it("should throw TypeError for invalid max value", function () { @@ -52,9 +52,9 @@ describe("LRU Cache", function () { assert.throws(() => lru(10, NaN), TypeError, "Invalid ttl value"); }); - it("should throw TypeError for invalid resetTtl value", function () { - assert.throws(() => lru(10, 0, "invalid"), TypeError, "Invalid resetTtl value"); - assert.throws(() => lru(10, 0, 1), TypeError, "Invalid resetTtl value"); + it("should throw TypeError for invalid resetTTL value", function () { + assert.throws(() => lru(10, 0, "invalid"), TypeError, "Invalid resetTTL value"); + assert.throws(() => lru(10, 0, 1), TypeError, "Invalid resetTTL value"); }); }); @@ -326,7 +326,7 @@ describe("LRU Cache", function () { assert.equal(neverExpireCache.expiresAt("key1"), 0); }); - it("should reset TTL when updating with resetTtl=true", async function () { + it("should reset TTL when updating with resetTTL=true", async function () { const resetCache = new LRU(5, 1000, true); resetCache.set("key1", "value1"); @@ -339,7 +339,7 @@ describe("LRU Cache", function () { assert.ok(secondExpiry > firstExpiry, "TTL should be reset"); }); - it("should not reset TTL when resetTtl=false", async function () { + it("should not reset TTL when resetTTL=false", async function () { const noResetCache = new LRU(5, 100, false); noResetCache.set("key1", "value1"); @@ -350,7 +350,7 @@ describe("LRU Cache", function () { assert.equal(noResetCache.get("key1"), undefined); }); - it("should not reset TTL on get() even with resetTtl=true", async function () { + it("should not reset TTL on get() even with resetTTL=true", async function () { const resetCache = new LRU(5, 100, true); resetCache.set("key1", "value1"); @@ -647,7 +647,7 @@ describe("LRU Cache", function () { assert.ok(expiry <= before + 250); }); - it("should set expiry to 0 when resetTtl=true and ttl=0 on update", function () { + it("should set expiry to 0 when resetTTL=true and ttl=0 on update", function () { const cache = new LRU(2, 0, true); cache.set("x", 1); assert.equal(cache.expiresAt("x"), 0); @@ -655,7 +655,7 @@ describe("LRU Cache", function () { assert.equal(cache.expiresAt("x"), 0); }); - it("should set expiry to 0 when resetTtl=true and ttl=0 on setWithEvicted", function () { + it("should set expiry to 0 when resetTTL=true and ttl=0 on setWithEvicted", function () { const cache = new LRU(2, 0, true); cache.set("x", 1); assert.equal(cache.expiresAt("x"), 0); @@ -728,4 +728,851 @@ describe("LRU Cache", function () { assert.deepEqual(values, [1, undefined, 2]); }); }); + + describe("peek method", function () { + let cache; + + beforeEach(function () { + cache = new LRU(3); + }); + + it("should retrieve value without moving to end", function () { + cache.set("key1", "value1"); + cache.set("key2", "value2"); + + assert.equal(cache.peek("key1"), "value1"); + assert.deepEqual(cache.keys(), ["key1", "key2"]); + }); + + it("should return undefined for non-existent key", function () { + assert.equal(cache.peek("nonexistent"), undefined); + }); + + it("should not affect LRU order when used with get", function () { + cache.set("key1", "value1"); + cache.set("key2", "value2"); + cache.set("key3", "value3"); + + cache.peek("key1"); + cache.get("key2"); + + assert.deepEqual(cache.keys(), ["key1", "key3", "key2"]); + }); + + it("should work with TTL enabled but not check expiration", function () { + const ttlCache = new LRU(3, 100); + ttlCache.set("key1", "value1"); + + assert.equal(ttlCache.peek("key1"), "value1"); + + const expiry = ttlCache.expiresAt("key1"); + assert.ok(expiry > 0); + }); + + it("should allow peek after get maintains LRU order", function () { + cache.set("key1", "value1"); + cache.set("key2", "value2"); + cache.set("key3", "value3"); + + cache.get("key1"); + cache.peek("key2"); + + assert.deepEqual(cache.keys(), ["key2", "key3", "key1"]); + }); + }); + + describe("forEach method", function () { + let cache; + + beforeEach(function () { + cache = new LRU(5); + cache.set("a", 1).set("b", 2).set("c", 3); + }); + + it("should iterate over all items in LRU order", function () { + const result = []; + cache.forEach((value, key) => { + result.push({ key, value }); + }); + + assert.deepEqual(result, [ + { key: "a", value: 1 }, + { key: "b", value: 2 }, + { key: "c", value: 3 }, + ]); + }); + + it("should iterate without modifying LRU order", function () { + const result = []; + cache.forEach((value, key) => { + result.push(key); + }); + + assert.deepEqual(result, ["a", "b", "c"]); + assert.deepEqual(cache.keys(), ["a", "b", "c"]); + }); + + it("should not modify LRU order when calling peek() during forEach", function () { + const result = []; + cache.forEach((value, key) => { + result.push(key); + cache.peek(key); + }); + + assert.deepEqual(result, ["a", "b", "c"]); + assert.deepEqual(cache.keys(), ["a", "b", "c"]); + }); + + it("should work with thisArg parameter", function () { + const context = { items: [] }; + cache.forEach(function (value, key) { + this.items.push({ key, value }); + }, context); + + assert.deepEqual(context.items, [ + { key: "a", value: 1 }, + { key: "b", value: 2 }, + { key: "c", value: 3 }, + ]); + }); + + it("should return this for chaining", function () { + const result = cache.forEach(() => {}); + assert.equal(result, cache); + }); + + it("should handle empty cache", function () { + cache.clear(); + const result = []; + cache.forEach((value, key) => result.push({ key, value })); + assert.deepEqual(result, []); + }); + + it("should iterate in correct LRU order after operations", function () { + cache.get("a"); + cache.set("d", 4); + + const result = []; + cache.forEach((value, key) => result.push(key)); + + assert.deepEqual(result, ["b", "c", "a", "d"]); + }); + + it("should allow deleting items after collecting keys during iteration", function () { + const keysToDelete = []; + cache.forEach((value, key) => { + if (value === 2) { + keysToDelete.push(key); + } + }); + + keysToDelete.forEach((key) => cache.delete(key)); + + assert.equal(cache.size, 2); + assert.equal(cache.has("b"), false); + }); + }); + + describe("getMany method", function () { + let cache; + + beforeEach(function () { + cache = new LRU(5); + cache.set("a", 1).set("b", 2).set("c", 3); + }); + + it("should retrieve multiple values", function () { + const result = cache.getMany(["a", "c"]); + assert.deepEqual(result, { a: 1, c: 3 }); + }); + + it("should handle non-existent keys", function () { + const result = cache.getMany(["a", "nonexistent", "c"]); + assert.deepEqual(result, { a: 1, nonexistent: undefined, c: 3 }); + }); + + it("should handle empty array", function () { + const result = cache.getMany([]); + assert.deepEqual(result, {}); + }); + + it("should expire items when TTL is enabled", async function () { + const ttlCache = new LRU(5, 50); + ttlCache.set("a", 1).set("b", 2); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const result = ttlCache.getMany(["a", "b"]); + assert.equal(result.a, undefined); + assert.equal(result.b, undefined); + }); + + it("should update LRU order for retrieved items", function () { + cache.getMany(["a", "b"]); + + assert.deepEqual(cache.keys(), ["c", "a", "b"]); + }); + + it("should work with single key", function () { + const result = cache.getMany(["b"]); + assert.deepEqual(result, { b: 2 }); + }); + }); + + describe("hasAll method", function () { + let cache; + + beforeEach(function () { + cache = new LRU(5); + cache.set("a", 1).set("b", 2).set("c", 3); + }); + + it("should return true when all keys exist", function () { + assert.equal(cache.hasAll(["a", "b"]), true); + assert.equal(cache.hasAll(["a", "b", "c"]), true); + }); + + it("should return false when any key is missing", function () { + assert.equal(cache.hasAll(["a", "nonexistent"]), false); + assert.equal(cache.hasAll(["a", "b", "nonexistent"]), false); + }); + + it("should return true for empty array", function () { + assert.equal(cache.hasAll([]), true); + }); + + it("should return false for expired items with TTL", async function () { + const ttlCache = new LRU(5, 50); + ttlCache.set("a", 1).set("b", 2); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + assert.equal(ttlCache.hasAll(["a", "b"]), false); + }); + + it("should not modify LRU order", function () { + cache.hasAll(["a", "b"]); + assert.deepEqual(cache.keys(), ["a", "b", "c"]); + }); + }); + + describe("hasAny method", function () { + let cache; + + beforeEach(function () { + cache = new LRU(5); + cache.set("a", 1).set("b", 2).set("c", 3); + }); + + it("should return true when any key exists", function () { + assert.equal(cache.hasAny(["a", "nonexistent"]), true); + assert.equal(cache.hasAny(["nonexistent", "b"]), true); + }); + + it("should return false when no keys exist", function () { + assert.equal(cache.hasAny(["nonexistent1", "nonexistent2"]), false); + }); + + it("should return false for empty array", function () { + assert.equal(cache.hasAny([]), false); + }); + + it("should return false for all expired items with TTL", async function () { + const ttlCache = new LRU(5, 50); + ttlCache.set("a", 1).set("b", 2); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + assert.equal(ttlCache.hasAny(["a", "b"]), false); + }); + + it("should not modify LRU order", function () { + cache.hasAny(["a", "b"]); + assert.deepEqual(cache.keys(), ["a", "b", "c"]); + }); + }); + + describe("cleanup method", function () { + let cache; + + beforeEach(function () { + cache = new LRU(5, 100); + }); + + it("should remove expired items", async function () { + cache.set("a", 1); + cache.set("b", 2); + + await new Promise((resolve) => setTimeout(resolve, 150)); + + const removed = cache.cleanup(); + assert.equal(removed, 2); + assert.equal(cache.size, 0); + }); + + it("should return 0 when no items expired", async function () { + cache.set("a", 1); + cache.set("b", 2); + + const removed = cache.cleanup(); + assert.equal(removed, 0); + assert.equal(cache.size, 2); + }); + + it("should return 0 when TTL is disabled", function () { + const noTtlCache = new LRU(5, 0); + noTtlCache.set("a", 1); + + const removed = noTtlCache.cleanup(); + assert.equal(removed, 0); + }); + + it("should not affect valid items", async function () { + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const removed = cache.cleanup(); + assert.equal(removed, 0); + assert.equal(cache.size, 3); + }); + + it("should handle mixed expired and valid items", async function () { + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + + await new Promise((resolve) => setTimeout(resolve, 150)); + + cache.set("d", 4); + cache.set("e", 5); + + const removed = cache.cleanup(); + assert.equal(removed, 3); + assert.equal(cache.size, 2); + assert.equal(cache.has("a"), false); + assert.equal(cache.has("b"), false); + assert.equal(cache.has("c"), false); + assert.equal(cache.has("d"), true); + assert.equal(cache.has("e"), true); + }); + + it("should return 0 for empty cache", function () { + const removed = cache.cleanup(); + assert.equal(removed, 0); + }); + }); + + describe("toJSON method", function () { + let cache; + + beforeEach(function () { + cache = new LRU(5); + cache.set("a", 1).set("b", 2).set("c", 3); + }); + + it("should serialize cache items", function () { + const json = cache.toJSON(); + assert.strictEqual(Array.isArray(json), true); + assert.equal(json.length, 3); + + assert.deepEqual(json[0], { key: "a", value: 1, expiry: 0 }); + assert.deepEqual(json[1], { key: "b", value: 2, expiry: 0 }); + assert.deepEqual(json[2], { key: "c", value: 3, expiry: 0 }); + }); + + it("should include expiry timestamps when TTL is enabled", function () { + const ttlCache = new LRU(5, 1000); + ttlCache.set("a", 1); + + const json = ttlCache.toJSON(); + assert.ok(json[0].expiry > 0); + }); + + it("should preserve LRU order", function () { + cache.get("a"); + const json = cache.toJSON(); + + assert.deepEqual(json[0].key, "b"); + assert.deepEqual(json[1].key, "c"); + assert.deepEqual(json[2].key, "a"); + }); + + it("should return empty array for empty cache", function () { + cache.clear(); + const json = cache.toJSON(); + assert.deepEqual(json, []); + }); + + it("should be compatible with JSON.stringify", function () { + const jsonStr = JSON.stringify(cache); + const parsed = JSON.parse(jsonStr); + + assert.strictEqual(Array.isArray(parsed), true); + assert.equal(parsed.length, 3); + }); + }); + + describe("stats method", function () { + let cache; + + beforeEach(function () { + cache = new LRU(3); + cache.set("a", 1).set("b", 2).set("c", 3); + }); + + it("should return statistics object", function () { + const stats = cache.stats(); + assert.strictEqual(typeof stats, "object"); + assert.ok("hits" in stats); + assert.ok("misses" in stats); + assert.ok("sets" in stats); + assert.ok("deletes" in stats); + assert.ok("evictions" in stats); + }); + + it("should track set operations", function () { + cache.set("d", 4); + const stats = cache.stats(); + assert.equal(stats.sets, 4); + }); + + it("should track get hits", function () { + cache.get("a"); + cache.get("b"); + const stats = cache.stats(); + assert.equal(stats.hits, 2); + }); + + it("should track get misses", function () { + cache.get("nonexistent"); + const stats = cache.stats(); + assert.equal(stats.misses, 1); + }); + + it("should track delete operations", function () { + cache.delete("a"); + const stats = cache.stats(); + assert.equal(stats.deletes, 1); + }); + + it("should track evictions", function () { + cache.set("d", 4); + cache.set("e", 5); + const stats = cache.stats(); + assert.equal(stats.evictions, 2); + }); + + it("should return a copy, not the internal object", function () { + const stats1 = cache.stats(); + cache.set("d", 4); + const stats2 = cache.stats(); + + assert.equal(stats1.sets, 3); + assert.equal(stats2.sets, 4); + }); + + it("should track correct hits/misses with has()", function () { + cache.has("a"); + cache.has("nonexistent"); + const stats = cache.stats(); + + assert.equal(stats.hits, 0); + assert.equal(stats.misses, 0); + }); + + it("should reset on clear()", function () { + cache.get("a"); + cache.clear(); + const stats = cache.stats(); + + assert.equal(stats.hits, 0); + assert.equal(stats.misses, 0); + assert.equal(stats.sets, 0); + assert.equal(stats.deletes, 0); + assert.equal(stats.evictions, 0); + }); + + it("should track evictions with onEvict callback", function () { + let evictedKey; + cache.onEvict((item) => { + evictedKey = item.key; + }); + + cache.set("d", 4); + cache.set("e", 5); + + const stats = cache.stats(); + assert.equal(stats.evictions, 2); + assert.equal(evictedKey, "b"); + }); + }); + + describe("onEvict method", function () { + let cache; + + beforeEach(function () { + cache = new LRU(3); + }); + + it("should register evict callback", function () { + let evicted = null; + cache.onEvict((item) => { + evicted = item; + }); + + cache.set("a", 1).set("b", 2).set("c", 3).set("d", 4); + + assert.ok(evicted !== null); + assert.equal(evicted.key, "a"); + assert.equal(evicted.value, 1); + }); + + it("should receive correct item shape", function () { + cache.set("a", 1).set("b", 2).set("c", 3); + + let receivedItem; + cache.onEvict((item) => { + receivedItem = item; + }); + + cache.set("d", 4); + + assert.equal(receivedItem.key, "a"); + assert.equal(receivedItem.value, 1); + assert.ok("expiry" in receivedItem); + }); + + it("should work with TTL expiry via cleanup", async function () { + const ttlCache = new LRU(5, 50); + ttlCache.set("a", 1).set("b", 2).set("c", 3); + + let evictedItem = null; + ttlCache.onEvict((item) => { + evictedItem = item; + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + const removed = ttlCache.cleanup(); + + assert.equal(removed, 3); + assert.equal(ttlCache.size, 0); + assert.equal(evictedItem, null, "onEvict callback should not be called during cleanup()"); + }); + + it("should only have last registered callback", function () { + let firstCalled = false; + let secondCalled = false; + + cache.onEvict(() => { + firstCalled = true; + }); + + cache.onEvict(() => { + secondCalled = true; + }); + + cache.set("a", 1).set("b", 2).set("c", 3).set("d", 4); + + assert.equal(firstCalled, false); + assert.equal(secondCalled, true); + }); + + it("should return this for chaining", function () { + const result = cache.onEvict(() => {}); + assert.equal(result, cache); + }); + + it("should handle multiple evictions", function () { + const evictedItems = []; + cache.onEvict((item) => { + evictedItems.push(item); + }); + + cache.set("a", 1).set("b", 2).set("c", 3).set("d", 4).set("e", 5).set("f", 6); + + assert.equal(evictedItems.length, 3); + assert.equal(evictedItems[0].key, "a"); + assert.equal(evictedItems[1].key, "b"); + assert.equal(evictedItems[2].key, "c"); + }); + + it("should throw TypeError if callback is not a function", function () { + assert.throws(() => cache.onEvict(undefined), { + message: "onEvict callback must be a function", + }); + assert.throws(() => cache.onEvict(null), { message: "onEvict callback must be a function" }); + assert.throws(() => cache.onEvict("not a function"), { + message: "onEvict callback must be a function", + }); + assert.throws(() => cache.onEvict({}), { message: "onEvict callback must be a function" }); + }); + }); + + describe("sizeByTTL method", function () { + let cache; + + beforeEach(function () { + cache = new LRU(10, 100); + }); + + it("should return counts for valid, expired, and noTTL items", function () { + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + + assert.deepEqual(cache.sizeByTTL(), { valid: 3, expired: 0, noTTL: 0 }); + }); + + it("should count expired items correctly", async function () { + cache.set("a", 1); + cache.set("b", 2); + + await new Promise((resolve) => setTimeout(resolve, 150)); + + const counts = cache.sizeByTTL(); + assert.equal(counts.valid, 0); + assert.equal(counts.expired, 2); + assert.equal(counts.noTTL, 0); + }); + + it("should count noTTL items when ttl=0", function () { + const noTtlCache = new LRU(10, 0); + noTtlCache.set("a", 1).set("b", 2); + + assert.deepEqual(noTtlCache.sizeByTTL(), { valid: 2, expired: 0, noTTL: 2 }); + }); + + it("should handle items with expiry=0 when ttl>0 (manual expiry set)", function () { + const cache = new LRU(10, 100); + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + + cache.items["a"].expiry = 0; + cache.items["b"].expiry = 0; + + const counts = cache.sizeByTTL(); + assert.equal(counts.valid, 3); + assert.equal(counts.expired, 0); + assert.equal(counts.noTTL, 2); + }); + + it("should handle mixed expired and valid items", async function () { + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + + await new Promise((resolve) => setTimeout(resolve, 150)); + + cache.set("d", 4); + cache.set("e", 5); + + const counts = cache.sizeByTTL(); + assert.equal(counts.valid, 2); + assert.equal(counts.expired, 3); + assert.equal(counts.noTTL, 0); + }); + + it("should return zero counts for empty cache", function () { + assert.deepEqual(cache.sizeByTTL(), { valid: 0, expired: 0, noTTL: 0 }); + }); + + it("should not modify cache state", function () { + cache.set("a", 1).set("b", 2); + const originalSize = cache.size; + + cache.sizeByTTL(); + + assert.equal(cache.size, originalSize); + }); + }); + + describe("keysByTTL method", function () { + let cache; + + beforeEach(function () { + cache = new LRU(10, 100); + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + }); + + it("should return keys grouped by TTL status", function () { + const result = cache.keysByTTL(); + assert.ok("valid" in result); + assert.ok("expired" in result); + assert.ok("noTTL" in result); + }); + + it("should return all keys in valid array when none expired", function () { + const result = cache.keysByTTL(); + assert.deepEqual(result.valid.sort(), ["a", "b", "c"]); + assert.deepEqual(result.expired, []); + }); + + it("should return correct expired keys after TTL", async function () { + await new Promise((resolve) => setTimeout(resolve, 150)); + const result = cache.keysByTTL(); + + assert.deepEqual(result.valid, []); + assert.deepEqual(result.expired.sort(), ["a", "b", "c"]); + }); + + it("should handle noTTL when ttl=0", function () { + const noTtlCache = new LRU(10, 0); + noTtlCache.set("a", 1).set("b", 2); + + const result = noTtlCache.keysByTTL(); + assert.deepEqual(result.valid.sort(), ["a", "b"]); + assert.deepEqual(result.expired, []); + assert.deepEqual(result.noTTL.sort(), ["a", "b"]); + }); + + it("should handle items with expiry=0 when ttl>0 (manual expiry set)", function () { + const cache = new LRU(10, 100); + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + + cache.items["a"].expiry = 0; + cache.items["b"].expiry = 0; + + const result = cache.keysByTTL(); + assert.equal(result.valid.length, 3); + assert.equal(result.expired.length, 0); + assert.deepEqual(result.noTTL.sort(), ["a", "b"]); + assert.ok(result.valid.includes("a")); + assert.ok(result.valid.includes("b")); + assert.ok(result.valid.includes("c")); + }); + + it("should return empty arrays for empty cache", function () { + cache.clear(); + const result = cache.keysByTTL(); + assert.deepEqual(result.valid, []); + assert.deepEqual(result.expired, []); + assert.deepEqual(result.noTTL, []); + }); + + it("should preserve key order", function () { + const result = cache.keysByTTL(); + assert.deepEqual(result.valid, ["a", "b", "c"]); + }); + + it("should handle mixed expired and valid", async function () { + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + + await new Promise((resolve) => setTimeout(resolve, 150)); + + cache.set("d", 4); + cache.set("e", 5); + + const result = cache.keysByTTL(); + assert.equal(result.valid.length, 2); + assert.equal(result.expired.length, 3); + assert.ok(result.valid.includes("d")); + assert.ok(result.valid.includes("e")); + assert.ok(result.expired.includes("a")); + assert.ok(result.expired.includes("b")); + assert.ok(result.expired.includes("c")); + }); + }); + + describe("valuesByTTL method", function () { + let cache; + + beforeEach(function () { + cache = new LRU(10, 100); + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + }); + + it("should return values grouped by TTL status", function () { + const result = cache.valuesByTTL(); + assert.ok("valid" in result); + assert.ok("expired" in result); + assert.ok("noTTL" in result); + }); + + it("should return all values in valid array when none expired", function () { + const result = cache.valuesByTTL(); + assert.deepEqual(result.valid, [1, 2, 3]); + assert.deepEqual(result.expired, []); + }); + + it("should handle items with expiry=0 when ttl>0 (manual expiry set)", function () { + const cache = new LRU(10, 100); + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + + cache.items["a"].expiry = 0; + cache.items["b"].expiry = 0; + + const result = cache.valuesByTTL(); + assert.equal(result.valid.length, 3); + assert.equal(result.expired.length, 0); + assert.deepEqual(result.noTTL.sort(), [1, 2]); + assert.ok(result.valid.includes(1)); + assert.ok(result.valid.includes(2)); + assert.ok(result.valid.includes(3)); + }); + + it("should return correct expired values after TTL", async function () { + await new Promise((resolve) => setTimeout(resolve, 150)); + const result = cache.valuesByTTL(); + + assert.deepEqual(result.valid, []); + assert.deepEqual(result.expired.sort(), [1, 2, 3]); + }); + + it("should handle noTTL when ttl=0", function () { + const noTtlCache = new LRU(10, 0); + noTtlCache.set("a", 1).set("b", 2); + + const result = noTtlCache.valuesByTTL(); + assert.deepEqual(result.valid.sort(), [1, 2]); + assert.deepEqual(result.expired, []); + assert.deepEqual(result.noTTL.sort(), [1, 2]); + }); + + it("should return empty arrays for empty cache", function () { + cache.clear(); + const result = cache.valuesByTTL(); + assert.deepEqual(result.valid, []); + assert.deepEqual(result.expired, []); + assert.deepEqual(result.noTTL, []); + }); + + it("should preserve value order matching keys", function () { + const result = cache.valuesByTTL(); + assert.deepEqual(result.valid, [1, 2, 3]); + }); + + it("should handle mixed expired and valid", async function () { + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + + await new Promise((resolve) => setTimeout(resolve, 150)); + + cache.set("d", 4); + cache.set("e", 5); + + const result = cache.valuesByTTL(); + assert.equal(result.valid.length, 2); + assert.equal(result.expired.length, 3); + assert.ok(result.valid.includes(4)); + assert.ok(result.valid.includes(5)); + assert.ok(result.expired.includes(1)); + assert.ok(result.expired.includes(2)); + assert.ok(result.expired.includes(3)); + }); + }); }); diff --git a/types/lru.d.ts b/types/lru.d.ts index 3429ff5..8fb38aa 100644 --- a/types/lru.d.ts +++ b/types/lru.d.ts @@ -2,11 +2,11 @@ * Factory function to create a new LRU cache instance with parameter validation. * @param max Maximum number of items to store (default: 1000, 0 = unlimited) * @param ttl Time to live in milliseconds (default: 0, 0 = no expiration) - * @param resetTtl Whether to reset TTL when accessing existing items via get() (default: false) + * @param resetTTL Whether to reset TTL when updating existing items via set() (default: false) * @returns A new LRU cache instance * @throws TypeError when parameters are invalid (negative numbers or wrong types) */ -export function lru(max?: number, ttl?: number, resetTtl?: boolean): LRU; +export function lru(max?: number, ttl?: number, resetTTL?: boolean): LRU; /** * Internal structure representing a cache item in the doubly-linked list. @@ -46,24 +46,24 @@ export class LRU { * Note: Constructor does not validate parameters. Use lru() factory function for parameter validation. * @param max Maximum number of items to store (default: 0, 0 = unlimited) * @param ttl Time to live in milliseconds (default: 0, 0 = no expiration) - * @param resetTtl Whether to reset TTL when accessing existing items via get() (default: false) + * @param resetTTL Whether to reset TTL when updating existing items via set() (default: false) */ - constructor(max?: number, ttl?: number, resetTtl?: boolean); + constructor(max?: number, ttl?: number, resetTTL?: boolean); /** Pointer to the least recently used item (first to be evicted) */ - readonly first: LRUItem | null; + first: LRUItem | null; /** Hash map for O(1) key-based access to cache nodes */ - readonly items: Record>; + items: Record | undefined>; /** Pointer to the most recently used item */ - readonly last: LRUItem | null; + last: LRUItem | null; /** Maximum number of items to store (0 = unlimited) */ - readonly max: number; - /** Whether to reset TTL on each get() operation */ - readonly resetTtl: boolean; + max: number; + /** Whether to reset TTL on set() operations */ + resetTTL: boolean; /** Current number of items in the cache */ - readonly size: number; + size: number; /** Time-to-live in milliseconds (0 = no expiration) */ - readonly ttl: number; + ttl: number; /** * Removes all items from the cache. @@ -113,6 +113,105 @@ export class LRU { */ has(key: any): boolean; + /** + * Iterate over cache items in LRU order (least to most recent). + * @param callback Function to call for each item. Signature: callback(value, key, cache) + * @param thisArg Value to use as `this` when executing callback + * @returns The LRU instance for method chaining + */ + forEach(callback: (value: T, key: any, cache: this) => void, thisArg?: any): this; + + /** + * Retrieve a value from the cache by key without updating LRU order. + * Note: Does not perform TTL checks or remove expired items. + * @param key The key to retrieve + * @returns The value associated with the key, or undefined if not found + */ + peek(key: any): T | undefined; + + /** + * Batch retrieve multiple items. + * @param keys Array of keys to retrieve + * @returns Object mapping keys to values (undefined for missing/expired keys) + */ + getMany(keys: any[]): Record; + + /** + * Batch existence check - returns true if ALL keys exist. + * @param keys Array of keys to check + * @returns True if all keys exist and are not expired + */ + hasAll(keys: any[]): boolean; + + /** + * Batch existence check - returns true if ANY key exists. + * @param keys Array of keys to check + * @returns True if any key exists and is not expired + */ + hasAny(keys: any[]): boolean; + + /** + * Remove expired items without affecting LRU order. + * Unlike get(), this does not move items to the end. + * @returns Number of expired items removed + */ + cleanup(): number; + + /** + * Serialize cache to JSON-compatible format. + * @returns Array of cache items with key, value, and expiry + */ + toJSON(): Array<{ key: any; value: T; expiry: number }>; + + /** + * Get cache statistics. + * @returns Statistics object with hits, misses, sets, deletes, evictions counts + */ + stats(): { + hits: number; + misses: number; + sets: number; + deletes: number; + evictions: number; + }; + + /** + * Register callback for evicted items. + * @param callback Function called when item is evicted. Receives {key, value, expiry} + * @returns The LRU instance for method chaining + */ + onEvict(callback: (item: { key: any; value: T; expiry: number }) => void): this; + + /** + * Get counts of items by TTL status. + * @returns Object with valid, expired, and noTTL counts + */ + sizeByTTL(): { + valid: number; + expired: number; + noTTL: number; + }; + + /** + * Get keys filtered by TTL status. + * @returns Object with valid, expired, and noTTL arrays of keys + */ + keysByTTL(): { + valid: any[]; + expired: any[]; + noTTL: any[]; + }; + + /** + * Get values filtered by TTL status. + * @returns Object with valid, expired, and noTTL arrays of values + */ + valuesByTTL(): { + valid: (T | undefined)[]; + expired: (T | undefined)[]; + noTTL: (T | undefined)[]; + }; + /** * Returns an array of all keys in the cache, ordered from least to most recently used. * @returns Array of keys in LRU order