@asaidimu/utils-cache 1.0.0
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.
- package/LICENSE.md +21 -0
- package/README.md +753 -0
- package/index.d.mts +141 -0
- package/index.d.ts +141 -0
- package/index.js +677 -0
- package/index.mjs +650 -0
- package/package.json +67 -0
package/README.md
ADDED
@@ -0,0 +1,753 @@
|
|
1
|
+
# @asaidimu/utils-cache
|
2
|
+
|
3
|
+
An intelligent, configurable in-memory cache library for Node.js and browser environments, designed for optimal performance, data consistency, and developer observability.
|
4
|
+
|
5
|
+
[](https://www.npmjs.com/package/@augustine/utils)
|
6
|
+
[](LICENSE)
|
7
|
+
[](https://github.com/augustine/utils-actions)
|
8
|
+
[](https://www.typescriptlang.org/)
|
9
|
+
|
10
|
+
---
|
11
|
+
|
12
|
+
## 🚀 Quick Links
|
13
|
+
|
14
|
+
- [Overview & Features](#-overview--features)
|
15
|
+
- [Detailed Description](#detailed-description)
|
16
|
+
- [Key Features](#key-features)
|
17
|
+
- [Installation & Setup](#-installation--setup)
|
18
|
+
- [Prerequisites](#prerequisites)
|
19
|
+
- [Installation Steps](#installation-steps)
|
20
|
+
- [Configuration](#configuration)
|
21
|
+
- [Verification](#verification)
|
22
|
+
- [Usage Documentation](#-usage-documentation)
|
23
|
+
- [Basic Usage](#basic-usage)
|
24
|
+
- [API Usage](#api-usage)
|
25
|
+
- [Configuration Examples](#configuration-examples)
|
26
|
+
- [Common Use Cases](#common-use-cases)
|
27
|
+
- [Project Architecture](#-project-architecture)
|
28
|
+
- [Directory Structure](#directory-structure)
|
29
|
+
- [Core Components](#core-components)
|
30
|
+
- [Data Flow](#data-flow)
|
31
|
+
- [Extension Points](#extension-points)
|
32
|
+
- [Development & Contributing](#-development--contributing)
|
33
|
+
- [Development Setup](#development-setup)
|
34
|
+
- [Scripts](#scripts)
|
35
|
+
- [Testing](#testing)
|
36
|
+
- [Contributing Guidelines](#contributing-guidelines)
|
37
|
+
- [Issue Reporting](#issue-reporting)
|
38
|
+
- [Additional Information](#-additional-information)
|
39
|
+
- [Troubleshooting](#troubleshooting)
|
40
|
+
- [FAQ](#faq)
|
41
|
+
- [Changelog/Roadmap](#changelogroadmap)
|
42
|
+
- [License](#license)
|
43
|
+
- [Acknowledgments](#acknowledgments)
|
44
|
+
|
45
|
+
---
|
46
|
+
|
47
|
+
## 📦 Overview & Features
|
48
|
+
|
49
|
+
### Detailed Description
|
50
|
+
|
51
|
+
`Cache` provides a robust, in-memory caching solution designed for applications that require efficient data retrieval, resilience against network failures, and state persistence across sessions or processes. It implements common caching patterns like *stale-while-revalidate* and *Least Recently Used (LRU)* eviction, along with advanced features like automatic retries for failed fetches, extensible persistence mechanisms, and a comprehensive event system for real-time monitoring.
|
52
|
+
|
53
|
+
Unlike simpler caches, `Cache` manages data freshness intelligently, allowing you to serve stale data while a fresh copy is being fetched in the background. Its pluggable persistence layer enables you to save and restore the cache state, making it ideal for client-side applications that need to maintain state offline or server-side applications that need rapid startup with pre-populated data. With built-in metrics and events, `SimpleCache` offers deep insights into cache performance and lifecycle.
|
54
|
+
|
55
|
+
### Key Features
|
56
|
+
|
57
|
+
* **Configurable In-Memory Store**: Fast access to cached data.
|
58
|
+
* **Stale-While-Revalidate (SWR)**: Serve existing data immediately while fetching new data in the background, minimizing perceived latency.
|
59
|
+
* **Automatic Retries**: Configurable retry attempts and exponential backoff for `fetchFunction` failures.
|
60
|
+
* **Pluggable Persistence**: Integrates with any `SimplePersistence` implementation (e.g., LocalStorage, IndexedDB, custom backend) to save and restore cache state.
|
61
|
+
* **Debounced Persistence Writes**: Optimizes write frequency to the underlying persistence layer.
|
62
|
+
* **Remote Update Handling**: Synchronizes cache state when the persistence layer is updated externally.
|
63
|
+
* **Custom Serialization/Deserialization**: Control how your data is prepared for persistence.
|
64
|
+
* **Configurable Eviction Policies**:
|
65
|
+
* **Time-Based (TTL)**: Automatically evicts entries inactive for `cacheTime`.
|
66
|
+
* **Size-Based (LRU)**: Evicts least recently used items when `maxSize` is exceeded.
|
67
|
+
* **Comprehensive Event System**: Subscribe to granular cache events (hit, miss, fetch, error, eviction, invalidation, persistence, set_data) for logging, debugging, and advanced reactivity.
|
68
|
+
* **Performance Metrics**: Built-in tracking for hits, misses, fetches, errors, evictions, and stale hits, with calculated hit rates.
|
69
|
+
* **Flexible Query Management**: Register `fetchFunction`s for specific keys, allowing `Cache` to manage their lifecycle.
|
70
|
+
* **Imperative Control**: Methods for manual `invalidate`, `prefetch`, `refresh`, `setData`, and `remove` operations.
|
71
|
+
* **TypeScript Support**: Fully typed API for enhanced developer experience.
|
72
|
+
|
73
|
+
---
|
74
|
+
|
75
|
+
## 🛠️ Installation & Setup
|
76
|
+
|
77
|
+
### Prerequisites
|
78
|
+
|
79
|
+
* Node.js (v14.x or higher)
|
80
|
+
* npm or yarn
|
81
|
+
|
82
|
+
### Installation Steps
|
83
|
+
|
84
|
+
Install `Cache` using your preferred package manager:
|
85
|
+
|
86
|
+
```bash
|
87
|
+
bun add @ausaidimu/utils-cache
|
88
|
+
```
|
89
|
+
|
90
|
+
### Configuration
|
91
|
+
|
92
|
+
`Cache` is initialized with a `CacheOptions` object, allowing you to customize its behavior globally. Individual queries can override these options.
|
93
|
+
|
94
|
+
```typescript
|
95
|
+
import { Cache } from '@ausaidimu/utils-cache';
|
96
|
+
import { IndexedDBPersistence } from '@asaidimu/utils-persistence';
|
97
|
+
|
98
|
+
|
99
|
+
const myCache = new Cache({
|
100
|
+
staleTime: 5 * 60 * 1000, // Data considered stale after 5 minutes
|
101
|
+
cacheTime: 30 * 60 * 1000, // Data evicted if not accessed for 30 minutes
|
102
|
+
retryAttempts: 2, // Retry fetch up to 2 times on failure
|
103
|
+
retryDelay: 2000, // 2-second initial delay between retries (doubles each attempt)
|
104
|
+
maxSize: 500, // Maximum 500 entries (LRU eviction)
|
105
|
+
enableMetrics: true,
|
106
|
+
persistence: new IndexedDBPersistence(), // Plug in your persistence layer
|
107
|
+
persistenceId: 'my-app-cache-v1', // Unique ID for this cache instance in persistence
|
108
|
+
persistenceDebounceTime: 1000, // Debounce persistence writes by 1 second
|
109
|
+
// Custom serializers/deserializers for non-JSON-serializable data
|
110
|
+
serializeValue: (value) => {
|
111
|
+
if (value instanceof Map) return { _type: 'Map', data: Array.from(value.entries()) };
|
112
|
+
return value;
|
113
|
+
},
|
114
|
+
deserializeValue: (value) => {
|
115
|
+
if (typeof value === 'object' && value !== null && value._type === 'Map') {
|
116
|
+
return new Map(value.data);
|
117
|
+
}
|
118
|
+
return value;
|
119
|
+
},
|
120
|
+
});
|
121
|
+
```
|
122
|
+
|
123
|
+
### Verification
|
124
|
+
|
125
|
+
To verify that `Cache` is installed and initialized correctly, you can run a simple test:
|
126
|
+
|
127
|
+
```typescript
|
128
|
+
import { Cache } from '@ausaidimu/utils-cache';
|
129
|
+
|
130
|
+
const cache = new Cache();
|
131
|
+
console.log('Cache initialized successfully!');
|
132
|
+
|
133
|
+
// You can add a simple query and try to fetch:
|
134
|
+
cache.registerQuery('hello', async () => 'world');
|
135
|
+
cache.get('hello').then(data => {
|
136
|
+
console.log(`Fetched 'hello': ${data}`);
|
137
|
+
});
|
138
|
+
```
|
139
|
+
|
140
|
+
---
|
141
|
+
|
142
|
+
## 📖 Usage Documentation
|
143
|
+
|
144
|
+
### Basic Usage
|
145
|
+
|
146
|
+
The core of `Cache` involves registering queries and then retrieving data using those queries.
|
147
|
+
|
148
|
+
```typescript
|
149
|
+
import { Cache } from '@ausaidimu/utils-cache';
|
150
|
+
|
151
|
+
const myCache = new Cache({
|
152
|
+
staleTime: 5000, // 5 seconds
|
153
|
+
cacheTime: 60000, // 1 minute
|
154
|
+
});
|
155
|
+
|
156
|
+
// 1. Register a query with a unique key and a function to fetch the data
|
157
|
+
myCache.registerQuery('user/123', async () => {
|
158
|
+
console.log('Fetching user data...');
|
159
|
+
// Simulate network delay
|
160
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
161
|
+
return { id: 123, name: 'Alice', email: 'alice@example.com' };
|
162
|
+
});
|
163
|
+
|
164
|
+
// 2. Retrieve data from the cache
|
165
|
+
async function getUserData() {
|
166
|
+
const userData = await myCache.get('user/123');
|
167
|
+
console.log('User data:', userData);
|
168
|
+
}
|
169
|
+
|
170
|
+
// First call: Triggers fetch, data is fetched and cached
|
171
|
+
getUserData();
|
172
|
+
|
173
|
+
// Subsequent calls (within staleTime): Data is returned instantly from cache
|
174
|
+
setTimeout(() => getUserData(), 500);
|
175
|
+
|
176
|
+
// Call after staleTime: Data is returned instantly, but a background fetch is triggered
|
177
|
+
setTimeout(() => getUserData(), 6000);
|
178
|
+
|
179
|
+
// Example of waiting for fresh data
|
180
|
+
async function getFreshUserData() {
|
181
|
+
console.log('\nRequesting fresh user data...');
|
182
|
+
const freshUserData = await myCache.get('user/123', { waitForFresh: true });
|
183
|
+
console.log('Fresh user data:', freshUserData);
|
184
|
+
}
|
185
|
+
|
186
|
+
setTimeout(() => getFreshUserData(), 7000);
|
187
|
+
```
|
188
|
+
|
189
|
+
### API Usage
|
190
|
+
|
191
|
+
#### `new Cache(defaultOptions?: CacheOptions)`
|
192
|
+
|
193
|
+
Creates a new `Cache` instance with global default options.
|
194
|
+
|
195
|
+
```typescript
|
196
|
+
import { Cache } from '@ausaidimu/utils-cache';
|
197
|
+
|
198
|
+
const cache = new Cache({
|
199
|
+
staleTime: 5 * 60 * 1000, // 5 minutes
|
200
|
+
cacheTime: 30 * 60 * 1000, // 30 minutes
|
201
|
+
maxSize: 1000,
|
202
|
+
});
|
203
|
+
```
|
204
|
+
|
205
|
+
#### `cache.registerQuery<T>(key: string, fetchFunction: () => Promise<T>, options?: CacheOptions): void`
|
206
|
+
|
207
|
+
Registers a data fetching function associated with a `key`. This function will be called when data for the `key` is not in cache, is stale, or explicitly invalidated/refreshed.
|
208
|
+
|
209
|
+
- `key`: A unique string identifier for the data.
|
210
|
+
- `fetchFunction`: An `async` function that returns a `Promise` resolving to the data.
|
211
|
+
- `options`: Optional `CacheOptions` to override the instance's default options for this specific query.
|
212
|
+
|
213
|
+
```typescript
|
214
|
+
cache.registerQuery('products/featured', async () => {
|
215
|
+
const response = await fetch('/api/products/featured');
|
216
|
+
if (!response.ok) throw new Error('Failed to fetch featured products');
|
217
|
+
return response.json();
|
218
|
+
}, {
|
219
|
+
staleTime: 60 * 1000, // This query's data is stale after 1 minute
|
220
|
+
retryAttempts: 5,
|
221
|
+
});
|
222
|
+
```
|
223
|
+
|
224
|
+
#### `cache.get<T>(key: string, options?: { waitForFresh?: boolean; throwOnError?: boolean }): Promise<T | undefined>`
|
225
|
+
|
226
|
+
Retrieves data for a given `key`.
|
227
|
+
|
228
|
+
- If data is fresh, returns it immediately.
|
229
|
+
- If data is stale, returns it immediately and triggers a background refetch (stale-while-revalidate).
|
230
|
+
- If data is not in cache (miss), triggers a fetch.
|
231
|
+
- `waitForFresh`: If `true`, the method will await the fetch function to complete and return fresh data. If `false` (default), it will return existing stale data immediately if available, otherwise `undefined` while a fetch is ongoing in the background.
|
232
|
+
- `throwOnError`: If `true`, and the fetch fails, the promise returned by `get` will reject with the error. If `false` (default), it will return `undefined` on fetch failure, or the last successfully fetched data if available.
|
233
|
+
|
234
|
+
```typescript
|
235
|
+
// Basic usage (stale-while-revalidate)
|
236
|
+
const post = await cache.get('posts/latest');
|
237
|
+
|
238
|
+
// Wait for fresh data, throw if fetch fails
|
239
|
+
try {
|
240
|
+
const userProfile = await cache.get('user/profile', { waitForFresh: true, throwOnError: true });
|
241
|
+
} catch (error) {
|
242
|
+
console.error('Could not get fresh user profile:', error);
|
243
|
+
}
|
244
|
+
```
|
245
|
+
|
246
|
+
#### `cache.peek<T>(key: string): T | undefined`
|
247
|
+
|
248
|
+
Retrieves data from the cache without triggering any fetches or updating `lastUpdated` time. Useful for quick synchronous checks.
|
249
|
+
|
250
|
+
```typescript
|
251
|
+
const cachedValue = cache.peek('some-key');
|
252
|
+
if (cachedValue) {
|
253
|
+
console.log('Value is in cache:', cachedValue);
|
254
|
+
} else {
|
255
|
+
console.log('Value not found in cache.');
|
256
|
+
}
|
257
|
+
```
|
258
|
+
|
259
|
+
#### `cache.has(key: string): boolean`
|
260
|
+
|
261
|
+
Checks if a non-stale, non-loading entry exists in the cache for the given `key`.
|
262
|
+
|
263
|
+
```typescript
|
264
|
+
if (cache.has('config/app')) {
|
265
|
+
console.log('App config is ready and fresh.');
|
266
|
+
}
|
267
|
+
```
|
268
|
+
|
269
|
+
#### `cache.invalidate(key: string, refetch?: boolean): Promise<void>`
|
270
|
+
|
271
|
+
Marks an entry as stale, forcing the next `get` call to trigger a refetch. Optionally triggers an immediate background refetch.
|
272
|
+
|
273
|
+
- `key`: The cache key to invalidate.
|
274
|
+
- `refetch`: If `true` (default), triggers an immediate background fetch for the invalidated key.
|
275
|
+
|
276
|
+
```typescript
|
277
|
+
// Invalidate a specific user's posts
|
278
|
+
await cache.invalidate('user/123/posts');
|
279
|
+
|
280
|
+
// Invalidate and don't refetch until `get` is called
|
281
|
+
await cache.invalidate('admin/dashboard/stats', false);
|
282
|
+
```
|
283
|
+
|
284
|
+
#### `cache.invalidatePattern(pattern: RegExp, refetch?: boolean): Promise<void>`
|
285
|
+
|
286
|
+
Invalidates all cache entries whose keys match the given regular expression.
|
287
|
+
|
288
|
+
- `pattern`: A `RegExp` to match cache keys.
|
289
|
+
- `refetch`: If `true` (default), triggers immediate background fetches for all matched keys.
|
290
|
+
|
291
|
+
```typescript
|
292
|
+
// Invalidate all product-related data
|
293
|
+
await cache.invalidatePattern(/^products\//);
|
294
|
+
```
|
295
|
+
|
296
|
+
#### `cache.prefetch(key: string): Promise<void>`
|
297
|
+
|
298
|
+
Triggers a background fetch for a `key` if it's not already in cache or is stale. Useful for loading data proactively.
|
299
|
+
|
300
|
+
```typescript
|
301
|
+
// On page load, prefetch common data
|
302
|
+
cache.prefetch('static-content/footer');
|
303
|
+
cache.prefetch('user/notifications/unread');
|
304
|
+
```
|
305
|
+
|
306
|
+
#### `cache.refresh<T>(key: string): Promise<T | undefined>`
|
307
|
+
|
308
|
+
Forces a re-fetch of data for a given `key`, bypassing staleness checks and existing fetch promises. Returns the fresh data.
|
309
|
+
|
310
|
+
```typescript
|
311
|
+
// Force update user data after an API call changes it
|
312
|
+
const updatedUser = await cache.refresh('user/current');
|
313
|
+
console.log('User data refreshed:', updatedUser);
|
314
|
+
```
|
315
|
+
|
316
|
+
#### `cache.setData<T>(key: string, data: T): void`
|
317
|
+
|
318
|
+
Manually sets or updates data in the cache for a given `key`. This immediately updates the cache entry, marks it as fresh, and triggers persistence if configured.
|
319
|
+
|
320
|
+
```typescript
|
321
|
+
// Manually update a shopping cart item count
|
322
|
+
cache.setData('cart/item-count', 5);
|
323
|
+
|
324
|
+
// Directly inject data fetched from another source
|
325
|
+
const externalData = { /* ... */ };
|
326
|
+
cache.setData('external-resource/id', externalData);
|
327
|
+
```
|
328
|
+
|
329
|
+
#### `cache.remove(key: string): boolean`
|
330
|
+
|
331
|
+
Removes an entry from the cache. Returns `true` if an entry was found and removed, `false` otherwise.
|
332
|
+
|
333
|
+
```typescript
|
334
|
+
// When a user logs out, remove their specific session data
|
335
|
+
cache.remove('user/session');
|
336
|
+
```
|
337
|
+
|
338
|
+
#### `cache.on<EType extends CacheEventType>(event: EType, listener: (ev: Extract<CacheEvent, { type: EType }>) => void): void`
|
339
|
+
|
340
|
+
Subscribes to cache events.
|
341
|
+
|
342
|
+
- `event`: The type of event to listen for (e.g., `'hit'`, `'miss'`, `'error'`).
|
343
|
+
- `listener`: A callback function that receives the event payload.
|
344
|
+
|
345
|
+
```typescript
|
346
|
+
import { Cache, CacheEvent, CacheEventType } from '@ausaidimu/utils-cache';
|
347
|
+
|
348
|
+
const myCache = new Cache();
|
349
|
+
|
350
|
+
myCache.on('hit', (e) => {
|
351
|
+
console.log(`Cache HIT for ${e.key} (stale: ${e.isStale})`);
|
352
|
+
});
|
353
|
+
|
354
|
+
myCache.on('miss', (e) => {
|
355
|
+
console.log(`Cache MISS for ${e.key}`);
|
356
|
+
});
|
357
|
+
|
358
|
+
myCache.on('error', (e) => {
|
359
|
+
console.error(`Cache ERROR for ${e.key} (attempt ${e.attempt}):`, e.error.message);
|
360
|
+
});
|
361
|
+
|
362
|
+
myCache.on('persistence', (e) => {
|
363
|
+
if (e.event === 'save_success') {
|
364
|
+
console.log(`Cache state saved successfully for ID: ${e.key}`);
|
365
|
+
} else if (e.event === 'load_fail') {
|
366
|
+
console.error(`Failed to load cache state for ID: ${e.key}`, e.error);
|
367
|
+
}
|
368
|
+
});
|
369
|
+
```
|
370
|
+
|
371
|
+
#### `cache.off<EType extends CacheEventType>(event: EType, listener: (ev: Extract<CacheEvent, { type: EType }>) => void): void`
|
372
|
+
|
373
|
+
Unsubscribes a listener from a cache event.
|
374
|
+
|
375
|
+
```typescript
|
376
|
+
const myHitListener = (e: any) => console.log(`Hit: ${e.key}`);
|
377
|
+
myCache.on('hit', myHitListener);
|
378
|
+
// Later...
|
379
|
+
myCache.off('hit', myHitListener);
|
380
|
+
```
|
381
|
+
|
382
|
+
#### `cache.getStats(): { size: number; metrics: CacheMetrics; hitRate: number; staleHitRate: number; entries: Array<CacheEntryInfo> }`
|
383
|
+
|
384
|
+
Returns current cache statistics and metrics.
|
385
|
+
|
386
|
+
- `size`: Number of entries in the cache.
|
387
|
+
- `metrics`: Object containing `hits`, `misses`, `fetches`, `errors`, `evictions`, `staleHits`.
|
388
|
+
- `hitRate`: Ratio of hits to total requests (hits + misses).
|
389
|
+
- `staleHitRate`: Ratio of stale hits to total hits.
|
390
|
+
- `entries`: An array of objects providing details for each cached item (key, lastAccessed, lastUpdated, accessCount, isStale, isLoading, error).
|
391
|
+
|
392
|
+
```typescript
|
393
|
+
const stats = myCache.getStats();
|
394
|
+
console.log('Cache Size:', stats.size);
|
395
|
+
console.log('Metrics:', stats.metrics);
|
396
|
+
console.log('Hit Rate:', (stats.hitRate * 100).toFixed(2) + '%');
|
397
|
+
```
|
398
|
+
|
399
|
+
#### `cache.clear(): Promise<void>`
|
400
|
+
|
401
|
+
Clears all data from the in-memory cache and attempts to clear the persisted state.
|
402
|
+
|
403
|
+
```typescript
|
404
|
+
await myCache.clear();
|
405
|
+
console.log('Cache cleared.');
|
406
|
+
```
|
407
|
+
|
408
|
+
#### `cache.destroy(): void`
|
409
|
+
|
410
|
+
Shuts down the cache instance, clearing all data, stopping garbage collection, and unsubscribing from persistence. Call this when the cache instance is no longer needed.
|
411
|
+
|
412
|
+
```typescript
|
413
|
+
myCache.destroy();
|
414
|
+
console.log('Cache instance destroyed.');
|
415
|
+
```
|
416
|
+
|
417
|
+
### Configuration Examples
|
418
|
+
|
419
|
+
The `CacheOptions` interface provides extensive control:
|
420
|
+
|
421
|
+
```typescript
|
422
|
+
import { CacheOptions, SimplePersistence, SerializableCacheState } from '@ausaidimu/utils-cache';
|
423
|
+
|
424
|
+
// A mock persistence layer for demonstration
|
425
|
+
class MockPersistence implements SimplePersistence<SerializableCacheState> {
|
426
|
+
private store = new Map<string, SerializableCacheState>();
|
427
|
+
private subscribers = new Map<string, Array<(data: SerializableCacheState) => void>>();
|
428
|
+
|
429
|
+
async get(id: string): Promise<SerializableCacheState | undefined> {
|
430
|
+
console.log(`[MockPersistence] Getting state for ${id}`);
|
431
|
+
return this.store.get(id);
|
432
|
+
}
|
433
|
+
async set(id: string, data: SerializableCacheState): Promise<void> {
|
434
|
+
console.log(`[MockPersistence] Setting state for ${id}`);
|
435
|
+
this.store.set(id, data);
|
436
|
+
// Simulate remote update to subscribers
|
437
|
+
this.subscribers.get(id)?.forEach(cb => cb(data));
|
438
|
+
}
|
439
|
+
async clear(id?: string): Promise<void> {
|
440
|
+
console.log(`[MockPersistence] Clearing state ${id ? 'for ' + id : 'all'}`);
|
441
|
+
if (id) {
|
442
|
+
this.store.delete(id);
|
443
|
+
} else {
|
444
|
+
this.store.clear();
|
445
|
+
}
|
446
|
+
}
|
447
|
+
subscribe(id: string, callback: (data: SerializableCacheState) => void): () => void {
|
448
|
+
console.log(`[MockPersistence] Subscribing to ${id}`);
|
449
|
+
if (!this.subscribers.has(id)) {
|
450
|
+
this.subscribers.set(id, []);
|
451
|
+
}
|
452
|
+
this.subscribers.get(id)?.push(callback);
|
453
|
+
return () => {
|
454
|
+
const callbacks = this.subscribers.get(id);
|
455
|
+
if (callbacks) {
|
456
|
+
this.subscribers.set(id, callbacks.filter(cb => cb !== callback));
|
457
|
+
}
|
458
|
+
};
|
459
|
+
}
|
460
|
+
}
|
461
|
+
|
462
|
+
|
463
|
+
const fullOptions: CacheOptions = {
|
464
|
+
staleTime: 1000 * 60 * 5, // 5 minutes
|
465
|
+
cacheTime: 1000 * 60 * 60, // 1 hour (items idle for this long are garbage collected)
|
466
|
+
retryAttempts: 3, // Max 3 fetch attempts (initial + 2 retries)
|
467
|
+
retryDelay: 1000, // 1 second initial delay for retries (doubles each attempt)
|
468
|
+
maxSize: 2000, // Keep up to 2000 entries (LRU eviction kicks in beyond this)
|
469
|
+
enableMetrics: true, // Enable performance tracking
|
470
|
+
persistence: new MockPersistence(), // Provide an instance of your persistence layer
|
471
|
+
persistenceId: 'my-unique-cache-instance', // Specific ID for this cache in persistence
|
472
|
+
persistenceDebounceTime: 750, // Wait 750ms before writing to persistence
|
473
|
+
serializeValue: (value: any) => {
|
474
|
+
// Example: Convert Date objects to ISO strings for JSON serialization
|
475
|
+
if (value instanceof Date) {
|
476
|
+
return { _type: 'Date', data: value.toISOString() };
|
477
|
+
}
|
478
|
+
return value;
|
479
|
+
},
|
480
|
+
deserializeValue: (value: any) => {
|
481
|
+
// Example: Convert ISO strings back to Date objects
|
482
|
+
if (typeof value === 'object' && value !== null && value._type === 'Date') {
|
483
|
+
return new Date(value.data);
|
484
|
+
}
|
485
|
+
return value;
|
486
|
+
},
|
487
|
+
};
|
488
|
+
|
489
|
+
const configuredCache = new Cache(fullOptions);
|
490
|
+
```
|
491
|
+
|
492
|
+
### Common Use Cases
|
493
|
+
|
494
|
+
#### Caching API Responses with SWR
|
495
|
+
|
496
|
+
```typescript
|
497
|
+
import { Cache } from '@ausaidimu/utils-cache';
|
498
|
+
|
499
|
+
const apiCache = new Cache({
|
500
|
+
staleTime: 5 * 60 * 1000, // 5 min before data is considered stale
|
501
|
+
cacheTime: 30 * 60 * 1000, // 30 min before idle data is garbage collected
|
502
|
+
retryAttempts: 3,
|
503
|
+
});
|
504
|
+
|
505
|
+
apiCache.registerQuery('blog/posts', async () => {
|
506
|
+
console.log('Fetching ALL blog posts from API...');
|
507
|
+
const response = await fetch('https://api.example.com/blog/posts');
|
508
|
+
if (!response.ok) throw new Error('Failed to fetch blog posts');
|
509
|
+
return response.json();
|
510
|
+
});
|
511
|
+
|
512
|
+
// User opens blog page: get posts instantly, refresh if needed
|
513
|
+
async function displayBlogPosts() {
|
514
|
+
const posts = await apiCache.get('blog/posts');
|
515
|
+
if (posts) {
|
516
|
+
console.log('Displaying posts (from cache or new fetch):', posts.slice(0, 2));
|
517
|
+
} else {
|
518
|
+
console.log('No posts yet, waiting for fetch...');
|
519
|
+
}
|
520
|
+
}
|
521
|
+
|
522
|
+
displayBlogPosts(); // First load
|
523
|
+
setTimeout(() => displayBlogPosts(), 2000); // Subsequent fast load from cache
|
524
|
+
setTimeout(() => displayBlogPosts(), 301000); // After staleTime, returns cached data, triggers background fetch
|
525
|
+
```
|
526
|
+
|
527
|
+
#### Using `waitForFresh` for Critical Data
|
528
|
+
|
529
|
+
```typescript
|
530
|
+
import { Cache } from '@ausaidimu/utils-cache';
|
531
|
+
|
532
|
+
const criticalCache = new Cache({ retryAttempts: 5, retryDelay: 1000 });
|
533
|
+
|
534
|
+
criticalCache.registerQuery('user/permissions', async () => {
|
535
|
+
console.log('Fetching user permissions...');
|
536
|
+
const response = await fetch('/api/user/permissions');
|
537
|
+
if (!response.ok) throw new Error('Failed to get permissions!');
|
538
|
+
return response.json();
|
539
|
+
});
|
540
|
+
|
541
|
+
async function checkPermissionsBeforeAction() {
|
542
|
+
try {
|
543
|
+
// We MUST have the latest permissions before proceeding
|
544
|
+
const permissions = await criticalCache.get('user/permissions', { waitForFresh: true, throwOnError: true });
|
545
|
+
console.log('User permissions:', permissions);
|
546
|
+
// Proceed with action based on permissions
|
547
|
+
} catch (error) {
|
548
|
+
console.error('Failed to load critical permissions:', error);
|
549
|
+
// Redirect to error page or show alert
|
550
|
+
}
|
551
|
+
}
|
552
|
+
|
553
|
+
checkPermissionsBeforeAction();
|
554
|
+
```
|
555
|
+
|
556
|
+
#### Real-time Monitoring with Events
|
557
|
+
|
558
|
+
```typescript
|
559
|
+
import { Cache } from '@ausaidimu/utils-cache';
|
560
|
+
|
561
|
+
const monitorCache = new Cache({ enableMetrics: true });
|
562
|
+
|
563
|
+
monitorCache.registerQuery('stock/AAPL', async () => {
|
564
|
+
const price = Math.random() * 100 + 150;
|
565
|
+
console.log(`Fetching AAPL price: $${price.toFixed(2)}`);
|
566
|
+
return { symbol: 'AAPL', price: parseFloat(price.toFixed(2)), timestamp: Date.now() };
|
567
|
+
}, { staleTime: 1000 }); // Very short staleTime for frequent fetches
|
568
|
+
|
569
|
+
monitorCache.on('fetch', (e) => {
|
570
|
+
console.log(`[EVENT] Fetching ${e.key} (attempt ${e.attempt})`);
|
571
|
+
});
|
572
|
+
monitorCache.on('hit', (e) => {
|
573
|
+
console.log(`[EVENT] Cache hit for ${e.key}. Stale: ${e.isStale}`);
|
574
|
+
});
|
575
|
+
monitorCache.on('miss', (e) => {
|
576
|
+
console.log(`[EVENT] Cache miss for ${e.key}`);
|
577
|
+
});
|
578
|
+
monitorCache.on('eviction', (e) => {
|
579
|
+
console.log(`[EVENT] Evicted ${e.key} due to ${e.reason}`);
|
580
|
+
});
|
581
|
+
monitorCache.on('set_data', (e) => {
|
582
|
+
console.log(`[EVENT] Data for ${e.key} manually set. Old: ${e.oldData?.price}, New: ${e.newData.price}`);
|
583
|
+
});
|
584
|
+
|
585
|
+
setInterval(() => {
|
586
|
+
monitorCache.get('stock/AAPL');
|
587
|
+
}, 500); // Continuously try to get data
|
588
|
+
|
589
|
+
setInterval(() => {
|
590
|
+
const stats = monitorCache.getStats();
|
591
|
+
console.log(`\n--- STATS ---`);
|
592
|
+
console.log(`Size: ${stats.size}, Hits: ${stats.metrics.hits}, Misses: ${stats.metrics.misses}, Fetches: ${stats.metrics.fetches}`);
|
593
|
+
console.log(`Hit Rate: ${(stats.hitRate * 100).toFixed(2)}%, Stale Hit Rate: ${(stats.staleHitRate * 100).toFixed(2)}%`);
|
594
|
+
console.log(`Active entries: ${stats.entries.map(e => `${e.key} (stale:${e.isStale})`).join(', ')}`);
|
595
|
+
console.log(`---\n`);
|
596
|
+
}, 5000); // Log stats every 5 seconds
|
597
|
+
```
|
598
|
+
|
599
|
+
---
|
600
|
+
|
601
|
+
## 🏗️ Project Architecture
|
602
|
+
|
603
|
+
### Core Components
|
604
|
+
|
605
|
+
* **`Cache` Class (`index.ts`)**: The primary entry point. Manages the in-memory `Map` (`this.cache`), handles fetching, retries, staleness, garbage collection, persistence, and events.
|
606
|
+
* **`CacheOptions` (`types.ts`)**: Defines the configuration parameters for `Cache` instances and individual queries.
|
607
|
+
* **`CacheEntry` (`types.ts`)**: Represents a single item stored in the cache, including its data, timestamps, access count, and loading/error status.
|
608
|
+
* **`QueryConfig` (`types.ts`)**: Stores the `fetchFunction` and resolved options for each registered query.
|
609
|
+
* **`CacheMetrics` (`types.ts`)**: Defines the structure for tracking cache performance statistics.
|
610
|
+
* **`SimplePersistence<SerializableCacheState>` (from `@core/persistence/types`)**: An external interface that `Cache` uses for persistent storage. It requires `get()`, `set()`, `clear()`, and optionally `subscribe()` methods to handle data serialization and deserialization for the storage medium.
|
611
|
+
* **`CacheEvent` / `CacheEventType` (`types.ts`)**: Defines the union of all possible events emitted by the cache, enabling fine-grained observability.
|
612
|
+
|
613
|
+
### Data Flow
|
614
|
+
|
615
|
+
1. **Initialization**: `Cache` constructor sets default options, initializes metrics, starts garbage collection, and attempts to load state from the configured `persistence` layer. It also subscribes to remote updates from persistence if available.
|
616
|
+
2. **`registerQuery`**: Stores a `fetchFunction` and its specific `CacheOptions` (merged with defaults) in a `queries` map.
|
617
|
+
3. **`get` Request**:
|
618
|
+
* Checks `this.cache` for the key.
|
619
|
+
* If found, updates `lastAccessed`, increments `accessCount`, emits `hit` event.
|
620
|
+
* Checks `isStale` based on `staleTime`.
|
621
|
+
* If `waitForFresh` is `true` OR if `isStale` / `cacheEntry` is loading/empty, it calls `fetchAndWait`.
|
622
|
+
* Otherwise, if `isStale`, it triggers `fetch` in the background.
|
623
|
+
* If no entry, emits `miss` event and creates a placeholder, then triggers `fetch`.
|
624
|
+
* Returns cached data (if available and not `waitForFresh`) or `undefined` (if background fetch).
|
625
|
+
4. **`fetch` / `fetchAndWait`**:
|
626
|
+
* Checks `this.fetching` to avoid concurrent fetches for the same key.
|
627
|
+
* Calls `performFetchWithRetry`.
|
628
|
+
5. **`performFetchWithRetry`**:
|
629
|
+
* Iteratively calls the registered `fetchFunction`.
|
630
|
+
* Emits `fetch` event before each attempt.
|
631
|
+
* On success: updates `cacheEntry` with `data`, `lastUpdated`, `isLoading: false`, `error: undefined`. Schedules `schedulePersistState`. Enforces `enforceSizeLimit`. Returns `data`.
|
632
|
+
* On failure: emits `error` event. Retries after `retryDelay` (with exponential backoff). After all attempts, updates `cacheEntry` with `error` and `isLoading: false`. Schedules `schedulePersistState`. Returns `undefined`.
|
633
|
+
6. **`schedulePersistState`**: Debounces persistence writes. When triggered, `serializeCache` transforms the current cache state into a `SerializableCacheState` (applying `serializeValue`), which is then written to the `persistence` layer. Emits `persistence` events.
|
634
|
+
7. **`handleRemoteStateChange`**: When the `persistence` layer notifies of a remote update, this method deserializes the state (applying `deserializeValue`) and updates `this.cache`, ensuring local cache consistency with external changes.
|
635
|
+
8. **`garbageCollect`**: Periodically (via `gcTimer`) iterates through `this.cache` and removes entries that have not been accessed for `cacheTime`. Emits `eviction` events.
|
636
|
+
9. **`enforceSizeLimit`**: Triggered after successful fetches or `setData`. If `maxSize` is exceeded, evicts `LRU` entries. Emits `eviction` events.
|
637
|
+
|
638
|
+
### Extension Points
|
639
|
+
|
640
|
+
* **`SimplePersistence` Interface**: The primary extension point for integrating `Cache` with various storage backends. Implement this interface to use `SimpleCache` with `localStorage`, `IndexedDB`, a database, or any other persistent storage.
|
641
|
+
* **`serializeValue` / `deserializeValue` Options**: Customize how cache entry data is converted to and from a serializable format (e.g., handling `Date` objects, `Map`s, custom classes) before interacting with the persistence layer.
|
642
|
+
* **Event Listeners (`on`/`off`)**: Integrate `Cache` events with your application's logging, analytics, UI reactivity, or debugging tools.
|
643
|
+
|
644
|
+
---
|
645
|
+
|
646
|
+
## 🤝 Development & Contributing
|
647
|
+
|
648
|
+
### Development Setup
|
649
|
+
|
650
|
+
To set up the development environment:
|
651
|
+
|
652
|
+
1. **Clone the repository:**
|
653
|
+
```bash
|
654
|
+
git clone https://github.com/ausaidimu/utils.git
|
655
|
+
cd utils-src/cache
|
656
|
+
```
|
657
|
+
2. **Install dependencies:**
|
658
|
+
```bash
|
659
|
+
npm install
|
660
|
+
# or
|
661
|
+
yarn install
|
662
|
+
```
|
663
|
+
3. **Build the project:**
|
664
|
+
```bash
|
665
|
+
npm run build
|
666
|
+
# or
|
667
|
+
yarn build
|
668
|
+
```
|
669
|
+
|
670
|
+
### Scripts
|
671
|
+
|
672
|
+
The following `npm` scripts are available:
|
673
|
+
|
674
|
+
* `npm run build`: Compiles TypeScript source files to JavaScript.
|
675
|
+
* `npm run test`: Runs the test suite.
|
676
|
+
* `npm run lint`: Runs ESLint to check for code style issues.
|
677
|
+
* `npm run format`: Formats code using Prettier.
|
678
|
+
|
679
|
+
### Testing
|
680
|
+
|
681
|
+
Tests are written using `[Your Testing Framework, e.g., Jest]`. To run tests:
|
682
|
+
|
683
|
+
```bash
|
684
|
+
npm test
|
685
|
+
# or
|
686
|
+
yarn test
|
687
|
+
```
|
688
|
+
|
689
|
+
We aim for high test coverage. Please ensure new features or bug fixes come with appropriate tests.
|
690
|
+
|
691
|
+
### Contributing Guidelines
|
692
|
+
|
693
|
+
We welcome contributions! Please follow these steps:
|
694
|
+
|
695
|
+
1. Fork the repository.
|
696
|
+
2. Create a new branch for your feature or bug fix: `git checkout -b feature/my-new-feature` or `bugfix/fix-issue-123`.
|
697
|
+
3. Make your changes, ensuring they adhere to the existing code style.
|
698
|
+
4. Write or update tests to cover your changes.
|
699
|
+
5. Ensure all tests pass (`npm test`).
|
700
|
+
6. Run lint and format checks (`npm run lint`, `npm run format`).
|
701
|
+
7. Write clear, concise commit messages following the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.
|
702
|
+
8. Push your branch and open a Pull Request to the `main` branch.
|
703
|
+
|
704
|
+
### Issue Reporting
|
705
|
+
|
706
|
+
Found a bug or have a feature request? Please open an issue on our [GitHub Issues page](https://github.com/ausaidimu/utils-issues).
|
707
|
+
When reporting a bug, please include:
|
708
|
+
|
709
|
+
* A clear and concise description of the issue.
|
710
|
+
* Steps to reproduce the behavior.
|
711
|
+
* Expected behavior.
|
712
|
+
* Screenshots or code snippets if applicable.
|
713
|
+
* Your environment details (Node.js version, OS, browser).
|
714
|
+
|
715
|
+
---
|
716
|
+
|
717
|
+
## 📚 Additional Information
|
718
|
+
|
719
|
+
### Troubleshooting
|
720
|
+
|
721
|
+
* **"No query registered for key: [key]" Error**: This means you tried to `get`, `prefetch`, or `refresh` a key that was not previously registered using `registerQuery`. Ensure you register all keys you intend to use.
|
722
|
+
* **Data not persisting**:
|
723
|
+
* Double-check that you passed a valid `persistence` instance to the `Cache` constructor.
|
724
|
+
* Verify your `SimplePersistence` implementation correctly saves and loads data.
|
725
|
+
* Ensure your data is properly serializable to JSON (or use `serializeValue`/`deserializeValue` options for complex types).
|
726
|
+
* Check for `persistence` event errors in your console.
|
727
|
+
* **Cache not evicting data**:
|
728
|
+
* Ensure `cacheTime` and `maxSize` are set to finite, non-zero values in `CacheOptions`. `Infinity` or `0` for `cacheTime` disables time-based GC.
|
729
|
+
* Verify the garbage collection interval is not too long (it's `cacheTime / 4` or `5 minutes` max).
|
730
|
+
* **Listeners not firing**: Ensure you're subscribing to the correct `CacheEventType` and that the cache operation (e.g., a `hit` or `error`) is actually occurring. Check `enableMetrics` isn't `false` if expecting metric-related events/updates.
|
731
|
+
|
732
|
+
### FAQ
|
733
|
+
|
734
|
+
**Q: How does `staleTime` differ from `cacheTime`?**
|
735
|
+
A: `staleTime` determines when data is considered "stale" and a background refetch should be triggered. You can still use stale data. `cacheTime` determines how long an item can remain unaccessed before it's eligible for garbage collection and removal from the cache. An item can be stale but still within its `cacheTime`.
|
736
|
+
|
737
|
+
**Q: When should I use `waitForFresh`?**
|
738
|
+
A: Use `waitForFresh: true` when your application absolutely needs the most up-to-date data before proceeding, and cannot tolerate serving stale or old data. This will block execution until the `fetchFunction` resolves. For most UI display purposes where latency is critical, `waitForFresh: false` (the default SWR behavior) is usually preferred.
|
739
|
+
|
740
|
+
**Q: Can I use `Cache` in a web worker?**
|
741
|
+
A: Yes, `Cache` is designed to be environment-agnostic. Its persistence mechanism is pluggable, so you can implement a `SimplePersistence` that works within a web worker (e.g., using IndexedDB directly or `postMessage` to the main thread).
|
742
|
+
|
743
|
+
**Q: Is `Cache` thread-safe (or safe with concurrent access)?**
|
744
|
+
A: `Cache` manages internal state with `Map`s and `Promise`s, which are atomic operations in JavaScript's single-threaded execution model. For concurrent `get` requests to the same key, it ensures only one `fetchFunction` runs via `this.fetching` map. It is safe for concurrent access within a single JavaScript runtime.
|
745
|
+
|
746
|
+
### License
|
747
|
+
|
748
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
749
|
+
|
750
|
+
### Acknowledgments
|
751
|
+
|
752
|
+
* Inspired by modern caching libraries like React Query and SWR.
|
753
|
+
* Uses `uuid` for generating unique cache IDs.
|