| author | |
| committer | |
| log | dd3babdcad749bd142a755fbc26c39085c85bc1c |
| tree | 93428fd6f911ccc41ca3cd1bf860ad36bc53c670 |
| parent | 7aa717c5c21e74607b4f5e796491af417dc90ccf |
- add reusable `LruCache` utility with bounded eviction
- replace keybinding AST cache clear-all behavior with true LRU lookups
- add unit tests for eviction, recency updates, and capacity validation3 files changed, 81 insertions(+), 10 deletions(-)
apps/web/src/keybindings.ts+10-10| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts"; | 1 | import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts"; |
| 2 | import { LruCache } from "./lib/lru"; | ||
| 2 | 3 | ||
| 3 | export interface ShortcutEventLike { | 4 | export interface ShortcutEventLike { |
| 4 | key: string; | 5 | key: string; |
| ... | @@ -44,8 +45,7 @@ type WhenToken = | ... | @@ -44,8 +45,7 @@ type WhenToken = |
| 44 | | { type: "lparen" } | 45 | | { type: "lparen" } |
| 45 | | { type: "rparen" }; | 46 | | { type: "rparen" }; |
| 46 | 47 | ||
| 47 | const WHEN_AST_CACHE = new Map<string, WhenNode | null>(); | 48 | const WHEN_AST_CACHE = new LruCache<string, WhenNode | null>(512); |
| 48 | const MAX_WHEN_AST_CACHE_ENTRIES = 512; | ||
| 49 | 49 | ||
| 50 | function isMacPlatform(platform: string): boolean { | 50 | function isMacPlatform(platform: string): boolean { |
| 51 | return /mac|iphone|ipad|ipod/i.test(platform); | 51 | return /mac|iphone|ipad|ipod/i.test(platform); |
| ... | @@ -290,15 +290,15 @@ function matchesWhenExpression(when: string | undefined, context: ShortcutMatchC | ... | @@ -290,15 +290,15 @@ function matchesWhenExpression(when: string | undefined, context: ShortcutMatchC |
| 290 | const normalized = when.trim(); | 290 | const normalized = when.trim(); |
| 291 | if (normalized.length === 0) return true; | 291 | if (normalized.length === 0) return true; |
| 292 | 292 | ||
| 293 | if (!WHEN_AST_CACHE.has(normalized)) { | 293 | const cached = WHEN_AST_CACHE.get(normalized); |
| 294 | if (WHEN_AST_CACHE.size >= MAX_WHEN_AST_CACHE_ENTRIES) { | 294 | if (cached === undefined) { |
| 295 | WHEN_AST_CACHE.clear(); | 295 | const parsed = parseWhenExpression(normalized); |
| 296 | } | 296 | WHEN_AST_CACHE.set(normalized, parsed); |
| 297 | WHEN_AST_CACHE.set(normalized, parseWhenExpression(normalized)); | 297 | if (!parsed) return false; |
| 298 | return evaluateWhenNode(parsed, context); | ||
| 298 | } | 299 | } |
| 299 | const ast = WHEN_AST_CACHE.get(normalized); | 300 | if (!cached) return false; |
| 300 | if (!ast) return false; | 301 | return evaluateWhenNode(cached, context); |
| 301 | return evaluateWhenNode(ast, context); | ||
| 302 | } | 302 | } |
| 303 | 303 | ||
| 304 | function resolvePlatform(options: ShortcutMatchOptions | undefined): string { | 304 | function resolvePlatform(options: ShortcutMatchOptions | undefined): string { |
apps/web/src/lib/lru.test.ts created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | import { assert, describe, it } from "vitest"; | ||
| 2 | import { LruCache } from "./lru"; | ||
| 3 | |||
| 4 | describe("LruCache", () => { | ||
| 5 | it("evicts the least recently used entry", () => { | ||
| 6 | const cache = new LruCache<string, number>(2); | ||
| 7 | cache.set("a", 1); | ||
| 8 | cache.set("b", 2); | ||
| 9 | assert.strictEqual(cache.get("a"), 1); | ||
| 10 | |||
| 11 | cache.set("c", 3); | ||
| 12 | |||
| 13 | assert.strictEqual(cache.get("b"), undefined); | ||
| 14 | assert.strictEqual(cache.get("a"), 1); | ||
| 15 | assert.strictEqual(cache.get("c"), 3); | ||
| 16 | }); | ||
| 17 | |||
| 18 | it("refreshes recency on update", () => { | ||
| 19 | const cache = new LruCache<string, number>(2); | ||
| 20 | cache.set("a", 1); | ||
| 21 | cache.set("b", 2); | ||
| 22 | cache.set("a", 10); | ||
| 23 | cache.set("c", 3); | ||
| 24 | |||
| 25 | assert.strictEqual(cache.get("b"), undefined); | ||
| 26 | assert.strictEqual(cache.get("a"), 10); | ||
| 27 | assert.strictEqual(cache.get("c"), 3); | ||
| 28 | }); | ||
| 29 | |||
| 30 | it("rejects non-positive capacities", () => { | ||
| 31 | assert.throws(() => new LruCache<string, number>(0), "positive integer"); | ||
| 32 | }); | ||
| 33 | }); | ||
apps/web/src/lib/lru.ts created+38| ... | @@ -0,0 +1,38 @@ | ||
| 1 | export class LruCache<K, V> { | ||
| 2 | private readonly entries = new Map<K, V>(); | ||
| 3 | |||
| 4 | constructor(private readonly maxEntries: number) { | ||
| 5 | if (!Number.isInteger(maxEntries) || maxEntries <= 0) { | ||
| 6 | throw new Error("LruCache maxEntries must be a positive integer."); | ||
| 7 | } | ||
| 8 | } | ||
| 9 | |||
| 10 | get size(): number { | ||
| 11 | return this.entries.size; | ||
| 12 | } | ||
| 13 | |||
| 14 | get(key: K): V | undefined { | ||
| 15 | const value = this.entries.get(key); | ||
| 16 | if (value === undefined && !this.entries.has(key)) { | ||
| 17 | return undefined; | ||
| 18 | } | ||
| 19 | |||
| 20 | // Refresh recency on successful lookup. | ||
| 21 | this.entries.delete(key); | ||
| 22 | this.entries.set(key, value as V); | ||
| 23 | return value; | ||
| 24 | } | ||
| 25 | |||
| 26 | set(key: K, value: V): void { | ||
| 27 | if (this.entries.has(key)) { | ||
| 28 | this.entries.delete(key); | ||
| 29 | } | ||
| 30 | this.entries.set(key, value); | ||
| 31 | |||
| 32 | while (this.entries.size > this.maxEntries) { | ||
| 33 | const oldest = this.entries.keys().next().value as K | undefined; | ||
| 34 | if (oldest === undefined) break; | ||
| 35 | this.entries.delete(oldest); | ||
| 36 | } | ||
| 37 | } | ||
| 38 | } | ||