@mandujs/core 0.24.0 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Phase 17 — LRUCache behaviour matrix.
3
+ *
4
+ * Covers:
5
+ * - Default + custom maxSize
6
+ * - Eviction order (LRU semantics, not FIFO)
7
+ * - `get` promotes the entry, `has` does not
8
+ * - `onEvict` fires for LRU pressure / delete / clear
9
+ * - Backward-compat numeric constructor
10
+ * - Stats bookkeeping
11
+ */
12
+
13
+ import { describe, test, expect } from "bun:test";
14
+ import { LRUCache } from "../lru-cache";
15
+
16
+ describe("LRUCache", () => {
17
+ test("default maxSize is 1000 when no options passed", () => {
18
+ const cache = new LRUCache<string, number>();
19
+ // Insert 1000 — all fit. The 1001st evicts the first.
20
+ for (let i = 0; i < 1000; i++) cache.set(`k${i}`, i);
21
+ expect(cache.size).toBe(1000);
22
+ expect(cache.has("k0")).toBe(true);
23
+
24
+ cache.set("k1000", 1000);
25
+ expect(cache.size).toBe(1000);
26
+ expect(cache.has("k0")).toBe(false);
27
+ expect(cache.has("k1000")).toBe(true);
28
+ });
29
+
30
+ test("numeric constructor is still supported (backward-compat)", () => {
31
+ const cache = new LRUCache<string, string>(3);
32
+ cache.set("a", "1");
33
+ cache.set("b", "2");
34
+ cache.set("c", "3");
35
+ cache.set("d", "4"); // evicts "a"
36
+ expect(cache.has("a")).toBe(false);
37
+ expect(cache.size).toBe(3);
38
+ });
39
+
40
+ test("rejects invalid maxSize", () => {
41
+ expect(() => new LRUCache({ maxSize: 0 })).toThrow();
42
+ expect(() => new LRUCache({ maxSize: -1 })).toThrow();
43
+ expect(() => new LRUCache({ maxSize: Number.NaN })).toThrow();
44
+ });
45
+
46
+ test("evicts least-recently-used entry, not FIFO", () => {
47
+ const cache = new LRUCache<string, number>({ maxSize: 3 });
48
+ cache.set("a", 1);
49
+ cache.set("b", 2);
50
+ cache.set("c", 3);
51
+ // Touch "a" so it becomes most-recently-used.
52
+ cache.get("a");
53
+ // Now inserting "d" must evict "b" (oldest), not "a".
54
+ cache.set("d", 4);
55
+ expect(cache.has("a")).toBe(true);
56
+ expect(cache.has("b")).toBe(false);
57
+ expect(cache.has("c")).toBe(true);
58
+ expect(cache.has("d")).toBe(true);
59
+ });
60
+
61
+ test("has() does NOT promote (read-only probe)", () => {
62
+ const cache = new LRUCache<string, number>({ maxSize: 3 });
63
+ cache.set("a", 1);
64
+ cache.set("b", 2);
65
+ cache.set("c", 3);
66
+ // `has` on "a" must not reorder. Inserting "d" should still evict "a".
67
+ expect(cache.has("a")).toBe(true);
68
+ cache.set("d", 4);
69
+ expect(cache.has("a")).toBe(false);
70
+ expect(cache.has("b")).toBe(true);
71
+ });
72
+
73
+ test("updating an existing key re-inserts as MRU but does not fire onEvict", () => {
74
+ const evicted: Array<[string, number]> = [];
75
+ const cache = new LRUCache<string, number>({
76
+ maxSize: 3,
77
+ onEvict: (k, v) => evicted.push([k, v]),
78
+ });
79
+ cache.set("a", 1);
80
+ cache.set("b", 2);
81
+ cache.set("c", 3);
82
+ cache.set("a", 10); // update, a becomes MRU
83
+ expect(evicted).toHaveLength(0);
84
+ cache.set("d", 4); // should evict "b" (now oldest), not "a"
85
+ expect(evicted).toEqual([["b", 2]]);
86
+ expect(cache.get("a")).toBe(10);
87
+ });
88
+
89
+ test("onEvict fires on LRU pressure with (key, value)", () => {
90
+ const evicted: Array<[string, number]> = [];
91
+ const cache = new LRUCache<string, number>({
92
+ maxSize: 2,
93
+ onEvict: (k, v) => evicted.push([k, v]),
94
+ });
95
+ cache.set("a", 1);
96
+ cache.set("b", 2);
97
+ cache.set("c", 3); // evicts "a"
98
+ cache.set("d", 4); // evicts "b"
99
+ expect(evicted).toEqual([
100
+ ["a", 1],
101
+ ["b", 2],
102
+ ]);
103
+ });
104
+
105
+ test("onEvict fires on delete() for present keys only", () => {
106
+ const evicted: Array<[string, number]> = [];
107
+ const cache = new LRUCache<string, number>({
108
+ maxSize: 5,
109
+ onEvict: (k, v) => evicted.push([k, v]),
110
+ });
111
+ cache.set("x", 1);
112
+ expect(cache.delete("x")).toBe(true);
113
+ expect(cache.delete("x")).toBe(false); // already gone
114
+ expect(cache.delete("missing")).toBe(false);
115
+ expect(evicted).toEqual([["x", 1]]);
116
+ });
117
+
118
+ test("onEvict fires for every entry during clear()", () => {
119
+ const evicted: string[] = [];
120
+ const cache = new LRUCache<string, number>({
121
+ maxSize: 10,
122
+ onEvict: (k) => evicted.push(k),
123
+ });
124
+ cache.set("a", 1);
125
+ cache.set("b", 2);
126
+ cache.set("c", 3);
127
+ cache.clear();
128
+ expect(evicted.sort()).toEqual(["a", "b", "c"]);
129
+ expect(cache.size).toBe(0);
130
+ });
131
+
132
+ test("onEvict errors are swallowed (must not corrupt state)", () => {
133
+ const cache = new LRUCache<string, number>({
134
+ maxSize: 2,
135
+ onEvict: () => {
136
+ throw new Error("boom");
137
+ },
138
+ });
139
+ cache.set("a", 1);
140
+ cache.set("b", 2);
141
+ // Would throw during eviction if errors weren't caught.
142
+ expect(() => cache.set("c", 3)).not.toThrow();
143
+ expect(cache.size).toBe(2);
144
+ expect(cache.has("a")).toBe(false);
145
+ });
146
+
147
+ test("getWithStats records hits/misses; getStats reports hit rate", () => {
148
+ const cache = new LRUCache<string, number>({ maxSize: 10 });
149
+ cache.set("a", 1);
150
+ cache.getWithStats("a"); // hit
151
+ cache.getWithStats("a"); // hit
152
+ cache.getWithStats("b"); // miss
153
+ const stats = cache.getStats();
154
+ expect(stats.hits).toBe(2);
155
+ expect(stats.misses).toBe(1);
156
+ expect(stats.hitRate).toBeCloseTo(2 / 3);
157
+ expect(stats.size).toBe(1);
158
+ expect(stats.maxSize).toBe(10);
159
+ cache.resetStats();
160
+ expect(cache.getStats().hits).toBe(0);
161
+ expect(cache.getStats().misses).toBe(0);
162
+ });
163
+
164
+ test("entries() yields LRU → MRU order", () => {
165
+ const cache = new LRUCache<string, number>({ maxSize: 5 });
166
+ cache.set("a", 1);
167
+ cache.set("b", 2);
168
+ cache.set("c", 3);
169
+ cache.get("a"); // a becomes MRU
170
+ const keys = [...cache.entries()].map(([k]) => k);
171
+ expect(keys).toEqual(["b", "c", "a"]);
172
+ });
173
+
174
+ test("fill-past-max proof: 1001 writes against a 1000-entry cache evict exactly the oldest", () => {
175
+ const cache = new LRUCache<number, number>({ maxSize: 1000 });
176
+ for (let i = 0; i < 1000; i++) cache.set(i, i);
177
+ expect(cache.size).toBe(1000);
178
+ expect(cache.has(0)).toBe(true);
179
+
180
+ cache.set(1000, 1000);
181
+ expect(cache.size).toBe(1000); // still bounded
182
+ expect(cache.has(0)).toBe(false); // oldest gone
183
+ expect(cache.has(1000)).toBe(true); // newest present
184
+ expect(cache.has(500)).toBe(true); // middle survives
185
+ });
186
+ });
@@ -1,75 +1,172 @@
1
- /**
2
- * 간단한 LRU (Least Recently Used) 캐시 구현
3
- */
4
- export class LRUCache<K, V> {
5
- private cache: Map<K, V>;
6
- private readonly maxSize: number;
7
- private _hits = 0;
8
- private _misses = 0;
9
-
10
- constructor(maxSize: number = 100) {
11
- this.cache = new Map();
12
- this.maxSize = maxSize;
13
- }
14
-
15
- get(key: K): V | undefined {
16
- const value = this.cache.get(key);
17
- if (value !== undefined) {
18
- this.cache.delete(key);
19
- this.cache.set(key, value);
20
- }
21
- return value;
22
- }
23
-
24
- getWithStats(key: K): V | undefined {
25
- const value = this.get(key);
26
- if (value !== undefined) {
27
- this._hits++;
28
- } else {
29
- this._misses++;
30
- }
31
- return value;
32
- }
33
-
34
- set(key: K, value: V): void {
35
- if (this.cache.has(key)) {
36
- this.cache.delete(key);
37
- } else if (this.cache.size >= this.maxSize) {
38
- const firstKey = this.cache.keys().next().value;
39
- if (firstKey !== undefined) {
40
- this.cache.delete(firstKey);
41
- }
42
- }
43
- this.cache.set(key, value);
44
- }
45
-
46
- has(key: K): boolean {
47
- return this.cache.has(key);
48
- }
49
-
50
- delete(key: K): boolean {
51
- return this.cache.delete(key);
52
- }
53
-
54
- clear(): void {
55
- this.cache.clear();
56
- }
57
-
58
- get size(): number {
59
- return this.cache.size;
60
- }
61
-
62
- getStats(): { hits: number; misses: number; hitRate: number } {
63
- const total = this._hits + this._misses;
64
- return {
65
- hits: this._hits,
66
- misses: this._misses,
67
- hitRate: total > 0 ? this._hits / total : 0,
68
- };
69
- }
70
-
71
- resetStats(): void {
72
- this._hits = 0;
73
- this._misses = 0;
74
- }
75
- }
1
+ /**
2
+ * 간단한 LRU (Least Recently Used) 캐시 구현.
3
+ *
4
+ * Phase 17 — bounded caches for memory safety. Used by:
5
+ * - `client/router.ts` patternCache (compiled route regex cache)
6
+ * - `client/use-fetch.ts` fetchCache (browser data cache)
7
+ * - `bundler/dev.ts` perFileTimers (debounce map)
8
+ *
9
+ * O(1) get / set on top of `Map` insertion order:
10
+ * - `get` re-inserts the entry so it becomes the newest
11
+ * - `set` evicts the first (oldest) key when over `maxSize`
12
+ *
13
+ * Optional `onEvict` callback fires when an entry is dropped through LRU
14
+ * pressure OR explicit `delete`/`clear` — lets callers release resources
15
+ * attached to the value (e.g. `clearTimeout(timer)` for debounce maps).
16
+ */
17
+ export interface LRUCacheOptions<K, V> {
18
+ /** Maximum number of entries. Defaults to `1000`. */
19
+ maxSize?: number;
20
+ /**
21
+ * Fired when an entry leaves the cache. Called for LRU eviction,
22
+ * `delete(key)`, and each entry during `clear()`. Errors are swallowed
23
+ * so a bad callback cannot corrupt cache state.
24
+ */
25
+ onEvict?: (key: K, value: V) => void;
26
+ }
27
+
28
+ export class LRUCache<K, V> {
29
+ private cache: Map<K, V>;
30
+ private readonly maxSize: number;
31
+ private readonly onEvict?: (key: K, value: V) => void;
32
+ private _hits = 0;
33
+ private _misses = 0;
34
+
35
+ /**
36
+ * Dual constructor signature for backward compatibility with the
37
+ * original `new LRUCache(200)` call sites:
38
+ *
39
+ * - `new LRUCache(maxSize)` legacy numeric form
40
+ * - `new LRUCache({ maxSize, onEvict })` — options form
41
+ */
42
+ constructor(options?: number | LRUCacheOptions<K, V>) {
43
+ this.cache = new Map();
44
+ if (typeof options === "number") {
45
+ this.maxSize = options;
46
+ } else {
47
+ this.maxSize = options?.maxSize ?? 1000;
48
+ this.onEvict = options?.onEvict;
49
+ }
50
+ if (!Number.isFinite(this.maxSize) || this.maxSize < 1) {
51
+ throw new Error(`LRUCache: maxSize must be >= 1 (got ${this.maxSize})`);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Retrieve a value AND promote it to the most-recently-used position.
57
+ * Returns `undefined` for misses. `has()` does NOT promote.
58
+ */
59
+ get(key: K): V | undefined {
60
+ const value = this.cache.get(key);
61
+ if (value !== undefined) {
62
+ // Re-insert to mark as most-recently-used.
63
+ this.cache.delete(key);
64
+ this.cache.set(key, value);
65
+ }
66
+ return value;
67
+ }
68
+
69
+ /**
70
+ * Same as `get` but also increments hit/miss counters.
71
+ * Use `getStats()` to read the tallies.
72
+ */
73
+ getWithStats(key: K): V | undefined {
74
+ const value = this.get(key);
75
+ if (value !== undefined) {
76
+ this._hits++;
77
+ } else {
78
+ this._misses++;
79
+ }
80
+ return value;
81
+ }
82
+
83
+ /**
84
+ * Insert / update an entry. If at capacity, evicts the oldest entry
85
+ * and fires `onEvict` for it.
86
+ */
87
+ set(key: K, value: V): void {
88
+ if (this.cache.has(key)) {
89
+ // Update in place → delete then re-insert so the new value is
90
+ // positioned at the MRU end. No eviction callback here (same key).
91
+ this.cache.delete(key);
92
+ } else if (this.cache.size >= this.maxSize) {
93
+ // At capacity → drop the oldest.
94
+ const firstKey = this.cache.keys().next().value;
95
+ if (firstKey !== undefined) {
96
+ const oldValue = this.cache.get(firstKey) as V;
97
+ this.cache.delete(firstKey);
98
+ this.fireEvict(firstKey, oldValue);
99
+ }
100
+ }
101
+ this.cache.set(key, value);
102
+ }
103
+
104
+ has(key: K): boolean {
105
+ return this.cache.has(key);
106
+ }
107
+
108
+ /**
109
+ * Explicit removal. Fires `onEvict` for the removed entry. Returns
110
+ * `true` iff the key was present.
111
+ */
112
+ delete(key: K): boolean {
113
+ const value = this.cache.get(key);
114
+ const existed = this.cache.delete(key);
115
+ if (existed) {
116
+ this.fireEvict(key, value as V);
117
+ }
118
+ return existed;
119
+ }
120
+
121
+ /**
122
+ * Drop every entry. Fires `onEvict` for each before clearing so
123
+ * callers can bulk-release resources (e.g. clear every timer).
124
+ */
125
+ clear(): void {
126
+ if (this.onEvict) {
127
+ // Iterate BEFORE clearing so the callback sees a valid entry.
128
+ for (const [k, v] of this.cache) {
129
+ this.fireEvict(k, v);
130
+ }
131
+ }
132
+ this.cache.clear();
133
+ }
134
+
135
+ get size(): number {
136
+ return this.cache.size;
137
+ }
138
+
139
+ /**
140
+ * Read-only iteration for introspection (metrics, debug dumps).
141
+ * Yields entries in LRU → MRU order.
142
+ */
143
+ *entries(): IterableIterator<[K, V]> {
144
+ yield* this.cache.entries();
145
+ }
146
+
147
+ getStats(): { hits: number; misses: number; hitRate: number; size: number; maxSize: number } {
148
+ const total = this._hits + this._misses;
149
+ return {
150
+ hits: this._hits,
151
+ misses: this._misses,
152
+ hitRate: total > 0 ? this._hits / total : 0,
153
+ size: this.cache.size,
154
+ maxSize: this.maxSize,
155
+ };
156
+ }
157
+
158
+ resetStats(): void {
159
+ this._hits = 0;
160
+ this._misses = 0;
161
+ }
162
+
163
+ private fireEvict(key: K, value: V): void {
164
+ if (!this.onEvict) return;
165
+ try {
166
+ this.onEvict(key, value);
167
+ } catch {
168
+ // Swallow — a bad callback must not corrupt cache state. Users
169
+ // who need error visibility can wrap their own callback.
170
+ }
171
+ }
172
+ }