From 38dee1ca9170f7db5986145f22e26b84dcd2613a Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 14 Sep 2026 22:45:54 -0400 Subject: [PATCH 1/4] fix: LRU edge cases in TTL handling and factory validation - Validate max/ttl/resetTTL in constructor (reject non-integer, negative, non-boolean) - Reclaim expired keys on set()/setWithEvicted() instead of leaving dead slots - Skip expired items in values()/entries()/forEach()/toJSON() - Make forEach() mutation-safe (capture next pointer before callback) - Stop incrementing deletes when get() removes an expired item on a miss - Validate array input in getMany()/hasAll()/hasAny()/values()/entries() - Treat expiry=0 with ttl>0 as expired in sizeByTTL/keysByTTL/valuesByTTL - Use silent eviction in setWithEvicted() (no onEvict double-fire) Fixes #487 --- coverage.txt | 4 +- memory/audit-edge-cases.md | 60 +++++++ memory/audit-state.md | 13 ++ memory/fix-issue-487-task.md | 157 +++++++++++++++++ memory/schedules/reflection-daily.json | 8 + memory/tools/todo.json | 9 + src/lru.js | 224 +++++++++++++++++-------- tests/unit/lru.test.js | 189 +++++++++++++++++++-- 8 files changed, 575 insertions(+), 89 deletions(-) create mode 100644 memory/audit-edge-cases.md create mode 100644 memory/audit-state.md create mode 100644 memory/fix-issue-487-task.md create mode 100644 memory/schedules/reflection-daily.json create mode 100644 memory/tools/todo.json diff --git a/coverage.txt b/coverage.txt index 355f23e..e37deb0 100644 --- a/coverage.txt +++ b/coverage.txt @@ -3,8 +3,8 @@ ℹ file | line % | branch % | funcs % | uncovered lines ℹ ---------------------------------------------------------- ℹ src | | | | -ℹ lru.js | 100.00 | 99.28 | 100.00 | +ℹ lru.js | 100.00 | 99.39 | 100.00 | ℹ ---------------------------------------------------------- -ℹ all files | 100.00 | 99.28 | 100.00 | +ℹ all files | 100.00 | 99.39 | 100.00 | ℹ ---------------------------------------------------------- ℹ end of coverage report diff --git a/memory/audit-edge-cases.md b/memory/audit-edge-cases.md new file mode 100644 index 0000000..fb1032a --- /dev/null +++ b/memory/audit-edge-cases.md @@ -0,0 +1,60 @@ +# tiny-lru Edge Case Audit — Findings + +Audited `src/lru.js` (673 lines) and `tests/unit/lru.test.js` (1578 lines). All 149 existing tests pass. The following edge cases are NOT covered by the test suite and represent real behavioral inconsistencies. All findings reproduced with `node` probes against `src/lru.js`. + +## Findings + +| # | Severity | Area | Description | Evidence | +|---|----------|------|-------------|----------| +| 1 | High | `lru()` factory validation | `lru("10")`, `lru(true)`, `lru(2.5)`, `lru(Infinity)`, `lru(null)`, `lru(false)`, `lru("")` all pass validation but silently disable eviction. `this.size === this.max` uses strict equality, so a non-integer max never evicts. | `lru("10")` → size 15 after 15 sets. `lru(2.5)` → size 5 after 5 sets. `lru(Infinity)` → size 5 after 5 sets. `lru("")` accepted (`isNaN("")` is false). | +| 2 | High | `set()` on expired key | With `resetTTL=false`, `set()` on an expired key leaves the item dead but occupying a slot. `has()` false, `get()` undefined, but `size` unchanged. With `resetTTL=true` it resurrects correctly — inconsistent. | `set("k","v")`, wait 80ms, `set("k","v2")` → `has(k)=false`, `get(k)=undefined`, `size=1`. | +| 3 | High | TTL semantics across read methods | `values()`, `entries()`, `forEach()`, `toJSON()` return expired items, while `get()`/`has()` treat them as gone. Inconsistent TTL enforcement. | Expired item: `values()=["v"]`, `entries()=[["k","v"]]`, `forEach` sees it, `toJSON` includes it. `get(k)=undefined`. | +| 4 | High | `forEach()` mutation truncates iteration | Deleting the current item during `forEach()` truncates the loop — subsequent items are never visited. | `forEach((v,k)=>{seen.push(k); c.delete(k);})` on 4 items → `seen=["a"]` (only first visited). | +| 5 | Medium | `get()` stats side effect | `get()` on an expired item increments BOTH `deletes` and `misses`. A read miss triggers a delete. | After expired `get(k)`: `stats={hits:0, misses:1, sets:1, deletes:1}`. | +| 6 | Medium | `cleanup()` vs `evict()` onEvict | `cleanup()` removes expired items but does NOT fire `onEvict`; `evict()` does. Inconsistent eviction notification. | Test confirms `onEvict` not called during `cleanup()`. | +| 7 | Medium | `setWithEvicted()` on expired key at max | On an expired key at max capacity, `setWithEvicted()` returns `null` evicted and leaves a dead item in place — the expired item is never reclaimed. | `set("a",1)` at max=1, wait, `setWithEvicted("a",2)` → `evicted=null`, `get(a)=undefined`. | +| 8 | Medium | Batch method side effects | `getMany()` deletes expired items (size drops to 0), but `hasAll()`/`hasAny()` do not (size unchanged). Inconsistent. | `getMany(["a","b"])` on expired → `{}`, `size=0`. `hasAll` → false, `size=2`. | +| 9 | Medium | Key coercion collisions | `set(1)` and `set("1")` collide to the same slot. Same for `set(true)`/`set("true")` and `set(-0)`/`set(0)`. Object keys coerce to `"[object Object]"`. | `set(1,"n"); set("1","s")` → `size=1`, `get(1)="s"`. `set(true)`/`set("true")` → `size=2`, `get(true)="S"`. | +| 10 | Medium | `values(null)` / `entries(null)` crash | Passing `null` as the keys argument throws `TypeError: Cannot read properties of null (reading 'length')`. | `values(null)` → `TypeError`. `entries(null)` → `TypeError`. | +| 11 | Medium | Batch methods crash on null/undefined | `getMany(null)`, `hasAll(null)`, `hasAny(undefined)` throw `TypeError: Cannot read properties of null/undefined (reading 'length')`. | `getMany(null)` → `TypeError`. `hasAny(undefined)` → `TypeError`. | +| 12 | Low | Constructor validation | `new LRU(-1)` works and behaves as unlimited. Constructor does not validate params (documented, but class is public API). | `new LRU(-1)` → size 2 after 2 sets, no eviction. | +| 13 | Low | `sizeByTTL`/`keysByTTL`/`valuesByTTL` noTTL semantics | Items with `expiry=0` are counted as `noTTL` even when `ttl>0`. | With `ttl=100`, item with `expiry=0` → `sizeByTTL={valid:1, expired:0, noTTL:1}`. | +| 14 | Low | `peek()` on expired item | `peek()` returns expired value (documented as no TTL check). By design, but inconsistent with `get()`. | Expired item: `peek(k)="v"`. | +| 15 | Low | `clear()`/`delete()` don't fire `onEvict` | Only `evict()` fires `onEvict`. Deleting or clearing items silently skips the callback. | `onEvict` fired for `[]` after delete+clear; only `evict()` triggered it. | +| 16 | Low | `setWithEvicted()` double-notification | Returns the evicted item AND fires `onEvict` for the same eviction — caller gets it twice. | `setWithEvicted("c",3)` at max → returned `{key:"a",...}` AND `onEvict` fired once. | +| 17 | Low | String keys argument treated as char list | `values("abc")`/`entries("abc")` iterate the string as single-char keys. | `values("abc")` → `[1,2,3]` for keys a,b,c. | +| 18 | Low | Non-array input silently ignored | `getMany(5)` returns `{}` silently instead of throwing or validating. | `getMany(5)` → `{}`. | + +## Reproduction + +All findings reproduced with `node` probes against `src/lru.js`. See evidence column per finding. + +## Root Cause Analysis + +- **Finding 1**: `lru()` factory uses `isNaN(max)` which returns false for numeric-coercible strings (`"10"`), booleans (`true`), floats (`2.5`), `Infinity`, `null`, and `false`. The eviction guard `this.size === this.max` then never triggers for non-integer values. +- **Findings 2, 3, 7**: `set()` and `setWithEvicted()` check `item !== undefined` to decide update-vs-insert, but never check `#isExpired(item)`. An expired item is treated as a live update, so its stale expiry is preserved. +- **Finding 4**: `forEach()` iterates the linked list by following `x.next`. Deleting the current item nullifies its `next` pointer, so the loop terminates early. +- **Findings 5, 6, 8**: `get()` deletes expired items (incrementing `deletes`), while `cleanup()` and the batch `has*` methods do not — inconsistent TTL enforcement paths. +- **Finding 9**: `items` is a plain object keyed by `key`, so JS coerces all keys to strings. `1` and `"1"`, `true` and `"true"`, `-0` and `0` all collide. +- **Findings 10, 11**: `values()`/`entries()`/`getMany()`/`hasAll()`/`hasAny()` assume `keys` is an array and access `.length` without validation. + +## Testing Strategy + +- **Unit tests**: Add tests for each finding — factory validation with string/boolean/float/Infinity/null max, `set()` on expired key with both `resetTTL` values, read-method TTL enforcement, `forEach()` mutation, `get()` stats on expired, `cleanup()` onEvict, `setWithEvicted()` on expired at max, batch method side effects, key coercion, null/undefined keys argument, string keys argument. +- **Edge cases**: Expired key at max capacity, `expiry=0` with `ttl>0`, negative constructor max, `lru("")`, symbol keys, `__proto__` key, `values(null)`. + +## Security Considerations + +- No credential or input-handling concerns. This is a cache correctness issue. The main risk is stale data being served via `values()`/`entries()`/`forEach()`/`toJSON()` after TTL expiry. The `__proto__` key is handled safely (null-prototype `items` prevents prototype pollution — verified `Object.prototype.polluted` is undefined). + +## Fix Steps + +1. **Validate factory inputs** — In `lru()`, replace `isNaN(max)` with `!Number.isInteger(max) || max < 0`. Apply the same to `ttl`. +2. **Handle expired keys in `set()`/`setWithEvicted()`** — In the update branch, check `#isExpired(item)` first. If expired, treat as a fresh insert (reclaim the slot) rather than an update. +3. **Enforce TTL in read methods** — Make `values()`, `entries()`, `forEach()`, `toJSON()` skip expired items, consistent with `get()`/`has()`. +4. **Make `forEach()` mutation-safe** — Capture the next pointer before invoking the callback so deleting the current item doesn't truncate iteration. +5. **Fix `get()` stats** — Do not increment `deletes` when `get()` removes an expired item on a miss. +6. **Fire `onEvict` in `cleanup()`** — Call `#onEvict` for each expired item removed, consistent with `evict()`. +7. **Validate keys arguments** — Guard `values()`, `entries()`, `getMany()`, `hasAll()`, `hasAny()` against null/undefined/non-array input. +8. **Add tests** — Add unit tests covering all 18 findings in `tests/unit/lru.test.js`. +9. **Verify** — Run `npm run test` and `npm run coverage` to confirm 100% line coverage and no regressions. diff --git a/memory/audit-state.md b/memory/audit-state.md new file mode 100644 index 0000000..fc71885 --- /dev/null +++ b/memory/audit-state.md @@ -0,0 +1,13 @@ +# Audit State + +## Phase Queue +- [ ] ./src + +## Current Phase +./src + +## Completed +- (none) + +## Findings +(none yet) \ No newline at end of file diff --git a/memory/fix-issue-487-task.md b/memory/fix-issue-487-task.md new file mode 100644 index 0000000..d2528ff --- /dev/null +++ b/memory/fix-issue-487-task.md @@ -0,0 +1,157 @@ +# Task File — fix-issue 487 (tiny-lru) + +> Live tracking file for the fix-issue pipeline. Update as work progresses. Do not delete. + +## Issue + +- **Number:** 487 +- **Title:** fix: LRU edge cases in TTL handling and factory validation +- **URL:** https://github.com/avoidwork/tiny-lru/issues/487 +- **Repo:** avoidwork/tiny-lru +- **Labels:** bug, approved, in progress +- **State:** OPEN + +## Pipeline State + +| Step | Status | Notes | +|------|--------|-------| +| Fetch issue | ✅ done | OPEN, approved+bug labels | +| Validate approval | ✅ done | `approved` present, no `in progress` at fetch | +| Set in-progress label | ✅ done | Label did not exist → created it (`in progress`, color fbca04), then added | +| Categorize | ✅ done | `bug` → branch type `fix` | +| Infra check | ✅ done | HAS_PACKAGE_JSON=true, HAS_OPENSPEC=false, HAS_BUILD_SCRIPT=true | +| create-feature chain | ⏳ pending | See "OpenSpec Decision" below | +| Comment on issue | ⏳ pending | After PR created | +| Verify | ⏳ pending | npm test + coverage | + +## OpenSpec Decision + +**Problem:** The fix-issue skill says: if ANY of package.json / openspec / build-script is present, proceed with the FULL create-feature pipeline (which requires an `openspec/` directory). But tiny-lru has **no `openspec/` directory** — only `package.json` + build scripts. + +**Facts:** +- `openspec` CLI is installed (`/home/jason/.nvm/versions/node/v25.8.1/bin/openspec`) +- `openspec/` directory does NOT exist in tiny-lru +- `package.json` exists with build/test/lint/coverage scripts + +**Decision:** The full OpenSpec pipeline (propose → spec → apply → archive) cannot run without an `openspec/` directory. This is a **bug fix on a library**, not a feature on the madz harness. Proceed with the **direct implementation path** (SKIP_OPENSPEC semantics): +1. Create feature branch `fix/` +2. Implement the fix inline in `src/lru.js` +3. Add tests in `tests/unit/lru.test.js` +4. Run `npm run test` + `npm run coverage` +5. Commit + push +6. Create PR targeting `master` (tiny-lru's default branch is `master`, not `main`) +7. Comment on issue #487 linking the PR + +## Findings (18) — Implementation Plan + +### Group A: Factory validation (Finding 1) +- **Fix:** In `lru()`, replace `isNaN(max)` with `!Number.isInteger(max) || max < 0`. Same for `ttl`. +- **Files:** `src/lru.js` lines 659-673 +- **Tests:** `lru("10")`, `lru(true)`, `lru(2.5)`, `lru(Infinity)`, `lru(null)`, `lru(false)`, `lru("")` all throw TypeError. + +### Group B: Expired-key handling in set/setWithEvicted (Findings 2, 7) +- **Fix:** In `set()` and `setWithEvicted()` update branch, check `#isExpired(item)` first. If expired, treat as fresh insert (reclaim slot) rather than update. +- **Files:** `src/lru.js` lines 285-324, 333-369 +- **Tests:** expired key + `set()` with resetTTL=false and true; `setWithEvicted()` on expired at max. + +### Group C: TTL enforcement in read methods (Finding 3) +- **Fix:** Make `values()`, `entries()`, `forEach()`, `toJSON()` skip expired items (consistent with `get()`/`has()`). +- **Files:** `src/lru.js` lines 90-103, 379-396, 407-413, 504-515 +- **Tests:** expired item not returned by any read method. + +### Group D: forEach mutation safety (Finding 4) +- **Fix:** Capture `x.next` before invoking callback so deleting current item doesn't truncate iteration. +- **Files:** `src/lru.js` lines 407-413 +- **Tests:** `forEach` + delete current item visits all items. + +### Group E: get() stats (Finding 5) +- **Fix:** Do not increment `deletes` when `get()` removes an expired item on a miss. +- **Files:** `src/lru.js` lines 184-201 +- **Tests:** expired `get()` → `deletes` not incremented. + +### Group F: cleanup() onEvict (Finding 6) +- **Fix:** Fire `#onEvict` for each expired item removed in `cleanup()`, consistent with `evict()`. +- **Files:** `src/lru.js` lines 469-497 +- **Tests:** `cleanup()` fires onEvict for removed items. + +### Group G: Batch method consistency (Finding 8) +- **Fix:** Make `hasAll()`/`hasAny()` consistent with `getMany()` re: expired items. Decide: either all delete expired or none do. Recommend: `has*` should NOT delete (read-only), `getMany` should NOT delete either (or document). **Needs decision.** +- **Files:** `src/lru.js` lines 421-461 +- **Tests:** batch methods on expired items. + +### Group H: Key coercion collisions (Finding 9) +- **Status:** DESIGN DECISION. `items` is a plain object keyed by string, so `1`/`"1"`, `true`/`"true"`, `-0`/`0` collide. Fixing requires switching to a `Map`. **This is a breaking change** — flag for user decision before implementing. +- **Files:** `src/lru.js` line 23 (`this.items = Object.create(null)`) + +### Group I: Null/undefined keys argument crash (Findings 10, 11) +- **Fix:** Guard `values()`, `entries()`, `getMany()`, `hasAll()`, `hasAny()` against null/undefined/non-array input. +- **Files:** `src/lru.js` lines 90-103, 379-396, 421-429, 437-445, 453-461 +- **Tests:** `values(null)`, `entries(null)`, `getMany(null)`, `hasAll(null)`, `hasAny(undefined)` don't crash. + +### Group J: Constructor validation (Finding 12) +- **Status:** DOCUMENTED BEHAVIOR. Constructor intentionally doesn't validate. `new LRU(-1)` behaves as unlimited. Low priority — likely leave as-is or document. **Needs decision.** + +### Group K: noTTL semantics (Finding 13) +- **Fix:** `sizeByTTL`/`keysByTTL`/`valuesByTTL` count `expiry=0` as `noTTL` even when `ttl>0`. Decide if this is correct (an item with expiry=0 genuinely has no TTL) or a bug. **Needs decision.** +- **Files:** `src/lru.js` lines 547-613 + +### Group L: peek() on expired (Finding 14) +- **Status:** BY DESIGN. `peek()` documented as no TTL check. Leave as-is. + +### Group M: clear()/delete() don't fire onEvict (Finding 15) +- **Fix:** Decide whether `delete()`/`clear()` should fire `onEvict`. Currently only `evict()` does. **Needs decision** — firing onEvict on delete/clear may be surprising. +- **Files:** `src/lru.js` lines 38-57, 65-80 + +### Group N: setWithEvicted double-notification (Finding 16) +- **Fix:** `setWithEvicted()` returns evicted item AND fires `onEvict`. Caller gets it twice. Decide: return value is the API contract; onEvict is the callback. Both firing is arguably correct (different consumers). **Needs decision.** +- **Files:** `src/lru.js` lines 285-324 + +### Group O: String keys argument (Finding 17) +- **Fix:** `values("abc")`/`entries("abc")` iterate chars. Guard against non-array input (treat as single key or throw). Ties into Group I. +- **Files:** `src/lru.js` lines 90-103, 379-396 + +### Group P: Non-array input silently ignored (Finding 18) +- **Fix:** `getMany(5)` returns `{}` silently. Ties into Group I — validate input. +- **Files:** `src/lru.js` lines 421-429 + +## Decisions (RESOLVED by user) + +1. **Finding 9 (key coercion):** NO Map — too slow. Keep plain object. Document key coercion as string-only. NOT fixed. +2. **Finding 8 (batch consistency):** YES — getMany/has* should delete expired items. FIX. +3. **Finding 12 (constructor validation):** YES — validate in the constructor. FIX. +4. **Finding 13 (noTTL semantics):** YES — expiry=0 with ttl>0 is a bug. Treat as expired. FIX. +5. **Finding 15 (onEvict on delete/clear):** NO — delete()/clear() should NOT fire onEvict. Leave as-is. NOT a bug. +6. **Finding 16 (double-notification):** NO — returning evicted + firing onEvict is NOT correct. setWithEvicted should use silent eviction. FIX. + +## Implementation Scope + +**FIX (14):** 1, 2, 3, 4, 5, 7, 8, 10, 11, 12, 13, 16, 17, 18 +**NOT FIXED (documented/design):** 6 (cleanup onEvict — correct per philosophy), 9 (key coercion — no Map), 14 (peek by design), 15 (delete/clear onEvict — correct per user) + +## Implementation Status: COMPLETE + +- **src/lru.js** — All fixes implemented. Constructor validates max/ttl/resetTTL. `set()`/`setWithEvicted()` reclaim expired keys. `values()`/`entries()`/`forEach()`/`toJSON()` skip expired items. `forEach()` is mutation-safe. `get()` no longer increments deletes on expired miss. `getMany()`/`has*()` validate array input. `sizeByTTL`/`keysByTTL`/`valuesByTTL` treat expiry=0 with ttl>0 as expired. `setWithEvicted()` uses silent eviction (no onEvict double-fire). +- **tests/unit/lru.test.js** — 19 new tests added for the edge cases. Updated 3 existing tests for the corrected expiry=0 semantics. +- **Verification:** 168 tests pass, 100% line coverage, 99.39% branch coverage, lint clean. + +## Next Steps + +- [ ] Commit and push implementation +- [ ] Create PR +- [ ] Comment on issue #487 + +## Environment + +- **Repo:** /home/jason/Projects/tiny-lru +- **Branch:** master (default branch is `master`, NOT `main`) +- **Node:** v25.8.1 +- **Test command:** `npm run test` (lint + node --test) +- **Coverage:** `npm run coverage` (100% line required) +- **Style:** tabs, double quotes, semicolons, no `new Array()` + +## Notes + +- `memory/` dir is untracked (`?? memory/`) — local tracking only, not committed. +- `memory/audit-edge-cases.md` holds the full 18-finding audit with evidence. +- The `in progress` label had to be created (didn't exist in repo). +- tiny-lru's default branch is `master` — PR must target `master`, not `main`. diff --git a/memory/schedules/reflection-daily.json b/memory/schedules/reflection-daily.json new file mode 100644 index 0000000..259dead --- /dev/null +++ b/memory/schedules/reflection-daily.json @@ -0,0 +1,8 @@ +{ + "name": "reflection-daily", + "cron": "0 2 * * *", + "command": "cd /home/jason/Projects/madz && node index.js --chat \"/reflection\"", + "enabled": true, + "createdAt": "2026-06-27T14:36:01.003Z", + "updatedAt": "2026-06-27T14:36:01.003Z" +} \ No newline at end of file diff --git a/memory/tools/todo.json b/memory/tools/todo.json new file mode 100644 index 0000000..c1483d7 --- /dev/null +++ b/memory/tools/todo.json @@ -0,0 +1,9 @@ +{ + "todos": [ + { + "key": "audit-code", + "content": "Audit all directories in ./src for bugs, security vulnerabilities, and performance issues", + "completed": false + } + ] +} \ No newline at end of file diff --git a/src/lru.js b/src/lru.js index 00cb72f..8b03036 100644 --- a/src/lru.js +++ b/src/lru.js @@ -11,14 +11,26 @@ export 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(). + * @throws {TypeError} When parameters are invalid (negative numbers or wrong types). */ constructor(max = 0, ttl = 0, resetTTL = false) { + if (!Number.isInteger(max) || max < 0) { + throw new TypeError("Invalid max value"); + } + + if (!Number.isInteger(ttl) || ttl < 0) { + throw new TypeError("Invalid ttl value"); + } + + if (typeof resetTTL !== "boolean") { + throw new TypeError("Invalid resetTTL value"); + } + this.first = null; this.items = Object.create(null); this.last = null; @@ -66,14 +78,8 @@ export class LRU { const item = this.items[key]; if (item !== undefined) { - delete this.items[key]; - this.size--; + this.#removeItem(item); this.#stats.deletes++; - - this.#unlink(item); - - item.prev = null; - item.next = null; } return this; @@ -89,14 +95,25 @@ export class LRU { */ entries(keys) { if (keys === undefined) { - keys = this.keys(); + const result = []; + for (let x = this.first; x !== null; x = x.next) { + if (!this.#isExpired(x)) { + result.push([x.key, x.value]); + } + } + + return result; + } + + if (!Array.isArray(keys)) { + throw new TypeError("keys must be an array"); } 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]; + result[i] = [key, item !== undefined && !this.#isExpired(item) ? item.value : undefined]; } return result; @@ -112,20 +129,8 @@ export class LRU { return this; } - const item = this.first; - - delete this.items[item.key]; - this.#stats.evictions++; - - if (--this.size === 0) { - this.first = null; - this.last = null; - } else { - this.#unlink(item); - } + const item = this.#evictItem(); - item.prev = null; - item.next = null; if (this.#onEvict !== null) { this.#onEvict({ key: item.key, @@ -156,7 +161,7 @@ export class LRU { * @private */ #isExpired(item) { - if (this.ttl === 0 || item.expiry === 0) { + if (this.ttl === 0) { return false; } @@ -191,7 +196,7 @@ export class LRU { return item.value; } - this.delete(key); + this.#removeItem(item); this.#stats.misses++; return undefined; } @@ -202,13 +207,20 @@ export class LRU { /** * Checks if a key exists in the cache. + * Expired items are removed before returning false. * * @param {string} key - The key to check for. * @returns {boolean} True if the key exists and is not expired, false otherwise. */ has(key) { const item = this.items[key]; - return item !== undefined && !this.#isExpired(item); + + if (item !== undefined && this.#isExpired(item)) { + this.#removeItem(item); + return false; + } + + return item !== undefined; } /** @@ -236,6 +248,47 @@ export class LRU { } } + /** + * Removes an item from the cache without incrementing the deletes stat. + * Used internally by get()/has() when removing expired items. + * + * @param {Object} item - The cache item to remove. + * @private + */ + #removeItem(item) { + delete this.items[item.key]; + this.size--; + this.#unlink(item); + item.prev = null; + item.next = null; + } + + /** + * Evicts the least recently used item from the cache without firing onEvict. + * Used internally by setWithEvicted() to avoid double-notification. + * + * @returns {Object} The evicted item. + * @private + */ + #evictItem() { + const item = this.first; + + delete this.items[item.key]; + this.#stats.evictions++; + + if (--this.size === 0) { + this.first = null; + this.last = null; + } else { + this.#unlink(item); + } + + item.prev = null; + item.next = null; + + return item; + } + /** * 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 @@ -277,6 +330,7 @@ export class LRU { /** * Sets a value in the cache and returns any evicted item. + * Eviction is silent — onEvict is not fired for the returned item. * * @param {string} key - The key to set. * @param {*} value - The value to store. @@ -286,20 +340,24 @@ export class LRU { let evicted = null; let item = this.items[key]; - if (item !== undefined) { + if (item !== undefined && !this.#isExpired(item)) { item.value = value; if (this.resetTTL) { item.expiry = this.ttl > 0 ? Date.now() + this.ttl : this.ttl; } this.moveToEnd(item); } else { + if (item !== undefined) { + this.#removeItem(item); + } + if (this.max > 0 && this.size === this.max) { + const evictedItem = this.#evictItem(); evicted = { - key: this.first.key, - value: this.first.value, - expiry: this.first.expiry, + key: evictedItem.key, + value: evictedItem.value, + expiry: evictedItem.expiry, }; - this.evict(); } item = this.items[key] = { @@ -333,7 +391,7 @@ export class LRU { set(key, value) { let item = this.items[key]; - if (item !== undefined) { + if (item !== undefined && !this.#isExpired(item)) { item.value = value; if (this.resetTTL) { @@ -342,6 +400,10 @@ export class LRU { this.moveToEnd(item); } else { + if (item !== undefined) { + this.#removeItem(item); + } + if (this.max > 0 && this.size === this.max) { this.evict(); } @@ -378,18 +440,24 @@ export class LRU { */ values(keys) { if (keys === undefined) { - const result = Array.from({ length: this.size }); - let i = 0; + const result = []; for (let x = this.first; x !== null; x = x.next) { - result[i++] = x.value; + if (!this.#isExpired(x)) { + result.push(x.value); + } } + return result; } + if (!Array.isArray(keys)) { + throw new TypeError("keys must be an array"); + } + 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; + result[i] = item !== undefined && !this.#isExpired(item) ? item.value : undefined; } return result; @@ -398,15 +466,19 @@ export class LRU { /** * 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. + * get() or peek(), so it does not update LRU order. Expired items are skipped. * * @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); + for (let x = this.first; x !== null; ) { + const next = x.next; + if (!this.#isExpired(x)) { + callback.call(thisArg, x.value, x.key, this); + } + x = next; } return this; @@ -419,6 +491,10 @@ export class LRU { * @returns {Object} Object mapping keys to values (undefined for missing/expired keys). */ getMany(keys) { + if (!Array.isArray(keys)) { + throw new TypeError("keys must be an array"); + } + const result = Object.create(null); for (let i = 0; i < keys.length; i++) { const key = keys[i]; @@ -435,6 +511,10 @@ export class LRU { * @returns {boolean} True if all keys exist and are not expired. */ hasAll(keys) { + if (!Array.isArray(keys)) { + throw new TypeError("keys must be an array"); + } + for (let i = 0; i < keys.length; i++) { if (!this.has(keys[i])) { return false; @@ -451,6 +531,10 @@ export class LRU { * @returns {boolean} True if any key exists and is not expired. */ hasAny(keys) { + if (!Array.isArray(keys)) { + throw new TypeError("keys must be an array"); + } + for (let i = 0; i < keys.length; i++) { if (this.has(keys[i])) { return true; @@ -504,11 +588,13 @@ export class LRU { toJSON() { const result = []; for (let x = this.first; x !== null; x = x.next) { - result.push({ - key: x.key, - value: x.value, - expiry: x.expiry, - }); + if (!this.#isExpired(x)) { + result.push({ + key: x.key, + value: x.value, + expiry: x.expiry, + }); + } } return result; @@ -552,20 +638,16 @@ export class LRU { 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) { + if (x.expiry > now) { valid++; } else { expired++; } } - return { valid, expired, noTTL }; + return { valid, expired, noTTL: 0 }; } /** @@ -581,20 +663,16 @@ export class LRU { 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) { + if (x.expiry > now) { valid.push(x.key); } else { expired.push(x.key); } } - return { valid, expired, noTTL }; + return { valid, expired, noTTL: [] }; } /** @@ -603,13 +681,23 @@ export class LRU { * @returns {Object} Object with valid, expired, and noTTL arrays of values. */ valuesByTTL() { - const keysByTTL = this.keysByTTL(); + if (this.ttl === 0) { + return { valid: this.values(), expired: [], noTTL: this.values() }; + } - return { - valid: this.values(keysByTTL.valid), - expired: this.values(keysByTTL.expired), - noTTL: this.values(keysByTTL.noTTL), - }; + const now = Date.now(); + const valid = []; + const expired = []; + + for (let x = this.first; x !== null; x = x.next) { + if (x.expiry > now) { + valid.push(x.value); + } else { + expired.push(x.value); + } + } + + return { valid, expired, noTTL: [] }; } /** @@ -657,17 +745,5 @@ export class LRU { * @throws {TypeError} When parameters are invalid (negative numbers or wrong types). */ export 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); } diff --git a/tests/unit/lru.test.js b/tests/unit/lru.test.js index 6f3d7d4..930d282 100644 --- a/tests/unit/lru.test.js +++ b/tests/unit/lru.test.js @@ -1355,9 +1355,9 @@ describe("LRU Cache", function () { cache.items["b"].expiry = 0; const counts = cache.sizeByTTL(); - assert.equal(counts.valid, 3); - assert.equal(counts.expired, 0); - assert.equal(counts.noTTL, 2); + assert.equal(counts.valid, 1); + assert.equal(counts.expired, 2); + assert.equal(counts.noTTL, 0); }); it("should handle mixed expired and valid items", async function () { @@ -1441,12 +1441,12 @@ describe("LRU Cache", function () { 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.equal(result.valid.length, 1); + assert.equal(result.expired.length, 2); + assert.deepEqual(result.noTTL, []); assert.ok(result.valid.includes("c")); + assert.ok(result.expired.includes("a")); + assert.ok(result.expired.includes("b")); }); it("should return empty arrays for empty cache", function () { @@ -1516,12 +1516,12 @@ describe("LRU Cache", function () { 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.equal(result.valid.length, 1); + assert.equal(result.expired.length, 2); + assert.deepEqual(result.noTTL, []); assert.ok(result.valid.includes(3)); + assert.ok(result.expired.includes(1)); + assert.ok(result.expired.includes(2)); }); it("should return correct expired values after TTL", async function () { @@ -1575,4 +1575,167 @@ describe("LRU Cache", function () { assert.ok(result.expired.includes(3)); }); }); + + describe("Edge case fixes (issue #487)", function () { + it("should throw for non-array keys in entries()", function () { + const cache = new LRU(3); + cache.set("a", 1); + assert.throws(() => cache.entries(null), TypeError, "keys must be an array"); + assert.throws(() => cache.entries("abc"), TypeError, "keys must be an array"); + }); + + it("should throw for non-array keys in values()", function () { + const cache = new LRU(3); + cache.set("a", 1); + assert.throws(() => cache.values(null), TypeError, "keys must be an array"); + assert.throws(() => cache.values(5), TypeError, "keys must be an array"); + }); + + it("should throw for non-array keys in getMany()", function () { + const cache = new LRU(3); + cache.set("a", 1); + assert.throws(() => cache.getMany(null), TypeError, "keys must be an array"); + assert.throws(() => cache.getMany(5), TypeError, "keys must be an array"); + }); + + it("should throw for non-array keys in hasAll()", function () { + const cache = new LRU(3); + cache.set("a", 1); + assert.throws(() => cache.hasAll(null), TypeError, "keys must be an array"); + assert.throws(() => cache.hasAll("abc"), TypeError, "keys must be an array"); + }); + + it("should throw for non-array keys in hasAny()", function () { + const cache = new LRU(3); + cache.set("a", 1); + assert.throws(() => cache.hasAny(undefined), TypeError, "keys must be an array"); + assert.throws(() => cache.hasAny(5), TypeError, "keys must be an array"); + }); + + it("should validate max in constructor", function () { + assert.throws(() => new LRU(-1), TypeError, "Invalid max value"); + assert.throws(() => new LRU("10"), TypeError, "Invalid max value"); + assert.throws(() => new LRU(2.5), TypeError, "Invalid max value"); + assert.throws(() => new LRU(Infinity), TypeError, "Invalid max value"); + assert.throws(() => new LRU(null), TypeError, "Invalid max value"); + assert.throws(() => new LRU(""), TypeError, "Invalid max value"); + }); + + it("should validate ttl in constructor", function () { + assert.throws(() => new LRU(10, -1), TypeError, "Invalid ttl value"); + assert.throws(() => new LRU(10, "100"), TypeError, "Invalid ttl value"); + assert.throws(() => new LRU(10, 2.5), TypeError, "Invalid ttl value"); + assert.throws(() => new LRU(10, Infinity), TypeError, "Invalid ttl value"); + }); + + it("should validate resetTTL in constructor", function () { + assert.throws(() => new LRU(10, 0, "true"), TypeError, "Invalid resetTTL value"); + assert.throws(() => new LRU(10, 0, 1), TypeError, "Invalid resetTTL value"); + }); + + it("should reclaim expired key on set() with resetTTL=false", async function () { + const cache = new LRU(5, 50, false); + cache.set("k", "v"); + await new Promise((resolve) => setTimeout(resolve, 80)); + cache.set("k", "v2"); + assert.equal(cache.has("k"), true); + assert.equal(cache.get("k"), "v2"); + assert.equal(cache.size, 1); + }); + + it("should reclaim expired key on setWithEvicted()", async function () { + const cache = new LRU(1, 50, false); + cache.set("a", 1); + await new Promise((resolve) => setTimeout(resolve, 80)); + const evicted = cache.setWithEvicted("a", 2); + assert.equal(evicted, null); + assert.equal(cache.size, 1); + assert.equal(cache.get("a"), 2); + }); + + it("should skip expired items in values()", async function () { + const cache = new LRU(5, 50, false); + cache.set("a", 1); + cache.set("b", 2); + await new Promise((resolve) => setTimeout(resolve, 80)); + assert.deepEqual(cache.values(), []); + }); + + it("should skip expired items in entries()", async function () { + const cache = new LRU(5, 50, false); + cache.set("a", 1); + await new Promise((resolve) => setTimeout(resolve, 80)); + assert.deepEqual(cache.entries(), []); + }); + + it("should skip expired items in toJSON()", async function () { + const cache = new LRU(5, 50, false); + cache.set("a", 1); + await new Promise((resolve) => setTimeout(resolve, 80)); + assert.deepEqual(cache.toJSON(), []); + }); + + it("should skip expired items in forEach()", async function () { + const cache = new LRU(5, 50, false); + cache.set("a", 1); + cache.set("b", 2); + await new Promise((resolve) => setTimeout(resolve, 80)); + const seen = []; + cache.forEach((value, key) => seen.push(key)); + assert.deepEqual(seen, []); + }); + + it("should be mutation-safe in forEach() when deleting current item", function () { + const cache = new LRU(10); + cache.set("a", 1).set("b", 2).set("c", 3).set("d", 4); + const seen = []; + cache.forEach((value, key) => { + seen.push(key); + cache.delete(key); + }); + assert.deepEqual(seen, ["a", "b", "c", "d"]); + }); + + it("should not increment deletes when get() removes expired item", async function () { + const cache = new LRU(5, 50, false); + cache.set("k", "v"); + await new Promise((resolve) => setTimeout(resolve, 80)); + cache.get("k"); + const stats = cache.stats(); + assert.equal(stats.deletes, 0); + assert.equal(stats.misses, 1); + }); + + it("should delete expired items in getMany()", async function () { + const cache = new LRU(5, 50, false); + cache.set("a", 1).set("b", 2); + await new Promise((resolve) => setTimeout(resolve, 80)); + const result = cache.getMany(["a", "b"]); + assert.equal(result.a, undefined); + assert.equal(result.b, undefined); + assert.equal(cache.size, 0); + }); + + it("should treat expiry=0 with ttl>0 as expired in sizeByTTL()", function () { + const cache = new LRU(10, 100); + cache.set("a", 1).set("b", 2).set("c", 3); + cache.items["a"].expiry = 0; + cache.items["b"].expiry = 0; + const counts = cache.sizeByTTL(); + assert.equal(counts.valid, 1); + assert.equal(counts.expired, 2); + assert.equal(counts.noTTL, 0); + }); + + it("should not fire onEvict for setWithEvicted() silent eviction", function () { + const cache = new LRU(2); + let cbCount = 0; + cache.onEvict(() => cbCount++); + cache.set("a", 1).set("b", 2); + const evicted = cache.setWithEvicted("c", 3); + assert.notEqual(evicted, null); + assert.equal(evicted.key, "a"); + assert.equal(cbCount, 0); + }); + }); }); From 5a568eca8834dac12ea20f526669f08a9351d5ac Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Tue, 15 Sep 2026 06:08:29 -0400 Subject: [PATCH 2/4] wtf --- memory/audit-edge-cases.md | 60 ---------- memory/audit-state.md | 13 -- memory/fix-issue-487-task.md | 157 ------------------------- memory/schedules/reflection-daily.json | 8 -- memory/tools/todo.json | 9 -- 5 files changed, 247 deletions(-) delete mode 100644 memory/audit-edge-cases.md delete mode 100644 memory/audit-state.md delete mode 100644 memory/fix-issue-487-task.md delete mode 100644 memory/schedules/reflection-daily.json delete mode 100644 memory/tools/todo.json diff --git a/memory/audit-edge-cases.md b/memory/audit-edge-cases.md deleted file mode 100644 index fb1032a..0000000 --- a/memory/audit-edge-cases.md +++ /dev/null @@ -1,60 +0,0 @@ -# tiny-lru Edge Case Audit — Findings - -Audited `src/lru.js` (673 lines) and `tests/unit/lru.test.js` (1578 lines). All 149 existing tests pass. The following edge cases are NOT covered by the test suite and represent real behavioral inconsistencies. All findings reproduced with `node` probes against `src/lru.js`. - -## Findings - -| # | Severity | Area | Description | Evidence | -|---|----------|------|-------------|----------| -| 1 | High | `lru()` factory validation | `lru("10")`, `lru(true)`, `lru(2.5)`, `lru(Infinity)`, `lru(null)`, `lru(false)`, `lru("")` all pass validation but silently disable eviction. `this.size === this.max` uses strict equality, so a non-integer max never evicts. | `lru("10")` → size 15 after 15 sets. `lru(2.5)` → size 5 after 5 sets. `lru(Infinity)` → size 5 after 5 sets. `lru("")` accepted (`isNaN("")` is false). | -| 2 | High | `set()` on expired key | With `resetTTL=false`, `set()` on an expired key leaves the item dead but occupying a slot. `has()` false, `get()` undefined, but `size` unchanged. With `resetTTL=true` it resurrects correctly — inconsistent. | `set("k","v")`, wait 80ms, `set("k","v2")` → `has(k)=false`, `get(k)=undefined`, `size=1`. | -| 3 | High | TTL semantics across read methods | `values()`, `entries()`, `forEach()`, `toJSON()` return expired items, while `get()`/`has()` treat them as gone. Inconsistent TTL enforcement. | Expired item: `values()=["v"]`, `entries()=[["k","v"]]`, `forEach` sees it, `toJSON` includes it. `get(k)=undefined`. | -| 4 | High | `forEach()` mutation truncates iteration | Deleting the current item during `forEach()` truncates the loop — subsequent items are never visited. | `forEach((v,k)=>{seen.push(k); c.delete(k);})` on 4 items → `seen=["a"]` (only first visited). | -| 5 | Medium | `get()` stats side effect | `get()` on an expired item increments BOTH `deletes` and `misses`. A read miss triggers a delete. | After expired `get(k)`: `stats={hits:0, misses:1, sets:1, deletes:1}`. | -| 6 | Medium | `cleanup()` vs `evict()` onEvict | `cleanup()` removes expired items but does NOT fire `onEvict`; `evict()` does. Inconsistent eviction notification. | Test confirms `onEvict` not called during `cleanup()`. | -| 7 | Medium | `setWithEvicted()` on expired key at max | On an expired key at max capacity, `setWithEvicted()` returns `null` evicted and leaves a dead item in place — the expired item is never reclaimed. | `set("a",1)` at max=1, wait, `setWithEvicted("a",2)` → `evicted=null`, `get(a)=undefined`. | -| 8 | Medium | Batch method side effects | `getMany()` deletes expired items (size drops to 0), but `hasAll()`/`hasAny()` do not (size unchanged). Inconsistent. | `getMany(["a","b"])` on expired → `{}`, `size=0`. `hasAll` → false, `size=2`. | -| 9 | Medium | Key coercion collisions | `set(1)` and `set("1")` collide to the same slot. Same for `set(true)`/`set("true")` and `set(-0)`/`set(0)`. Object keys coerce to `"[object Object]"`. | `set(1,"n"); set("1","s")` → `size=1`, `get(1)="s"`. `set(true)`/`set("true")` → `size=2`, `get(true)="S"`. | -| 10 | Medium | `values(null)` / `entries(null)` crash | Passing `null` as the keys argument throws `TypeError: Cannot read properties of null (reading 'length')`. | `values(null)` → `TypeError`. `entries(null)` → `TypeError`. | -| 11 | Medium | Batch methods crash on null/undefined | `getMany(null)`, `hasAll(null)`, `hasAny(undefined)` throw `TypeError: Cannot read properties of null/undefined (reading 'length')`. | `getMany(null)` → `TypeError`. `hasAny(undefined)` → `TypeError`. | -| 12 | Low | Constructor validation | `new LRU(-1)` works and behaves as unlimited. Constructor does not validate params (documented, but class is public API). | `new LRU(-1)` → size 2 after 2 sets, no eviction. | -| 13 | Low | `sizeByTTL`/`keysByTTL`/`valuesByTTL` noTTL semantics | Items with `expiry=0` are counted as `noTTL` even when `ttl>0`. | With `ttl=100`, item with `expiry=0` → `sizeByTTL={valid:1, expired:0, noTTL:1}`. | -| 14 | Low | `peek()` on expired item | `peek()` returns expired value (documented as no TTL check). By design, but inconsistent with `get()`. | Expired item: `peek(k)="v"`. | -| 15 | Low | `clear()`/`delete()` don't fire `onEvict` | Only `evict()` fires `onEvict`. Deleting or clearing items silently skips the callback. | `onEvict` fired for `[]` after delete+clear; only `evict()` triggered it. | -| 16 | Low | `setWithEvicted()` double-notification | Returns the evicted item AND fires `onEvict` for the same eviction — caller gets it twice. | `setWithEvicted("c",3)` at max → returned `{key:"a",...}` AND `onEvict` fired once. | -| 17 | Low | String keys argument treated as char list | `values("abc")`/`entries("abc")` iterate the string as single-char keys. | `values("abc")` → `[1,2,3]` for keys a,b,c. | -| 18 | Low | Non-array input silently ignored | `getMany(5)` returns `{}` silently instead of throwing or validating. | `getMany(5)` → `{}`. | - -## Reproduction - -All findings reproduced with `node` probes against `src/lru.js`. See evidence column per finding. - -## Root Cause Analysis - -- **Finding 1**: `lru()` factory uses `isNaN(max)` which returns false for numeric-coercible strings (`"10"`), booleans (`true`), floats (`2.5`), `Infinity`, `null`, and `false`. The eviction guard `this.size === this.max` then never triggers for non-integer values. -- **Findings 2, 3, 7**: `set()` and `setWithEvicted()` check `item !== undefined` to decide update-vs-insert, but never check `#isExpired(item)`. An expired item is treated as a live update, so its stale expiry is preserved. -- **Finding 4**: `forEach()` iterates the linked list by following `x.next`. Deleting the current item nullifies its `next` pointer, so the loop terminates early. -- **Findings 5, 6, 8**: `get()` deletes expired items (incrementing `deletes`), while `cleanup()` and the batch `has*` methods do not — inconsistent TTL enforcement paths. -- **Finding 9**: `items` is a plain object keyed by `key`, so JS coerces all keys to strings. `1` and `"1"`, `true` and `"true"`, `-0` and `0` all collide. -- **Findings 10, 11**: `values()`/`entries()`/`getMany()`/`hasAll()`/`hasAny()` assume `keys` is an array and access `.length` without validation. - -## Testing Strategy - -- **Unit tests**: Add tests for each finding — factory validation with string/boolean/float/Infinity/null max, `set()` on expired key with both `resetTTL` values, read-method TTL enforcement, `forEach()` mutation, `get()` stats on expired, `cleanup()` onEvict, `setWithEvicted()` on expired at max, batch method side effects, key coercion, null/undefined keys argument, string keys argument. -- **Edge cases**: Expired key at max capacity, `expiry=0` with `ttl>0`, negative constructor max, `lru("")`, symbol keys, `__proto__` key, `values(null)`. - -## Security Considerations - -- No credential or input-handling concerns. This is a cache correctness issue. The main risk is stale data being served via `values()`/`entries()`/`forEach()`/`toJSON()` after TTL expiry. The `__proto__` key is handled safely (null-prototype `items` prevents prototype pollution — verified `Object.prototype.polluted` is undefined). - -## Fix Steps - -1. **Validate factory inputs** — In `lru()`, replace `isNaN(max)` with `!Number.isInteger(max) || max < 0`. Apply the same to `ttl`. -2. **Handle expired keys in `set()`/`setWithEvicted()`** — In the update branch, check `#isExpired(item)` first. If expired, treat as a fresh insert (reclaim the slot) rather than an update. -3. **Enforce TTL in read methods** — Make `values()`, `entries()`, `forEach()`, `toJSON()` skip expired items, consistent with `get()`/`has()`. -4. **Make `forEach()` mutation-safe** — Capture the next pointer before invoking the callback so deleting the current item doesn't truncate iteration. -5. **Fix `get()` stats** — Do not increment `deletes` when `get()` removes an expired item on a miss. -6. **Fire `onEvict` in `cleanup()`** — Call `#onEvict` for each expired item removed, consistent with `evict()`. -7. **Validate keys arguments** — Guard `values()`, `entries()`, `getMany()`, `hasAll()`, `hasAny()` against null/undefined/non-array input. -8. **Add tests** — Add unit tests covering all 18 findings in `tests/unit/lru.test.js`. -9. **Verify** — Run `npm run test` and `npm run coverage` to confirm 100% line coverage and no regressions. diff --git a/memory/audit-state.md b/memory/audit-state.md deleted file mode 100644 index fc71885..0000000 --- a/memory/audit-state.md +++ /dev/null @@ -1,13 +0,0 @@ -# Audit State - -## Phase Queue -- [ ] ./src - -## Current Phase -./src - -## Completed -- (none) - -## Findings -(none yet) \ No newline at end of file diff --git a/memory/fix-issue-487-task.md b/memory/fix-issue-487-task.md deleted file mode 100644 index d2528ff..0000000 --- a/memory/fix-issue-487-task.md +++ /dev/null @@ -1,157 +0,0 @@ -# Task File — fix-issue 487 (tiny-lru) - -> Live tracking file for the fix-issue pipeline. Update as work progresses. Do not delete. - -## Issue - -- **Number:** 487 -- **Title:** fix: LRU edge cases in TTL handling and factory validation -- **URL:** https://github.com/avoidwork/tiny-lru/issues/487 -- **Repo:** avoidwork/tiny-lru -- **Labels:** bug, approved, in progress -- **State:** OPEN - -## Pipeline State - -| Step | Status | Notes | -|------|--------|-------| -| Fetch issue | ✅ done | OPEN, approved+bug labels | -| Validate approval | ✅ done | `approved` present, no `in progress` at fetch | -| Set in-progress label | ✅ done | Label did not exist → created it (`in progress`, color fbca04), then added | -| Categorize | ✅ done | `bug` → branch type `fix` | -| Infra check | ✅ done | HAS_PACKAGE_JSON=true, HAS_OPENSPEC=false, HAS_BUILD_SCRIPT=true | -| create-feature chain | ⏳ pending | See "OpenSpec Decision" below | -| Comment on issue | ⏳ pending | After PR created | -| Verify | ⏳ pending | npm test + coverage | - -## OpenSpec Decision - -**Problem:** The fix-issue skill says: if ANY of package.json / openspec / build-script is present, proceed with the FULL create-feature pipeline (which requires an `openspec/` directory). But tiny-lru has **no `openspec/` directory** — only `package.json` + build scripts. - -**Facts:** -- `openspec` CLI is installed (`/home/jason/.nvm/versions/node/v25.8.1/bin/openspec`) -- `openspec/` directory does NOT exist in tiny-lru -- `package.json` exists with build/test/lint/coverage scripts - -**Decision:** The full OpenSpec pipeline (propose → spec → apply → archive) cannot run without an `openspec/` directory. This is a **bug fix on a library**, not a feature on the madz harness. Proceed with the **direct implementation path** (SKIP_OPENSPEC semantics): -1. Create feature branch `fix/` -2. Implement the fix inline in `src/lru.js` -3. Add tests in `tests/unit/lru.test.js` -4. Run `npm run test` + `npm run coverage` -5. Commit + push -6. Create PR targeting `master` (tiny-lru's default branch is `master`, not `main`) -7. Comment on issue #487 linking the PR - -## Findings (18) — Implementation Plan - -### Group A: Factory validation (Finding 1) -- **Fix:** In `lru()`, replace `isNaN(max)` with `!Number.isInteger(max) || max < 0`. Same for `ttl`. -- **Files:** `src/lru.js` lines 659-673 -- **Tests:** `lru("10")`, `lru(true)`, `lru(2.5)`, `lru(Infinity)`, `lru(null)`, `lru(false)`, `lru("")` all throw TypeError. - -### Group B: Expired-key handling in set/setWithEvicted (Findings 2, 7) -- **Fix:** In `set()` and `setWithEvicted()` update branch, check `#isExpired(item)` first. If expired, treat as fresh insert (reclaim slot) rather than update. -- **Files:** `src/lru.js` lines 285-324, 333-369 -- **Tests:** expired key + `set()` with resetTTL=false and true; `setWithEvicted()` on expired at max. - -### Group C: TTL enforcement in read methods (Finding 3) -- **Fix:** Make `values()`, `entries()`, `forEach()`, `toJSON()` skip expired items (consistent with `get()`/`has()`). -- **Files:** `src/lru.js` lines 90-103, 379-396, 407-413, 504-515 -- **Tests:** expired item not returned by any read method. - -### Group D: forEach mutation safety (Finding 4) -- **Fix:** Capture `x.next` before invoking callback so deleting current item doesn't truncate iteration. -- **Files:** `src/lru.js` lines 407-413 -- **Tests:** `forEach` + delete current item visits all items. - -### Group E: get() stats (Finding 5) -- **Fix:** Do not increment `deletes` when `get()` removes an expired item on a miss. -- **Files:** `src/lru.js` lines 184-201 -- **Tests:** expired `get()` → `deletes` not incremented. - -### Group F: cleanup() onEvict (Finding 6) -- **Fix:** Fire `#onEvict` for each expired item removed in `cleanup()`, consistent with `evict()`. -- **Files:** `src/lru.js` lines 469-497 -- **Tests:** `cleanup()` fires onEvict for removed items. - -### Group G: Batch method consistency (Finding 8) -- **Fix:** Make `hasAll()`/`hasAny()` consistent with `getMany()` re: expired items. Decide: either all delete expired or none do. Recommend: `has*` should NOT delete (read-only), `getMany` should NOT delete either (or document). **Needs decision.** -- **Files:** `src/lru.js` lines 421-461 -- **Tests:** batch methods on expired items. - -### Group H: Key coercion collisions (Finding 9) -- **Status:** DESIGN DECISION. `items` is a plain object keyed by string, so `1`/`"1"`, `true`/`"true"`, `-0`/`0` collide. Fixing requires switching to a `Map`. **This is a breaking change** — flag for user decision before implementing. -- **Files:** `src/lru.js` line 23 (`this.items = Object.create(null)`) - -### Group I: Null/undefined keys argument crash (Findings 10, 11) -- **Fix:** Guard `values()`, `entries()`, `getMany()`, `hasAll()`, `hasAny()` against null/undefined/non-array input. -- **Files:** `src/lru.js` lines 90-103, 379-396, 421-429, 437-445, 453-461 -- **Tests:** `values(null)`, `entries(null)`, `getMany(null)`, `hasAll(null)`, `hasAny(undefined)` don't crash. - -### Group J: Constructor validation (Finding 12) -- **Status:** DOCUMENTED BEHAVIOR. Constructor intentionally doesn't validate. `new LRU(-1)` behaves as unlimited. Low priority — likely leave as-is or document. **Needs decision.** - -### Group K: noTTL semantics (Finding 13) -- **Fix:** `sizeByTTL`/`keysByTTL`/`valuesByTTL` count `expiry=0` as `noTTL` even when `ttl>0`. Decide if this is correct (an item with expiry=0 genuinely has no TTL) or a bug. **Needs decision.** -- **Files:** `src/lru.js` lines 547-613 - -### Group L: peek() on expired (Finding 14) -- **Status:** BY DESIGN. `peek()` documented as no TTL check. Leave as-is. - -### Group M: clear()/delete() don't fire onEvict (Finding 15) -- **Fix:** Decide whether `delete()`/`clear()` should fire `onEvict`. Currently only `evict()` does. **Needs decision** — firing onEvict on delete/clear may be surprising. -- **Files:** `src/lru.js` lines 38-57, 65-80 - -### Group N: setWithEvicted double-notification (Finding 16) -- **Fix:** `setWithEvicted()` returns evicted item AND fires `onEvict`. Caller gets it twice. Decide: return value is the API contract; onEvict is the callback. Both firing is arguably correct (different consumers). **Needs decision.** -- **Files:** `src/lru.js` lines 285-324 - -### Group O: String keys argument (Finding 17) -- **Fix:** `values("abc")`/`entries("abc")` iterate chars. Guard against non-array input (treat as single key or throw). Ties into Group I. -- **Files:** `src/lru.js` lines 90-103, 379-396 - -### Group P: Non-array input silently ignored (Finding 18) -- **Fix:** `getMany(5)` returns `{}` silently. Ties into Group I — validate input. -- **Files:** `src/lru.js` lines 421-429 - -## Decisions (RESOLVED by user) - -1. **Finding 9 (key coercion):** NO Map — too slow. Keep plain object. Document key coercion as string-only. NOT fixed. -2. **Finding 8 (batch consistency):** YES — getMany/has* should delete expired items. FIX. -3. **Finding 12 (constructor validation):** YES — validate in the constructor. FIX. -4. **Finding 13 (noTTL semantics):** YES — expiry=0 with ttl>0 is a bug. Treat as expired. FIX. -5. **Finding 15 (onEvict on delete/clear):** NO — delete()/clear() should NOT fire onEvict. Leave as-is. NOT a bug. -6. **Finding 16 (double-notification):** NO — returning evicted + firing onEvict is NOT correct. setWithEvicted should use silent eviction. FIX. - -## Implementation Scope - -**FIX (14):** 1, 2, 3, 4, 5, 7, 8, 10, 11, 12, 13, 16, 17, 18 -**NOT FIXED (documented/design):** 6 (cleanup onEvict — correct per philosophy), 9 (key coercion — no Map), 14 (peek by design), 15 (delete/clear onEvict — correct per user) - -## Implementation Status: COMPLETE - -- **src/lru.js** — All fixes implemented. Constructor validates max/ttl/resetTTL. `set()`/`setWithEvicted()` reclaim expired keys. `values()`/`entries()`/`forEach()`/`toJSON()` skip expired items. `forEach()` is mutation-safe. `get()` no longer increments deletes on expired miss. `getMany()`/`has*()` validate array input. `sizeByTTL`/`keysByTTL`/`valuesByTTL` treat expiry=0 with ttl>0 as expired. `setWithEvicted()` uses silent eviction (no onEvict double-fire). -- **tests/unit/lru.test.js** — 19 new tests added for the edge cases. Updated 3 existing tests for the corrected expiry=0 semantics. -- **Verification:** 168 tests pass, 100% line coverage, 99.39% branch coverage, lint clean. - -## Next Steps - -- [ ] Commit and push implementation -- [ ] Create PR -- [ ] Comment on issue #487 - -## Environment - -- **Repo:** /home/jason/Projects/tiny-lru -- **Branch:** master (default branch is `master`, NOT `main`) -- **Node:** v25.8.1 -- **Test command:** `npm run test` (lint + node --test) -- **Coverage:** `npm run coverage` (100% line required) -- **Style:** tabs, double quotes, semicolons, no `new Array()` - -## Notes - -- `memory/` dir is untracked (`?? memory/`) — local tracking only, not committed. -- `memory/audit-edge-cases.md` holds the full 18-finding audit with evidence. -- The `in progress` label had to be created (didn't exist in repo). -- tiny-lru's default branch is `master` — PR must target `master`, not `main`. diff --git a/memory/schedules/reflection-daily.json b/memory/schedules/reflection-daily.json deleted file mode 100644 index 259dead..0000000 --- a/memory/schedules/reflection-daily.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "reflection-daily", - "cron": "0 2 * * *", - "command": "cd /home/jason/Projects/madz && node index.js --chat \"/reflection\"", - "enabled": true, - "createdAt": "2026-06-27T14:36:01.003Z", - "updatedAt": "2026-06-27T14:36:01.003Z" -} \ No newline at end of file diff --git a/memory/tools/todo.json b/memory/tools/todo.json deleted file mode 100644 index c1483d7..0000000 --- a/memory/tools/todo.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "todos": [ - { - "key": "audit-code", - "content": "Audit all directories in ./src for bugs, security vulnerabilities, and performance issues", - "completed": false - } - ] -} \ No newline at end of file From 49ddc20e5f6926a11a227da3cb88a37d39f4ff40 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Tue, 15 Sep 2026 06:22:23 -0400 Subject: [PATCH 3/4] test: add regression test for mutable ttl expiration --- tests/unit/lru.test.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/unit/lru.test.js b/tests/unit/lru.test.js index 930d282..a15cb67 100644 --- a/tests/unit/lru.test.js +++ b/tests/unit/lru.test.js @@ -1727,6 +1727,18 @@ describe("LRU Cache", function () { assert.equal(counts.noTTL, 0); }); + it("should treat items as expired when ttl is raised after insertion", function () { + const cache = new LRU(10, 0); + cache.set("a", 1); + assert.equal(cache.ttl, 0); + assert.equal(cache.get("a"), 1); + + cache.ttl = 5000; + assert.equal(cache.has("a"), false); + assert.equal(cache.get("a"), undefined); + assert.equal(cache.size, 0); + }); + it("should not fire onEvict for setWithEvicted() silent eviction", function () { const cache = new LRU(2); let cbCount = 0; From 3170657a83198b34df6065d1b972e2cd3b147bc9 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Tue, 15 Sep 2026 06:42:42 -0400 Subject: [PATCH 4/4] Building --- dist/tiny-lru.cjs | 224 ++++++++++++++++++++++++++------------- dist/tiny-lru.js | 224 ++++++++++++++++++++++++++------------- dist/tiny-lru.min.js | 2 +- dist/tiny-lru.min.js.map | 2 +- 4 files changed, 302 insertions(+), 150 deletions(-) diff --git a/dist/tiny-lru.cjs b/dist/tiny-lru.cjs index b61ab79..bbca10d 100644 --- a/dist/tiny-lru.cjs +++ b/dist/tiny-lru.cjs @@ -20,14 +20,26 @@ 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(). + * @throws {TypeError} When parameters are invalid (negative numbers or wrong types). */ constructor(max = 0, ttl = 0, resetTTL = false) { + if (!Number.isInteger(max) || max < 0) { + throw new TypeError("Invalid max value"); + } + + if (!Number.isInteger(ttl) || ttl < 0) { + throw new TypeError("Invalid ttl value"); + } + + if (typeof resetTTL !== "boolean") { + throw new TypeError("Invalid resetTTL value"); + } + this.first = null; this.items = Object.create(null); this.last = null; @@ -75,14 +87,8 @@ class LRU { const item = this.items[key]; if (item !== undefined) { - delete this.items[key]; - this.size--; + this.#removeItem(item); this.#stats.deletes++; - - this.#unlink(item); - - item.prev = null; - item.next = null; } return this; @@ -98,14 +104,25 @@ class LRU { */ entries(keys) { if (keys === undefined) { - keys = this.keys(); + const result = []; + for (let x = this.first; x !== null; x = x.next) { + if (!this.#isExpired(x)) { + result.push([x.key, x.value]); + } + } + + return result; + } + + if (!Array.isArray(keys)) { + throw new TypeError("keys must be an array"); } 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]; + result[i] = [key, item !== undefined && !this.#isExpired(item) ? item.value : undefined]; } return result; @@ -121,20 +138,8 @@ class LRU { return this; } - const item = this.first; - - delete this.items[item.key]; - this.#stats.evictions++; - - if (--this.size === 0) { - this.first = null; - this.last = null; - } else { - this.#unlink(item); - } + const item = this.#evictItem(); - item.prev = null; - item.next = null; if (this.#onEvict !== null) { this.#onEvict({ key: item.key, @@ -165,7 +170,7 @@ class LRU { * @private */ #isExpired(item) { - if (this.ttl === 0 || item.expiry === 0) { + if (this.ttl === 0) { return false; } @@ -200,7 +205,7 @@ class LRU { return item.value; } - this.delete(key); + this.#removeItem(item); this.#stats.misses++; return undefined; } @@ -211,13 +216,20 @@ class LRU { /** * Checks if a key exists in the cache. + * Expired items are removed before returning false. * * @param {string} key - The key to check for. * @returns {boolean} True if the key exists and is not expired, false otherwise. */ has(key) { const item = this.items[key]; - return item !== undefined && !this.#isExpired(item); + + if (item !== undefined && this.#isExpired(item)) { + this.#removeItem(item); + return false; + } + + return item !== undefined; } /** @@ -245,6 +257,47 @@ class LRU { } } + /** + * Removes an item from the cache without incrementing the deletes stat. + * Used internally by get()/has() when removing expired items. + * + * @param {Object} item - The cache item to remove. + * @private + */ + #removeItem(item) { + delete this.items[item.key]; + this.size--; + this.#unlink(item); + item.prev = null; + item.next = null; + } + + /** + * Evicts the least recently used item from the cache without firing onEvict. + * Used internally by setWithEvicted() to avoid double-notification. + * + * @returns {Object} The evicted item. + * @private + */ + #evictItem() { + const item = this.first; + + delete this.items[item.key]; + this.#stats.evictions++; + + if (--this.size === 0) { + this.first = null; + this.last = null; + } else { + this.#unlink(item); + } + + item.prev = null; + item.next = null; + + return item; + } + /** * 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 @@ -286,6 +339,7 @@ class LRU { /** * Sets a value in the cache and returns any evicted item. + * Eviction is silent — onEvict is not fired for the returned item. * * @param {string} key - The key to set. * @param {*} value - The value to store. @@ -295,20 +349,24 @@ class LRU { let evicted = null; let item = this.items[key]; - if (item !== undefined) { + if (item !== undefined && !this.#isExpired(item)) { item.value = value; if (this.resetTTL) { item.expiry = this.ttl > 0 ? Date.now() + this.ttl : this.ttl; } this.moveToEnd(item); } else { + if (item !== undefined) { + this.#removeItem(item); + } + if (this.max > 0 && this.size === this.max) { + const evictedItem = this.#evictItem(); evicted = { - key: this.first.key, - value: this.first.value, - expiry: this.first.expiry, + key: evictedItem.key, + value: evictedItem.value, + expiry: evictedItem.expiry, }; - this.evict(); } item = this.items[key] = { @@ -342,7 +400,7 @@ class LRU { set(key, value) { let item = this.items[key]; - if (item !== undefined) { + if (item !== undefined && !this.#isExpired(item)) { item.value = value; if (this.resetTTL) { @@ -351,6 +409,10 @@ class LRU { this.moveToEnd(item); } else { + if (item !== undefined) { + this.#removeItem(item); + } + if (this.max > 0 && this.size === this.max) { this.evict(); } @@ -387,18 +449,24 @@ class LRU { */ values(keys) { if (keys === undefined) { - const result = Array.from({ length: this.size }); - let i = 0; + const result = []; for (let x = this.first; x !== null; x = x.next) { - result[i++] = x.value; + if (!this.#isExpired(x)) { + result.push(x.value); + } } + return result; } + if (!Array.isArray(keys)) { + throw new TypeError("keys must be an array"); + } + 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; + result[i] = item !== undefined && !this.#isExpired(item) ? item.value : undefined; } return result; @@ -407,15 +475,19 @@ class LRU { /** * 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. + * get() or peek(), so it does not update LRU order. Expired items are skipped. * * @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); + for (let x = this.first; x !== null; ) { + const next = x.next; + if (!this.#isExpired(x)) { + callback.call(thisArg, x.value, x.key, this); + } + x = next; } return this; @@ -428,6 +500,10 @@ class LRU { * @returns {Object} Object mapping keys to values (undefined for missing/expired keys). */ getMany(keys) { + if (!Array.isArray(keys)) { + throw new TypeError("keys must be an array"); + } + const result = Object.create(null); for (let i = 0; i < keys.length; i++) { const key = keys[i]; @@ -444,6 +520,10 @@ class LRU { * @returns {boolean} True if all keys exist and are not expired. */ hasAll(keys) { + if (!Array.isArray(keys)) { + throw new TypeError("keys must be an array"); + } + for (let i = 0; i < keys.length; i++) { if (!this.has(keys[i])) { return false; @@ -460,6 +540,10 @@ class LRU { * @returns {boolean} True if any key exists and is not expired. */ hasAny(keys) { + if (!Array.isArray(keys)) { + throw new TypeError("keys must be an array"); + } + for (let i = 0; i < keys.length; i++) { if (this.has(keys[i])) { return true; @@ -513,11 +597,13 @@ class LRU { toJSON() { const result = []; for (let x = this.first; x !== null; x = x.next) { - result.push({ - key: x.key, - value: x.value, - expiry: x.expiry, - }); + if (!this.#isExpired(x)) { + result.push({ + key: x.key, + value: x.value, + expiry: x.expiry, + }); + } } return result; @@ -561,20 +647,16 @@ class LRU { 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) { + if (x.expiry > now) { valid++; } else { expired++; } } - return { valid, expired, noTTL }; + return { valid, expired, noTTL: 0 }; } /** @@ -590,20 +672,16 @@ class LRU { 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) { + if (x.expiry > now) { valid.push(x.key); } else { expired.push(x.key); } } - return { valid, expired, noTTL }; + return { valid, expired, noTTL: [] }; } /** @@ -612,13 +690,23 @@ class LRU { * @returns {Object} Object with valid, expired, and noTTL arrays of values. */ valuesByTTL() { - const keysByTTL = this.keysByTTL(); + if (this.ttl === 0) { + return { valid: this.values(), expired: [], noTTL: this.values() }; + } - return { - valid: this.values(keysByTTL.valid), - expired: this.values(keysByTTL.expired), - noTTL: this.values(keysByTTL.noTTL), - }; + const now = Date.now(); + const valid = []; + const expired = []; + + for (let x = this.first; x !== null; x = x.next) { + if (x.expiry > now) { + valid.push(x.value); + } else { + expired.push(x.value); + } + } + + return { valid, expired, noTTL: [] }; } /** @@ -666,18 +754,6 @@ class LRU { * @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); } diff --git a/dist/tiny-lru.js b/dist/tiny-lru.js index f8b50ed..58b667f 100644 --- a/dist/tiny-lru.js +++ b/dist/tiny-lru.js @@ -18,14 +18,26 @@ 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(). + * @throws {TypeError} When parameters are invalid (negative numbers or wrong types). */ constructor(max = 0, ttl = 0, resetTTL = false) { + if (!Number.isInteger(max) || max < 0) { + throw new TypeError("Invalid max value"); + } + + if (!Number.isInteger(ttl) || ttl < 0) { + throw new TypeError("Invalid ttl value"); + } + + if (typeof resetTTL !== "boolean") { + throw new TypeError("Invalid resetTTL value"); + } + this.first = null; this.items = Object.create(null); this.last = null; @@ -73,14 +85,8 @@ class LRU { const item = this.items[key]; if (item !== undefined) { - delete this.items[key]; - this.size--; + this.#removeItem(item); this.#stats.deletes++; - - this.#unlink(item); - - item.prev = null; - item.next = null; } return this; @@ -96,14 +102,25 @@ class LRU { */ entries(keys) { if (keys === undefined) { - keys = this.keys(); + const result = []; + for (let x = this.first; x !== null; x = x.next) { + if (!this.#isExpired(x)) { + result.push([x.key, x.value]); + } + } + + return result; + } + + if (!Array.isArray(keys)) { + throw new TypeError("keys must be an array"); } 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]; + result[i] = [key, item !== undefined && !this.#isExpired(item) ? item.value : undefined]; } return result; @@ -119,20 +136,8 @@ class LRU { return this; } - const item = this.first; - - delete this.items[item.key]; - this.#stats.evictions++; - - if (--this.size === 0) { - this.first = null; - this.last = null; - } else { - this.#unlink(item); - } + const item = this.#evictItem(); - item.prev = null; - item.next = null; if (this.#onEvict !== null) { this.#onEvict({ key: item.key, @@ -163,7 +168,7 @@ class LRU { * @private */ #isExpired(item) { - if (this.ttl === 0 || item.expiry === 0) { + if (this.ttl === 0) { return false; } @@ -198,7 +203,7 @@ class LRU { return item.value; } - this.delete(key); + this.#removeItem(item); this.#stats.misses++; return undefined; } @@ -209,13 +214,20 @@ class LRU { /** * Checks if a key exists in the cache. + * Expired items are removed before returning false. * * @param {string} key - The key to check for. * @returns {boolean} True if the key exists and is not expired, false otherwise. */ has(key) { const item = this.items[key]; - return item !== undefined && !this.#isExpired(item); + + if (item !== undefined && this.#isExpired(item)) { + this.#removeItem(item); + return false; + } + + return item !== undefined; } /** @@ -243,6 +255,47 @@ class LRU { } } + /** + * Removes an item from the cache without incrementing the deletes stat. + * Used internally by get()/has() when removing expired items. + * + * @param {Object} item - The cache item to remove. + * @private + */ + #removeItem(item) { + delete this.items[item.key]; + this.size--; + this.#unlink(item); + item.prev = null; + item.next = null; + } + + /** + * Evicts the least recently used item from the cache without firing onEvict. + * Used internally by setWithEvicted() to avoid double-notification. + * + * @returns {Object} The evicted item. + * @private + */ + #evictItem() { + const item = this.first; + + delete this.items[item.key]; + this.#stats.evictions++; + + if (--this.size === 0) { + this.first = null; + this.last = null; + } else { + this.#unlink(item); + } + + item.prev = null; + item.next = null; + + return item; + } + /** * 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 @@ -284,6 +337,7 @@ class LRU { /** * Sets a value in the cache and returns any evicted item. + * Eviction is silent — onEvict is not fired for the returned item. * * @param {string} key - The key to set. * @param {*} value - The value to store. @@ -293,20 +347,24 @@ class LRU { let evicted = null; let item = this.items[key]; - if (item !== undefined) { + if (item !== undefined && !this.#isExpired(item)) { item.value = value; if (this.resetTTL) { item.expiry = this.ttl > 0 ? Date.now() + this.ttl : this.ttl; } this.moveToEnd(item); } else { + if (item !== undefined) { + this.#removeItem(item); + } + if (this.max > 0 && this.size === this.max) { + const evictedItem = this.#evictItem(); evicted = { - key: this.first.key, - value: this.first.value, - expiry: this.first.expiry, + key: evictedItem.key, + value: evictedItem.value, + expiry: evictedItem.expiry, }; - this.evict(); } item = this.items[key] = { @@ -340,7 +398,7 @@ class LRU { set(key, value) { let item = this.items[key]; - if (item !== undefined) { + if (item !== undefined && !this.#isExpired(item)) { item.value = value; if (this.resetTTL) { @@ -349,6 +407,10 @@ class LRU { this.moveToEnd(item); } else { + if (item !== undefined) { + this.#removeItem(item); + } + if (this.max > 0 && this.size === this.max) { this.evict(); } @@ -385,18 +447,24 @@ class LRU { */ values(keys) { if (keys === undefined) { - const result = Array.from({ length: this.size }); - let i = 0; + const result = []; for (let x = this.first; x !== null; x = x.next) { - result[i++] = x.value; + if (!this.#isExpired(x)) { + result.push(x.value); + } } + return result; } + if (!Array.isArray(keys)) { + throw new TypeError("keys must be an array"); + } + 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; + result[i] = item !== undefined && !this.#isExpired(item) ? item.value : undefined; } return result; @@ -405,15 +473,19 @@ class LRU { /** * 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. + * get() or peek(), so it does not update LRU order. Expired items are skipped. * * @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); + for (let x = this.first; x !== null; ) { + const next = x.next; + if (!this.#isExpired(x)) { + callback.call(thisArg, x.value, x.key, this); + } + x = next; } return this; @@ -426,6 +498,10 @@ class LRU { * @returns {Object} Object mapping keys to values (undefined for missing/expired keys). */ getMany(keys) { + if (!Array.isArray(keys)) { + throw new TypeError("keys must be an array"); + } + const result = Object.create(null); for (let i = 0; i < keys.length; i++) { const key = keys[i]; @@ -442,6 +518,10 @@ class LRU { * @returns {boolean} True if all keys exist and are not expired. */ hasAll(keys) { + if (!Array.isArray(keys)) { + throw new TypeError("keys must be an array"); + } + for (let i = 0; i < keys.length; i++) { if (!this.has(keys[i])) { return false; @@ -458,6 +538,10 @@ class LRU { * @returns {boolean} True if any key exists and is not expired. */ hasAny(keys) { + if (!Array.isArray(keys)) { + throw new TypeError("keys must be an array"); + } + for (let i = 0; i < keys.length; i++) { if (this.has(keys[i])) { return true; @@ -511,11 +595,13 @@ class LRU { toJSON() { const result = []; for (let x = this.first; x !== null; x = x.next) { - result.push({ - key: x.key, - value: x.value, - expiry: x.expiry, - }); + if (!this.#isExpired(x)) { + result.push({ + key: x.key, + value: x.value, + expiry: x.expiry, + }); + } } return result; @@ -559,20 +645,16 @@ class LRU { 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) { + if (x.expiry > now) { valid++; } else { expired++; } } - return { valid, expired, noTTL }; + return { valid, expired, noTTL: 0 }; } /** @@ -588,20 +670,16 @@ class LRU { 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) { + if (x.expiry > now) { valid.push(x.key); } else { expired.push(x.key); } } - return { valid, expired, noTTL }; + return { valid, expired, noTTL: [] }; } /** @@ -610,13 +688,23 @@ class LRU { * @returns {Object} Object with valid, expired, and noTTL arrays of values. */ valuesByTTL() { - const keysByTTL = this.keysByTTL(); + if (this.ttl === 0) { + return { valid: this.values(), expired: [], noTTL: this.values() }; + } - return { - valid: this.values(keysByTTL.valid), - expired: this.values(keysByTTL.expired), - noTTL: this.values(keysByTTL.noTTL), - }; + const now = Date.now(); + const valid = []; + const expired = []; + + for (let x = this.first; x !== null; x = x.next) { + if (x.expiry > now) { + valid.push(x.value); + } else { + expired.push(x.value); + } + } + + return { valid, expired, noTTL: [] }; } /** @@ -664,17 +752,5 @@ class LRU { * @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); }export{LRU,lru}; \ No newline at end of file diff --git a/dist/tiny-lru.min.js b/dist/tiny-lru.min.js index 37eb50a..28d0d11 100644 --- a/dist/tiny-lru.min.js +++ b/dist/tiny-lru.min.js @@ -2,4 +2,4 @@ 2026 Jason Mulligan @version 13.0.0 */ -class t{#t;#s;constructor(t=0,s=0,i=!1){this.first=null,this.items=Object.create(null),this.last=null,this.max=t,this.resetTTL=i,this.size=0,this.ttl=s,this.#t={hits:0,misses:0,sets:0,deletes:0,evictions:0},this.#s=null}clear(){for(let t=this.first;null!==t;){const s=t.next;t.prev=null,t.next=null,t=s}return this.first=null,this.items=Object.create(null),this.last=null,this.size=0,this.#t.hits=0,this.#t.misses=0,this.#t.sets=0,this.#t.deletes=0,this.#t.evictions=0,this}delete(t){const s=this.items[t];return void 0!==s&&(delete this.items[t],this.size--,this.#t.deletes++,this.#i(s),s.prev=null,s.next=null),this}entries(t){void 0===t&&(t=this.keys());const s=Array.from({length:t.length});for(let i=0;i0?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;s0&&this.size===this.max){const t=this.#r();e={key:t.key,value:t.value,expiry:t.expiry}}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}else i.value=s,this.resetTTL&&(i.expiry=this.ttl>0?Date.now()+this.ttl:this.ttl),this.moveToEnd(i);return this.#t.sets++,e}set(t,s){let e=this.items[t];return void 0===e||this.#i(e)?(void 0!==e&&this.#e(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:s},1==++this.size?this.first=e:this.last.next=e,this.last=e):(e.value=s,this.resetTTL&&(e.expiry=this.ttl>0?Date.now()+this.ttl:this.ttl),this.moveToEnd(e)),this.#t.sets++,this}values(t){if(void 0===t){const t=[];for(let s=this.first;null!==s;s=s.next)this.#i(s)||t.push(s.value);return t}if(!Array.isArray(t))throw new TypeError("keys must be an array");const s=Array.from({length:t.length});for(let e=0;e0&&this.#n(),t}toJSON(){const t=[];for(let s=this.first;null!==s;s=s.next)this.#i(s)||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,e=0;for(let i=this.first;null!==i;i=i.next)i.expiry>t?s++:e++;return{valid:s,expired:e,noTTL:0}}keysByTTL(){if(0===this.ttl)return{valid:this.keys(),expired:[],noTTL:this.keys()};const t=Date.now(),s=[],e=[];for(let i=this.first;null!==i;i=i.next)i.expiry>t?s.push(i.key):e.push(i.key);return{valid:s,expired:e,noTTL:[]}}valuesByTTL(){if(0===this.ttl)return{valid:this.values(),expired:[],noTTL:this.values()};const t=Date.now(),s=[],e=[];for(let i=this.first;null!==i;i=i.next)i.expiry>t?s.push(i.value):e.push(i.value);return{valid:s,expired:e,noTTL:[]}}#n(){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\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":";;;;AAOO,MAAMA,EACZC,GACAC,GAWA,WAAAC,CAAYC,EAAM,EAAGC,EAAM,EAAGC,GAAW,GACxCC,KAAKC,MAAQ,KACbD,KAAKE,MAAQC,OAAOC,OAAO,MAC3BJ,KAAKK,KAAO,KACZL,KAAKH,IAAMA,EACXG,KAAKD,SAAWA,EAChBC,KAAKM,KAAO,EACZN,KAAKF,IAAMA,EACXE,MAAKN,EAAS,CAAEa,KAAM,EAAGC,OAAQ,EAAGC,KAAM,EAAGC,QAAS,EAAGC,UAAW,GACpEX,MAAKL,EAAW,IACjB,CAOA,KAAAiB,GACC,IAAK,IAAIC,EAAIb,KAAKC,MAAa,OAANY,GAAc,CACtC,MAAMC,EAAOD,EAAEC,KACfD,EAAEE,KAAO,KACTF,EAAEC,KAAO,KACTD,EAAIC,CACL,CAYA,OAVAd,KAAKC,MAAQ,KACbD,KAAKE,MAAQC,OAAOC,OAAO,MAC3BJ,KAAKK,KAAO,KACZL,KAAKM,KAAO,EACZN,MAAKN,EAAOa,KAAO,EACnBP,MAAKN,EAAOc,OAAS,EACrBR,MAAKN,EAAOe,KAAO,EACnBT,MAAKN,EAAOgB,QAAU,EACtBV,MAAKN,EAAOiB,UAAY,EAEjBX,IACR,CAQA,OAAOgB,GACN,MAAMC,EAAOjB,KAAKE,MAAMc,GAaxB,YAXaE,IAATD,WACIjB,KAAKE,MAAMc,GAClBhB,KAAKM,OACLN,MAAKN,EAAOgB,UAEZV,MAAKmB,EAAQF,GAEbA,EAAKF,KAAO,KACZE,EAAKH,KAAO,MAGNd,IACR,CAUA,OAAAoB,CAAQC,QACMH,IAATG,IACHA,EAAOrB,KAAKqB,QAGb,MAAMC,EAASC,MAAMC,KAAK,CAAEC,OAAQJ,EAAKI,SACzC,IAAK,IAAIC,EAAI,EAAGA,EAAIL,EAAKI,OAAQC,IAAK,CACrC,MAAMV,EAAMK,EAAKK,GACXT,EAAOjB,KAAKE,MAAMc,GACxBM,EAAOI,GAAK,CAACV,OAAcE,IAATD,EAAqBA,EAAKU,WAAQT,EACrD,CAEA,OAAOI,CACR,CAOA,KAAAM,GACC,GAAkB,IAAd5B,KAAKM,KACR,OAAON,KAGR,MAAMiB,EAAOjB,KAAKC,MAsBlB,cApBOD,KAAKE,MAAMe,EAAKD,KACvBhB,MAAKN,EAAOiB,YAEQ,KAAdX,KAAKM,MACVN,KAAKC,MAAQ,KACbD,KAAKK,KAAO,MAEZL,MAAKmB,EAAQF,GAGdA,EAAKF,KAAO,KACZE,EAAKH,KAAO,KACU,OAAlBd,MAAKL,GACRK,MAAKL,EAAS,CACbqB,IAAKC,EAAKD,IACVW,MAAOV,EAAKU,MACZE,OAAQZ,EAAKY,SAIR7B,IACR,CAQA,SAAA8B,CAAUd,GACT,MAAMC,EAAOjB,KAAKE,MAAMc,GACxB,YAAgBE,IAATD,EAAqBA,EAAKY,YAASX,CAC3C,CASA,EAAAa,CAAWd,GACV,OAAiB,IAAbjB,KAAKF,KAA6B,IAAhBmB,EAAKY,QAIpBZ,EAAKY,QAAUG,KAAKC,KAC5B,CASA,IAAAC,CAAKlB,GACJ,MAAMC,EAAOjB,KAAKE,MAAMc,GACxB,YAAgBE,IAATD,EAAqBA,EAAKU,WAAQT,CAC1C,CAQA,GAAAiB,CAAInB,GACH,MAAMC,EAAOjB,KAAKE,MAAMc,GAExB,QAAaE,IAATD,EACH,OAAKjB,MAAK+B,EAAWd,IAMrBjB,KAAKoC,OAAOpB,QACZhB,MAAKN,EAAOc,WANXR,KAAKqC,UAAUpB,GACfjB,MAAKN,EAAOa,OACLU,EAAKU,OAQd3B,MAAKN,EAAOc,QAEb,CAQA,GAAA8B,CAAItB,GACH,MAAMC,EAAOjB,KAAKE,MAAMc,GACxB,YAAgBE,IAATD,IAAuBjB,MAAK+B,EAAWd,EAC/C,CASA,EAAAE,CAAQF,GACW,OAAdA,EAAKF,OACRE,EAAKF,KAAKD,KAAOG,EAAKH,MAGL,OAAdG,EAAKH,OACRG,EAAKH,KAAKC,KAAOE,EAAKF,MAGnBf,KAAKC,QAAUgB,IAClBjB,KAAKC,MAAQgB,EAAKH,MAGfd,KAAKK,OAASY,IACjBjB,KAAKK,KAAOY,EAAKF,KAEnB,CAUA,SAAAsB,CAAUpB,GACLjB,KAAKK,OAASY,IAIlBjB,MAAKmB,EAAQF,GAEbA,EAAKF,KAAOf,KAAKK,KACjBY,EAAKH,KAAO,KACZd,KAAKK,KAAKS,KAAOG,EACjBjB,KAAKK,KAAOY,EACb,CAOA,IAAAI,GACC,MAAMC,EAASC,MAAMC,KAAK,CAAEC,OAAQzB,KAAKM,OACzC,IAAIO,EAAIb,KAAKC,MACTyB,EAAI,EAER,KAAa,OAANb,GACNS,EAAOI,KAAOb,EAAEG,IAChBH,EAAIA,EAAEC,KAGP,OAAOQ,CACR,CASA,cAAAiB,CAAevB,EAAKW,GACnB,IAAIa,EAAU,KACVvB,EAAOjB,KAAKE,MAAMc,GAoCtB,YAlCaE,IAATD,GACHA,EAAKU,MAAQA,EACT3B,KAAKD,WACRkB,EAAKY,OAAS7B,KAAKF,IAAM,EAAIkC,KAAKC,MAAQjC,KAAKF,IAAME,KAAKF,KAE3DE,KAAKqC,UAAUpB,KAEXjB,KAAKH,IAAM,GAAKG,KAAKM,OAASN,KAAKH,MACtC2C,EAAU,CACTxB,IAAKhB,KAAKC,MAAMe,IAChBW,MAAO3B,KAAKC,MAAM0B,MAClBE,OAAQ7B,KAAKC,MAAM4B,QAEpB7B,KAAK4B,SAGNX,EAAOjB,KAAKE,MAAMc,GAAO,CACxBa,OAAQ7B,KAAKF,IAAM,EAAIkC,KAAKC,MAAQjC,KAAKF,IAAME,KAAKF,IACpDkB,IAAKA,EACLD,KAAMf,KAAKK,KACXS,KAAM,KACNa,SAGmB,KAAd3B,KAAKM,KACVN,KAAKC,MAAQgB,EAEbjB,KAAKK,KAAKS,KAAOG,EAGlBjB,KAAKK,KAAOY,GAGbjB,MAAKN,EAAOe,OACL+B,CACR,CASA,GAAAC,CAAIzB,EAAKW,GACR,IAAIV,EAAOjB,KAAKE,MAAMc,GAkCtB,YAhCaE,IAATD,GACHA,EAAKU,MAAQA,EAET3B,KAAKD,WACRkB,EAAKY,OAAS7B,KAAKF,IAAM,EAAIkC,KAAKC,MAAQjC,KAAKF,IAAME,KAAKF,KAG3DE,KAAKqC,UAAUpB,KAEXjB,KAAKH,IAAM,GAAKG,KAAKM,OAASN,KAAKH,KACtCG,KAAK4B,QAGNX,EAAOjB,KAAKE,MAAMc,GAAO,CACxBa,OAAQ7B,KAAKF,IAAM,EAAIkC,KAAKC,MAAQjC,KAAKF,IAAME,KAAKF,IACpDkB,IAAKA,EACLD,KAAMf,KAAKK,KACXS,KAAM,KACNa,SAGmB,KAAd3B,KAAKM,KACVN,KAAKC,MAAQgB,EAEbjB,KAAKK,KAAKS,KAAOG,EAGlBjB,KAAKK,KAAOY,GAGbjB,MAAKN,EAAOe,OAELT,IACR,CAUA,MAAA0C,CAAOrB,GACN,QAAaH,IAATG,EAAoB,CACvB,MAAMC,EAASC,MAAMC,KAAK,CAAEC,OAAQzB,KAAKM,OACzC,IAAIoB,EAAI,EACR,IAAK,IAAIb,EAAIb,KAAKC,MAAa,OAANY,EAAYA,EAAIA,EAAEC,KAC1CQ,EAAOI,KAAOb,EAAEc,MAEjB,OAAOL,CACR,CAEA,MAAMA,EAASC,MAAMC,KAAK,CAAEC,OAAQJ,EAAKI,SACzC,IAAK,IAAIC,EAAI,EAAGA,EAAIL,EAAKI,OAAQC,IAAK,CACrC,MAAMT,EAAOjB,KAAKE,MAAMmB,EAAKK,IAC7BJ,EAAOI,QAAcR,IAATD,EAAqBA,EAAKU,WAAQT,CAC/C,CAEA,OAAOI,CACR,CAWA,OAAAqB,CAAQC,EAAUC,GACjB,IAAK,IAAIhC,EAAIb,KAAKC,MAAa,OAANY,EAAYA,EAAIA,EAAEC,KAC1C8B,EAASE,KAAKD,EAAShC,EAAEc,MAAOd,EAAEG,IAAKhB,MAGxC,OAAOA,IACR,CAQA,OAAA+C,CAAQ1B,GACP,MAAMC,EAASnB,OAAOC,OAAO,MAC7B,IAAK,IAAIsB,EAAI,EAAGA,EAAIL,EAAKI,OAAQC,IAAK,CACrC,MAAMV,EAAMK,EAAKK,GACjBJ,EAAON,GAAOhB,KAAKmC,IAAInB,EACxB,CAEA,OAAOM,CACR,CAQA,MAAA0B,CAAO3B,GACN,IAAK,IAAIK,EAAI,EAAGA,EAAIL,EAAKI,OAAQC,IAChC,IAAK1B,KAAKsC,IAAIjB,EAAKK,IAClB,OAAO,EAIT,OAAO,CACR,CAQA,MAAAuB,CAAO5B,GACN,IAAK,IAAIK,EAAI,EAAGA,EAAIL,EAAKI,OAAQC,IAChC,GAAI1B,KAAKsC,IAAIjB,EAAKK,IACjB,OAAO,EAIT,OAAO,CACR,CAQA,OAAAwB,GACC,GAAiB,IAAblD,KAAKF,KAA2B,IAAdE,KAAKM,KAC1B,OAAO,EAGR,IAAI6C,EAAU,EAEd,IAAK,IAAItC,EAAIb,KAAKC,MAAa,OAANY,GAAc,CACtC,MAAMC,EAAOD,EAAEC,KACf,GAAId,MAAK+B,EAAWlB,GAAI,CACvB,MAAMG,EAAMH,EAAEG,SACUE,IAApBlB,KAAKE,MAAMc,YACPhB,KAAKE,MAAMc,GAClBhB,KAAKM,OACL6C,IACAnD,MAAKmB,EAAQN,GACbA,EAAEE,KAAO,KACTF,EAAEC,KAAO,KAEX,CACAD,EAAIC,CACL,CAMA,OAJIqC,EAAU,GACbnD,MAAKoD,IAGCD,CACR,CAOA,MAAAE,GACC,MAAM/B,EAAS,GACf,IAAK,IAAIT,EAAIb,KAAKC,MAAa,OAANY,EAAYA,EAAIA,EAAEC,KAC1CQ,EAAOgC,KAAK,CACXtC,IAAKH,EAAEG,IACPW,MAAOd,EAAEc,MACTE,OAAQhB,EAAEgB,SAIZ,OAAOP,CACR,CAOA,KAAA5B,GACC,MAAO,IAAKM,MAAKN,EAClB,CAQA,OAAAC,CAAQiD,GACP,GAAwB,mBAAbA,EACV,MAAM,IAAIW,UAAU,uCAKrB,OAFAvD,MAAKL,EAAWiD,EAET5C,IACR,CAOA,SAAAwD,GACC,GAAiB,IAAbxD,KAAKF,IACR,MAAO,CAAE2D,MAAOzD,KAAKM,KAAMoD,QAAS,EAAGC,MAAO3D,KAAKM,MAGpD,MAAM2B,EAAMD,KAAKC,MACjB,IAAIwB,EAAQ,EACRC,EAAU,EACVC,EAAQ,EAEZ,IAAK,IAAI9C,EAAIb,KAAKC,MAAa,OAANY,EAAYA,EAAIA,EAAEC,KACzB,IAAbD,EAAEgB,QACL8B,IACAF,KACU5C,EAAEgB,OAASI,EACrBwB,IAEAC,IAIF,MAAO,CAAED,QAAOC,UAASC,QAC1B,CAOA,SAAAC,GACC,GAAiB,IAAb5D,KAAKF,IACR,MAAO,CAAE2D,MAAOzD,KAAKqB,OAAQqC,QAAS,GAAIC,MAAO3D,KAAKqB,QAGvD,MAAMY,EAAMD,KAAKC,MACXwB,EAAQ,GACRC,EAAU,GACVC,EAAQ,GAEd,IAAK,IAAI9C,EAAIb,KAAKC,MAAa,OAANY,EAAYA,EAAIA,EAAEC,KACzB,IAAbD,EAAEgB,QACL4B,EAAMH,KAAKzC,EAAEG,KACb2C,EAAML,KAAKzC,EAAEG,MACHH,EAAEgB,OAASI,EACrBwB,EAAMH,KAAKzC,EAAEG,KAEb0C,EAAQJ,KAAKzC,EAAEG,KAIjB,MAAO,CAAEyC,QAAOC,UAASC,QAC1B,CAOA,WAAAE,GACC,MAAMD,EAAY5D,KAAK4D,YAEvB,MAAO,CACNH,MAAOzD,KAAK0C,OAAOkB,EAAUH,OAC7BC,QAAS1D,KAAK0C,OAAOkB,EAAUF,SAC/BC,MAAO3D,KAAK0C,OAAOkB,EAAUD,OAE/B,CAQA,EAAAP,GACC,GAAkB,IAAdpD,KAAKM,KAGR,OAFAN,KAAKC,MAAQ,UACbD,KAAKK,KAAO,MAIb,MAAMgB,EAAOrB,KAAKqB,OAClBrB,KAAKC,MAAQ,KACbD,KAAKK,KAAO,KAEZ,IAAK,IAAIqB,EAAI,EAAGA,EAAIL,EAAKI,OAAQC,IAAK,CACrC,MAAMT,EAAOjB,KAAKE,MAAMmB,EAAKK,IACzBT,UACgB,OAAfjB,KAAKC,OACRD,KAAKC,MAAQgB,EACbA,EAAKF,KAAO,OAEZE,EAAKF,KAAOf,KAAKK,KACjBL,KAAKK,KAAKS,KAAOG,GAElBA,EAAKH,KAAO,KACZd,KAAKK,KAAOY,EAEd,CACD,EAaM,SAAS6C,EAAIjE,EAAM,IAAMC,EAAM,EAAGC,GAAW,GACnD,GAAIgE,MAAMlE,IAAQA,EAAM,EACvB,MAAM,IAAI0D,UAAU,qBAGrB,GAAIQ,MAAMjE,IAAQA,EAAM,EACvB,MAAM,IAAIyD,UAAU,qBAGrB,GAAwB,kBAAbxD,EACV,MAAM,IAAIwD,UAAU,0BAGrB,OAAO,IAAI9D,EAAII,EAAKC,EAAKC,EAC1B,QAAAN,SAAAqE"} \ 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 *\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 * @throws {TypeError} When parameters are invalid (negative numbers or wrong types).\n\t */\n\tconstructor(max = 0, ttl = 0, resetTTL = false) {\n\t\tif (!Number.isInteger(max) || max < 0) {\n\t\t\tthrow new TypeError(\"Invalid max value\");\n\t\t}\n\n\t\tif (!Number.isInteger(ttl) || ttl < 0) {\n\t\t\tthrow new TypeError(\"Invalid ttl value\");\n\t\t}\n\n\t\tif (typeof resetTTL !== \"boolean\") {\n\t\t\tthrow new TypeError(\"Invalid resetTTL value\");\n\t\t}\n\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\tthis.#removeItem(item);\n\t\t\tthis.#stats.deletes++;\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\tconst result = [];\n\t\t\tfor (let x = this.first; x !== null; x = x.next) {\n\t\t\t\tif (!this.#isExpired(x)) {\n\t\t\t\t\tresult.push([x.key, x.value]);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn result;\n\t\t}\n\n\t\tif (!Array.isArray(keys)) {\n\t\t\tthrow new TypeError(\"keys must be an array\");\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 && !this.#isExpired(item) ? 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.#evictItem();\n\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) {\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.#removeItem(item);\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 * Expired items are removed before returning false.\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\n\t\tif (item !== undefined && this.#isExpired(item)) {\n\t\t\tthis.#removeItem(item);\n\t\t\treturn false;\n\t\t}\n\n\t\treturn item !== undefined;\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 * Removes an item from the cache without incrementing the deletes stat.\n\t * Used internally by get()/has() when removing expired items.\n\t *\n\t * @param {Object} item - The cache item to remove.\n\t * @private\n\t */\n\t#removeItem(item) {\n\t\tdelete this.items[item.key];\n\t\tthis.size--;\n\t\tthis.#unlink(item);\n\t\titem.prev = null;\n\t\titem.next = null;\n\t}\n\n\t/**\n\t * Evicts the least recently used item from the cache without firing onEvict.\n\t * Used internally by setWithEvicted() to avoid double-notification.\n\t *\n\t * @returns {Object} The evicted item.\n\t * @private\n\t */\n\t#evictItem() {\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\n\t\treturn item;\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 * Eviction is silent — onEvict is not fired for the returned 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 && !this.#isExpired(item)) {\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 (item !== undefined) {\n\t\t\t\tthis.#removeItem(item);\n\t\t\t}\n\n\t\t\tif (this.max > 0 && this.size === this.max) {\n\t\t\t\tconst evictedItem = this.#evictItem();\n\t\t\t\tevicted = {\n\t\t\t\t\tkey: evictedItem.key,\n\t\t\t\t\tvalue: evictedItem.value,\n\t\t\t\t\texpiry: evictedItem.expiry,\n\t\t\t\t};\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 && !this.#isExpired(item)) {\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 (item !== undefined) {\n\t\t\t\tthis.#removeItem(item);\n\t\t\t}\n\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 = [];\n\t\t\tfor (let x = this.first; x !== null; x = x.next) {\n\t\t\t\tif (!this.#isExpired(x)) {\n\t\t\t\t\tresult.push(x.value);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn result;\n\t\t}\n\n\t\tif (!Array.isArray(keys)) {\n\t\t\tthrow new TypeError(\"keys must be an array\");\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 && !this.#isExpired(item) ? 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. Expired items are skipped.\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; ) {\n\t\t\tconst next = x.next;\n\t\t\tif (!this.#isExpired(x)) {\n\t\t\t\tcallback.call(thisArg, x.value, x.key, this);\n\t\t\t}\n\t\t\tx = next;\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\tif (!Array.isArray(keys)) {\n\t\t\tthrow new TypeError(\"keys must be an array\");\n\t\t}\n\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\tif (!Array.isArray(keys)) {\n\t\t\tthrow new TypeError(\"keys must be an array\");\n\t\t}\n\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\tif (!Array.isArray(keys)) {\n\t\t\tthrow new TypeError(\"keys must be an array\");\n\t\t}\n\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\tif (!this.#isExpired(x)) {\n\t\t\t\tresult.push({\n\t\t\t\t\tkey: x.key,\n\t\t\t\t\tvalue: x.value,\n\t\t\t\t\texpiry: x.expiry,\n\t\t\t\t});\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\n\t\tfor (let x = this.first; x !== null; x = x.next) {\n\t\t\tif (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: 0 };\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\n\t\tfor (let x = this.first; x !== null; x = x.next) {\n\t\t\tif (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\tif (this.ttl === 0) {\n\t\t\treturn { valid: this.values(), expired: [], noTTL: this.values() };\n\t\t}\n\n\t\tconst now = Date.now();\n\t\tconst valid = [];\n\t\tconst expired = [];\n\n\t\tfor (let x = this.first; x !== null; x = x.next) {\n\t\t\tif (x.expiry > now) {\n\t\t\t\tvalid.push(x.value);\n\t\t\t} else {\n\t\t\t\texpired.push(x.value);\n\t\t\t}\n\t\t}\n\n\t\treturn { valid, expired, noTTL: [] };\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\treturn new LRU(max, ttl, resetTTL);\n}\n"],"names":["LRU","stats","onEvict","constructor","max","ttl","resetTTL","Number","isInteger","TypeError","this","first","items","Object","create","last","size","hits","misses","sets","deletes","evictions","clear","x","next","prev","key","item","undefined","removeItem","entries","keys","result","isExpired","push","value","Array","isArray","from","length","i","evict","evictItem","expiry","expiresAt","Date","now","peek","get","moveToEnd","has","unlink","setWithEvicted","evicted","evictedItem","set","values","forEach","callback","thisArg","call","getMany","hasAll","hasAny","cleanup","removed","rebuildList","toJSON","sizeByTTL","valid","expired","noTTL","keysByTTL","valuesByTTL","lru"],"mappings":"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"} \ No newline at end of file