@boredland/node-ts-cache 1.0.0 → 1.0.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.
@@ -1,613 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
- import { CacheContainer } from "./cacheContainer.ts";
3
- import { LRUStorage } from "./lruStorage.ts";
4
- import { withCacheFactory } from "./withCache.ts";
5
-
6
- describe("LRUStorage with withCache", () => {
7
- let storage: LRUStorage;
8
- let container: CacheContainer;
9
- let withCache: ReturnType<typeof withCacheFactory>;
10
-
11
- beforeEach(() => {
12
- storage = new LRUStorage({ max: 10 });
13
- container = new CacheContainer(storage);
14
- withCache = withCacheFactory(container);
15
- });
16
-
17
- describe("Basic caching functionality", () => {
18
- it("should cache function results", async () => {
19
- const mockFn = vi.fn(async (x: number) => x * 2);
20
- const cachedFn = withCache(mockFn);
21
-
22
- const result1 = await cachedFn(5);
23
- const result2 = await cachedFn(5);
24
-
25
- expect(result1).toBe(10);
26
- expect(result2).toBe(10);
27
- expect(mockFn).toHaveResolvedTimes(1);
28
- });
29
-
30
- it("should differentiate between different parameters", async () => {
31
- const mockFn = vi.fn(async (x: number) => x * 2);
32
- const cachedFn = withCache(mockFn);
33
-
34
- const result1 = await cachedFn(5);
35
- const result2 = await cachedFn(10);
36
-
37
- expect(result1).toBe(10);
38
- expect(result2).toBe(20);
39
- expect(mockFn).toHaveResolvedTimes(2);
40
- });
41
-
42
- it("should use custom calculateKey function", async () => {
43
- const mockFn = vi.fn(async (x: number, y: number) => x + y);
44
- const cachedFn = withCache(mockFn, {
45
- calculateKey: ([x, y]) => `${x}-${y}`,
46
- });
47
-
48
- const result1 = await cachedFn(1, 2);
49
- const result2 = await cachedFn(1, 2);
50
-
51
- expect(result1).toBe(3);
52
- expect(result2).toBe(3);
53
- expect(mockFn).toHaveResolvedTimes(1);
54
- });
55
-
56
- it("should support prefix option", async () => {
57
- const mockFn = vi.fn(async (x: number) => x * 2);
58
- const cachedFn1 = withCache(mockFn, { prefix: "version1" });
59
- const cachedFn2 = withCache(mockFn, { prefix: "version2" });
60
-
61
- const result1 = await cachedFn1(5);
62
- const result2 = await cachedFn2(5);
63
-
64
- expect(result1).toBe(10);
65
- expect(result2).toBe(10);
66
- expect(mockFn).toHaveResolvedTimes(2);
67
- });
68
-
69
- it("should work with multiple parameters", async () => {
70
- const mockFn = vi.fn(async (a: number, b: string) => `${a}-${b}`);
71
- const cachedFn = withCache(mockFn);
72
-
73
- const result1 = await cachedFn(1, "a");
74
- const result2 = await cachedFn(1, "a");
75
-
76
- expect(result1).toBe("1-a");
77
- expect(result2).toBe("1-a");
78
- expect(mockFn).toHaveResolvedTimes(1);
79
- });
80
- });
81
-
82
- describe("TTL and expiration", () => {
83
- it("should cache items with TTL in eager mode", async () => {
84
- const mockFn = vi.fn(async (x: number) => x * 2);
85
- const cachedFn = withCache(mockFn, { ttl: 100, strategy: "eager" });
86
-
87
- const result1 = await cachedFn(5);
88
- expect(result1).toBe(10);
89
- expect(mockFn).toHaveResolvedTimes(1);
90
-
91
- // Item should still be cached before expiration
92
- const result2 = await cachedFn(5);
93
- expect(result2).toBe(10);
94
- expect(mockFn).toHaveResolvedTimes(1);
95
-
96
- // Wait for expiration
97
- await new Promise((resolve) => setTimeout(resolve, 150));
98
-
99
- // After expiration, item is removed and function is called again
100
- const result3 = await cachedFn(5);
101
- expect(result3).toBe(10);
102
- expect(mockFn).toHaveResolvedTimes(2);
103
- });
104
-
105
- it("should use lazy strategy to invalidate cache on touch", async () => {
106
- const mockFn = vi.fn(async (x: number) => x * 2);
107
- const cachedFn = withCache(mockFn, { ttl: 100, strategy: "lazy" });
108
-
109
- const result1 = await cachedFn(5);
110
- expect(result1).toBe(10);
111
- expect(mockFn).toHaveResolvedTimes(1);
112
-
113
- // Item should be cached for one subsequent call
114
- const result2 = await cachedFn(5);
115
- expect(result2).toBe(10);
116
- expect(mockFn).toHaveResolvedTimes(1);
117
-
118
- // Wait for expiration
119
- await new Promise((resolve) => setTimeout(resolve, 150));
120
-
121
- // After expiration, the cached item is stale, but should be returned
122
- const result3 = await cachedFn(5);
123
- expect(result3).toBe(10);
124
- expect(mockFn).toHaveResolvedTimes(1);
125
-
126
- // Next call should have invalidated the cache and call the function again
127
- const result4 = await cachedFn(5);
128
- expect(result4).toBe(10);
129
- expect(mockFn).toHaveResolvedTimes(2);
130
- });
131
-
132
- it("should use swr strategy to return stale cache and revalidate in background", async () => {
133
- const mockFn = vi.fn(async (x: number) => {
134
- await new Promise((resolve) => setTimeout(resolve, 50));
135
- return x * 2;
136
- });
137
- const cachedFn = withCache(mockFn, { ttl: 100, strategy: "swr" });
138
-
139
- const result1 = await cachedFn(5);
140
- expect(result1).toBe(10);
141
- expect(mockFn).toHaveResolvedTimes(1);
142
-
143
- // Item should be cached
144
- const result2 = await cachedFn(5);
145
- expect(result2).toBe(10);
146
- expect(mockFn).toHaveResolvedTimes(1);
147
-
148
- // Wait for expiration
149
- await new Promise((resolve) => setTimeout(resolve, 150));
150
-
151
- // With swr strategy, expired items are returned immediately
152
- const result3 = await cachedFn(5);
153
- expect(result3).toBe(10);
154
- expect(mockFn).toHaveResolvedTimes(1);
155
- // The stale cache is returned, but revalidation is queued in background
156
- // Wait a bit for background revalidation to complete
157
- await new Promise((resolve) => setTimeout(resolve, 50));
158
- expect(mockFn).toHaveResolvedTimes(2);
159
- });
160
- });
161
-
162
- describe("shouldStore option", () => {
163
- it("should not cache when shouldStore returns false", async () => {
164
- const mockFn = vi.fn(async (x: number) => x * 2);
165
- const cachedFn = withCache(mockFn, {
166
- shouldStore: (result: unknown) => (result as number) > 15,
167
- });
168
-
169
- const result1 = await cachedFn(5);
170
- expect(result1).toBe(10);
171
-
172
- const result2 = await cachedFn(5);
173
- expect(result2).toBe(10);
174
- expect(mockFn).toHaveResolvedTimes(2);
175
- });
176
-
177
- it("should cache when shouldStore returns true", async () => {
178
- const mockFn = vi.fn(async (x: number) => x * 2);
179
- const cachedFn = withCache(mockFn, {
180
- shouldStore: (result: unknown) => (result as number) > 5,
181
- });
182
-
183
- const result1 = await cachedFn(5);
184
- expect(result1).toBe(10);
185
-
186
- const result2 = await cachedFn(5);
187
- expect(result2).toBe(10);
188
- expect(mockFn).toHaveResolvedTimes(1);
189
- });
190
-
191
- it("should evaluate shouldStore on complex results", async () => {
192
- const mockFn = vi.fn(async (x: number) => ({
193
- value: x * 2,
194
- success: x > 0,
195
- }));
196
- const cachedFn = withCache(mockFn, {
197
- shouldStore: (result: unknown) =>
198
- (result as { success: boolean }).success,
199
- });
200
-
201
- const result1 = await cachedFn(5);
202
- expect(result1).toEqual({ value: 10, success: true });
203
-
204
- const result2 = await cachedFn(5);
205
- expect(result2).toEqual({ value: 10, success: true });
206
- expect(mockFn).toHaveResolvedTimes(1);
207
- });
208
- });
209
-
210
- describe("LRU eviction", () => {
211
- it("should evict least recently used items when max capacity is reached", async () => {
212
- // Create storage with small capacity
213
- const smallStorage = new LRUStorage({ max: 3 });
214
- const smallContainer = new CacheContainer(smallStorage);
215
- const smallWithCache = withCacheFactory(smallContainer);
216
-
217
- const mockFn = vi.fn(async (x: number) => x * 2);
218
- const cachedFn = smallWithCache(mockFn);
219
-
220
- // Fill cache to capacity
221
- await cachedFn(1); // key1
222
- await cachedFn(2); // key2
223
- await cachedFn(3); // key3
224
-
225
- expect(mockFn).toHaveResolvedTimes(3);
226
-
227
- // Access all three to verify they're cached
228
- await cachedFn(1);
229
- await cachedFn(2);
230
- await cachedFn(3);
231
- expect(mockFn).toHaveResolvedTimes(3);
232
-
233
- // Add a new item, which should evict the least recently used
234
- await cachedFn(4); // This should evict key1 (least recently used)
235
-
236
- expect(mockFn).toHaveResolvedTimes(4);
237
-
238
- // key1 should be evicted and function should be called again
239
- await cachedFn(1);
240
- expect(mockFn).toHaveResolvedTimes(5);
241
- });
242
-
243
- it("should keep recently accessed items in cache", async () => {
244
- const smallStorage = new LRUStorage({ max: 2 });
245
- const smallContainer = new CacheContainer(smallStorage);
246
- const smallWithCache = withCacheFactory(smallContainer);
247
-
248
- const mockFn = vi.fn(async (x: number) => x * 2);
249
- const cachedFn = smallWithCache(mockFn);
250
-
251
- await cachedFn(1);
252
- await cachedFn(2);
253
- expect(mockFn).toHaveResolvedTimes(2);
254
-
255
- // Access 1 again to make it recently used
256
- await cachedFn(1);
257
- expect(mockFn).toHaveResolvedTimes(2);
258
-
259
- // Add 3, should evict 2 (not 1)
260
- await cachedFn(3);
261
- expect(mockFn).toHaveResolvedTimes(3);
262
-
263
- // 1 should still be cached
264
- await cachedFn(1);
265
- expect(mockFn).toHaveResolvedTimes(3);
266
-
267
- // 2 should have been evicted
268
- await cachedFn(2);
269
- expect(mockFn).toHaveResolvedTimes(4);
270
- });
271
- });
272
-
273
- describe("Clear and removal", () => {
274
- it("should clear all cache entries", async () => {
275
- const mockFn = vi.fn(async (x: number) => x * 2);
276
- const cachedFn = withCache(mockFn);
277
-
278
- await cachedFn(1);
279
- await cachedFn(2);
280
- expect(mockFn).toHaveResolvedTimes(2);
281
-
282
- // Clear cache
283
- await container.clear();
284
-
285
- // Should call function again
286
- await cachedFn(1);
287
- await cachedFn(2);
288
- expect(mockFn).toHaveResolvedTimes(4);
289
- });
290
-
291
- it("should remove individual cache entries", async () => {
292
- const mockFn = vi.fn(async (x: number) => x * 2);
293
- // Create a named function so we know the function name
294
- const namedAsyncFn = Object.defineProperty(mockFn, "name", {
295
- value: "testFn",
296
- }) as unknown as (x: number) => Promise<number>;
297
-
298
- const cachedFn = withCache(namedAsyncFn, {
299
- calculateKey: ([x]) => `custom-key-${x}`,
300
- });
301
-
302
- await cachedFn(5);
303
- expect(mockFn).toHaveResolvedTimes(1);
304
-
305
- // Manually remove the key - must include the function name and prefix
306
- // The key format is: ${operation.name}:${prefix}:${calculateKeyResult}
307
- const fullKey = "testFn:default:custom-key-5";
308
- await container.unsetKey(fullKey);
309
-
310
- // Should call function again
311
- await cachedFn(5);
312
- expect(mockFn).toHaveResolvedTimes(2);
313
- });
314
- });
315
-
316
- describe("Complex scenarios", () => {
317
- it("should handle multiple concurrent calls", async () => {
318
- const mockFn = vi.fn(async (x: number) => {
319
- await new Promise((resolve) => setTimeout(resolve, 50));
320
- return x * 2;
321
- });
322
- const cachedFn = withCache(mockFn);
323
-
324
- const results = await Promise.all([
325
- cachedFn(5),
326
- cachedFn(5),
327
- cachedFn(5),
328
- ]);
329
-
330
- expect(results).toEqual([10, 10, 10]);
331
- expect(mockFn).toHaveResolvedTimes(3);
332
- });
333
-
334
- it("should handle different data types", async () => {
335
- const mockFn = vi.fn(async (data: Record<string, string>) => ({
336
- received: data,
337
- timestamp: Date.now(),
338
- }));
339
- const cachedFn = withCache(mockFn);
340
-
341
- const result1 = await cachedFn({ name: "test" });
342
- const result2 = await cachedFn({ name: "test" });
343
-
344
- // Verify both results are the same object (cached)
345
- expect(result1).toBe(result2);
346
- expect(mockFn).toHaveResolvedTimes(1);
347
- });
348
-
349
- it("should combine multiple cache options", async () => {
350
- const mockFn = vi.fn(async (x: number) => x * 2);
351
- const cachedFn = withCache(mockFn, {
352
- prefix: "combined",
353
- ttl: 100,
354
- strategy: "lazy",
355
- shouldStore: (result: unknown) => (result as number) > 5,
356
- });
357
-
358
- const result1 = await cachedFn(5);
359
- expect(result1).toBe(10);
360
- expect(mockFn).toHaveResolvedTimes(1);
361
-
362
- const result2 = await cachedFn(5);
363
- expect(result2).toBe(10);
364
- expect(mockFn).toHaveResolvedTimes(1);
365
-
366
- await new Promise((resolve) => setTimeout(resolve, 150));
367
-
368
- const result3 = await cachedFn(5);
369
- expect(result3).toBe(10);
370
- expect(mockFn).toHaveResolvedTimes(1);
371
- });
372
- });
373
-
374
- describe("Storage operations", () => {
375
- it("should correctly store and retrieve items", async () => {
376
- const key = "test-key";
377
- const content = { value: "test", number: 123 };
378
-
379
- await storage.setItem(key, {
380
- content,
381
- meta: {
382
- createdAt: Date.now(),
383
- ttl: null,
384
- isLazy: true,
385
- },
386
- });
387
-
388
- const retrieved = await storage.getItem(key);
389
- expect(retrieved?.content).toEqual(content);
390
- });
391
-
392
- it("should remove items from storage", async () => {
393
- const key = "test-key";
394
- const content = { value: "test" };
395
-
396
- await storage.setItem(key, {
397
- content,
398
- meta: {
399
- createdAt: Date.now(),
400
- ttl: null,
401
- isLazy: true,
402
- },
403
- });
404
-
405
- await storage.removeItem(key);
406
-
407
- const retrieved = await storage.getItem(key);
408
- expect(retrieved).toBeUndefined();
409
- });
410
-
411
- it("should clear all storage items", async () => {
412
- const key1 = "key1";
413
- const key2 = "key2";
414
-
415
- await storage.setItem(key1, {
416
- content: "value1",
417
- meta: {
418
- createdAt: Date.now(),
419
- ttl: null,
420
- isLazy: true,
421
- },
422
- });
423
-
424
- await storage.setItem(key2, {
425
- content: "value2",
426
- meta: {
427
- createdAt: Date.now(),
428
- ttl: null,
429
- isLazy: true,
430
- },
431
- });
432
-
433
- await storage.clear();
434
-
435
- const result1 = await storage.getItem(key1);
436
- const result2 = await storage.getItem(key2);
437
-
438
- expect(result1).toBeUndefined();
439
- expect(result2).toBeUndefined();
440
- });
441
- });
442
-
443
- describe("Error handling", () => {
444
- it("should not cache errors", async () => {
445
- const mockFn = vi.fn(async (x: number) => {
446
- if (x < 0) throw new Error("Negative number");
447
- return x * 2;
448
- });
449
- const cachedFn = withCache(mockFn);
450
-
451
- const result = await cachedFn(5);
452
- expect(result).toBe(10);
453
-
454
- await expect(cachedFn(-5)).rejects.toThrow("Negative number");
455
- expect(mockFn).toHaveBeenCalledTimes(2);
456
- });
457
- });
458
-
459
- describe("Concurrency limiting in revalidation queue", () => {
460
- it("should limit concurrent revalidations to revalidationConcurrency in swr mode", async () => {
461
- const mockFn = vi.fn(async (x: number) => {
462
- await new Promise((resolve) => setTimeout(resolve, 50));
463
- return x * 2;
464
- });
465
-
466
- const cachedFn = withCache(mockFn, {
467
- ttl: 100,
468
- strategy: "swr",
469
- revalidationConcurrency: 1,
470
- });
471
-
472
- // Prime the cache
473
- await cachedFn(5);
474
- expect(mockFn).toHaveResolvedTimes(1);
475
-
476
- // Wait for expiration so cache is stale
477
- await new Promise((resolve) => setTimeout(resolve, 150));
478
-
479
- // Track concurrent execution count
480
- let maxConcurrent = 0;
481
- let currentConcurrent = 0;
482
- const originalImpl = mockFn.getMockImplementation();
483
-
484
- mockFn.mockImplementation(async (x: number) => {
485
- currentConcurrent++;
486
- maxConcurrent = Math.max(maxConcurrent, currentConcurrent);
487
- // biome-ignore lint/style/noNonNullAssertion: we're sure it is here
488
- const result = await originalImpl!(x);
489
- currentConcurrent--;
490
- return result;
491
- });
492
-
493
- // Trigger multiple revalidations
494
- const results = await Promise.all([
495
- cachedFn(5),
496
- cachedFn(5),
497
- cachedFn(5),
498
- ]);
499
-
500
- // wait until all would have been called
501
- await new Promise((resolve) => setTimeout(resolve, 450));
502
-
503
- expect(results).toEqual([10, 10, 10]);
504
- // With concurrency: 1, should never have more than 1 concurrent revalidation
505
- expect(maxConcurrent).toBe(1);
506
- expect(mockFn).toHaveBeenCalledTimes(2);
507
- });
508
-
509
- it("should only queue revalidations in swr strategy when cache is expired", async () => {
510
- const mockFn = vi.fn(async (x: number) => {
511
- await new Promise((resolve) => setTimeout(resolve, 50));
512
- return x * 2;
513
- });
514
-
515
- const cachedFn = withCache(mockFn, {
516
- ttl: 100,
517
- strategy: "swr",
518
- revalidationConcurrency: 1,
519
- });
520
-
521
- // Prime the cache
522
- await cachedFn(5);
523
- expect(mockFn).toHaveResolvedTimes(1);
524
-
525
- // Track concurrent execution count
526
- let maxConcurrent = 0;
527
- let currentConcurrent = 0;
528
- const originalImpl = mockFn.getMockImplementation();
529
-
530
- mockFn.mockImplementation(async (x: number) => {
531
- currentConcurrent++;
532
- maxConcurrent = Math.max(maxConcurrent, currentConcurrent);
533
- // biome-ignore lint/style/noNonNullAssertion: we're sure it is here
534
- const result = await originalImpl!(x);
535
- currentConcurrent--;
536
- return result;
537
- });
538
-
539
- // Call while cache is still valid - should NOT queue revalidation
540
- const results = await Promise.all([
541
- cachedFn(5),
542
- cachedFn(5),
543
- cachedFn(5),
544
- ]);
545
-
546
- expect(results).toEqual([10, 10, 10]);
547
- // Since cache is not expired, no revalidation should be queued
548
- // Function should only be called once (initial call)
549
- expect(mockFn).toHaveResolvedTimes(1);
550
- });
551
-
552
- it("should use concurrency limit per unique cache key", async () => {
553
- const mockFn1 = vi.fn(async (x: number) => {
554
- await new Promise((resolve) => setTimeout(resolve, 50));
555
- return x * 2;
556
- });
557
-
558
- const mockFn2 = vi.fn(async (x: number) => {
559
- await new Promise((resolve) => setTimeout(resolve, 50));
560
- return x * 3;
561
- });
562
-
563
- const cachedFn1 = withCache(mockFn1, {
564
- ttl: 100,
565
- strategy: "swr",
566
- revalidationConcurrency: 1,
567
- prefix: "fn1",
568
- });
569
-
570
- const cachedFn2 = withCache(mockFn2, {
571
- ttl: 100,
572
- strategy: "swr",
573
- revalidationConcurrency: 1,
574
- prefix: "fn2",
575
- });
576
-
577
- // Prime both caches
578
- await cachedFn1(5);
579
- await cachedFn2(5);
580
- expect(mockFn1).toHaveResolvedTimes(1);
581
- expect(mockFn2).toHaveResolvedTimes(1);
582
-
583
- // Wait for expiration
584
- await new Promise((resolve) => setTimeout(resolve, 150));
585
-
586
- // Track concurrent execution for each
587
- let maxConcurrent1 = 0;
588
- let currentConcurrent1 = 0;
589
- const originalImpl1 = mockFn1.getMockImplementation();
590
-
591
- mockFn1.mockImplementation(async (x: number) => {
592
- currentConcurrent1++;
593
- maxConcurrent1 = Math.max(maxConcurrent1, currentConcurrent1);
594
- // biome-ignore lint/style/noNonNullAssertion: we're sure it is here
595
- const result = await originalImpl1!(x);
596
- currentConcurrent1--;
597
- return result;
598
- });
599
-
600
- // Trigger revalidations for both functions concurrently
601
- const results = await Promise.all([
602
- cachedFn1(5),
603
- cachedFn1(5),
604
- cachedFn2(5),
605
- cachedFn2(5),
606
- ]);
607
-
608
- expect(results).toEqual([10, 10, 15, 15]);
609
- // Each function's queue should limit its own concurrency
610
- expect(maxConcurrent1).toBe(1);
611
- });
612
- });
613
- });
package/src/lruStorage.ts DELETED
@@ -1,32 +0,0 @@
1
- import { LRUCache } from "lru-cache";
2
- import type { CachedItem } from "./cacheContainer.ts";
3
- import type { Storage } from "./storage.ts";
4
-
5
- export class LRUStorage implements Storage {
6
- private cache: LRUCache<string, CachedItem, unknown>;
7
-
8
- constructor({
9
- max = 10_000,
10
- }: Partial<LRUCache<string, CachedItem, unknown>> = {}) {
11
- this.cache = new LRUCache<string, CachedItem, unknown>({
12
- max,
13
- });
14
- }
15
-
16
- async clear(): Promise<void> {
17
- this.cache.clear();
18
- }
19
-
20
- async getItem(key: string) {
21
- const item = this.cache.get(key);
22
- return item;
23
- }
24
-
25
- async setItem(key: string, content: CachedItem) {
26
- this.cache.set(key, content);
27
- }
28
-
29
- async removeItem(key: string) {
30
- this.cache.delete(key);
31
- }
32
- }
package/src/storage.ts DELETED
@@ -1,27 +0,0 @@
1
- import type { CachedItem } from "./cacheContainer.ts";
2
-
3
- export interface Storage {
4
- /**
5
- * returns a cached item from the storage layer
6
- * @param key - key to look up
7
- */
8
- getItem(key: string): Promise<CachedItem | undefined>;
9
-
10
- /**
11
- * sets a cached item on the storage layer
12
- * @param key - key to store
13
- * @param content - content to store, including some meta data
14
- */
15
- setItem(key: string, content: CachedItem): Promise<void>;
16
-
17
- /**
18
- * removes item from the storage layer
19
- * @param key - key to remove
20
- */
21
- removeItem(key: string): Promise<void>;
22
-
23
- /**
24
- * remove all keys from the storage layer
25
- */
26
- clear(): Promise<void>;
27
- }