@asaidimu/utils-cache 1.0.0 → 2.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.
- package/README.md +400 -240
- package/package.json +3 -3
package/README.md
CHANGED
@@ -1,10 +1,10 @@
|
|
1
|
-
#
|
1
|
+
# `@asaidimu/utils-cache`
|
2
2
|
|
3
3
|
An intelligent, configurable in-memory cache library for Node.js and browser environments, designed for optimal performance, data consistency, and developer observability.
|
4
4
|
|
5
|
-
[](https://www.npmjs.com/package/@asaidimu/utils-cache)
|
6
6
|
[](LICENSE)
|
7
|
-
[](https://github.com/asaidimu/erp-utils/actions)
|
8
8
|
[](https://www.typescriptlang.org/)
|
9
9
|
|
10
10
|
---
|
@@ -48,27 +48,27 @@ An intelligent, configurable in-memory cache library for Node.js and browser env
|
|
48
48
|
|
49
49
|
### Detailed Description
|
50
50
|
|
51
|
-
`
|
51
|
+
`@asaidimu/utils-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 such as automatic retries for failed fetches, an extensible persistence mechanism, and a comprehensive event system for real-time monitoring.
|
52
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, `
|
53
|
+
Unlike simpler caches, `Cache` manages data freshness intelligently, allowing you to serve stale data immediately 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, `@asaidimu/utils-cache` offers deep insights into cache performance and lifecycle, ensuring both speed and data integrity.
|
54
54
|
|
55
55
|
### Key Features
|
56
56
|
|
57
|
-
* **Configurable In-Memory Store**:
|
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
|
60
|
-
* **Pluggable Persistence**:
|
61
|
-
* **Debounced Persistence Writes**: Optimizes write frequency to the underlying persistence layer.
|
62
|
-
* **Remote Update Handling**:
|
63
|
-
* **Custom Serialization/Deserialization**:
|
57
|
+
* **Configurable In-Memory Store**: Provides fast access to cached data with an underlying `Map` structure.
|
58
|
+
* **Stale-While-Revalidate (SWR)**: Serve existing data immediately while fetching new data in the background, minimizing perceived latency and improving user experience.
|
59
|
+
* **Automatic Retries with Exponential Backoff**: Configurable retry attempts and an exponentially increasing delay between retries for `fetchFunction` failures, enhancing resilience to transient network issues.
|
60
|
+
* **Pluggable Persistence**: Seamlessly integrates with any `SimplePersistence` implementation (e.g., LocalStorage, IndexedDB via `@asaidimu/utils-persistence`, or custom backend) to save and restore cache state across application restarts or sessions.
|
61
|
+
* **Debounced Persistence Writes**: Optimizes write frequency to the underlying persistence layer, reducing I/O operations and improving performance.
|
62
|
+
* **Remote Update Handling**: Automatically synchronizes cache state when the persistence layer is updated externally by other instances or processes.
|
63
|
+
* **Custom Serialization/Deserialization**: Provides options to serialize and deserialize complex data types (e.g., `Date`, `Map`, custom classes) for proper storage and retrieval.
|
64
64
|
* **Configurable Eviction Policies**:
|
65
|
-
* **Time-Based (TTL)**: Automatically evicts entries
|
66
|
-
* **Size-Based (LRU)**: Evicts least recently used items when `maxSize` is exceeded.
|
67
|
-
* **Comprehensive Event System**: Subscribe to granular cache events (hit
|
68
|
-
* **Performance Metrics**: Built-in tracking for hits
|
69
|
-
* **Flexible Query Management**: Register `fetchFunction`s for specific keys, allowing `Cache` to manage their lifecycle.
|
70
|
-
* **Imperative Control**:
|
71
|
-
* **TypeScript Support**: Fully typed API for enhanced developer experience.
|
65
|
+
* **Time-Based (TTL)**: Automatically evicts entries that haven't been accessed for a specified `cacheTime`, managing memory efficiently.
|
66
|
+
* **Size-Based (LRU)**: Evicts least recently used items when the `maxSize` limit is exceeded, preventing unbounded memory growth.
|
67
|
+
* **Comprehensive Event System**: Subscribe to granular cache events (e.g., `'hit'`, `'miss'`, `'fetch'`, `'error'`, `'eviction'`, `'invalidation'`, `'set_data'`, `'persistence'`) for real-time logging, debugging, analytics, and advanced reactivity.
|
68
|
+
* **Performance Metrics**: Built-in tracking for `hits`, `misses`, `fetches`, `errors`, `evictions`, and `staleHits`, providing insights into cache efficiency with calculated hit rates.
|
69
|
+
* **Flexible Query Management**: Register asynchronous `fetchFunction`s for specific keys, allowing the `Cache` instance to intelligently manage their data lifecycle, including fetching, caching, and invalidation.
|
70
|
+
* **Imperative Control**: Offers direct methods for `invalidate` (making data stale), `prefetch` (loading data proactively), `refresh` (forcing a re-fetch), `setData` (manual data injection), and `remove` operations.
|
71
|
+
* **TypeScript Support**: Fully typed API for enhanced developer experience, compile-time safety, and autocompletion.
|
72
72
|
|
73
73
|
---
|
74
74
|
|
@@ -77,47 +77,60 @@ Unlike simpler caches, `Cache` manages data freshness intelligently, allowing yo
|
|
77
77
|
### Prerequisites
|
78
78
|
|
79
79
|
* Node.js (v14.x or higher)
|
80
|
-
* npm or
|
80
|
+
* npm, yarn, or bun
|
81
81
|
|
82
82
|
### Installation Steps
|
83
83
|
|
84
|
-
Install `
|
84
|
+
Install `@asaidimu/utils-cache` using your preferred package manager:
|
85
85
|
|
86
86
|
```bash
|
87
|
-
bun add @
|
87
|
+
bun add @asaidimu/utils-cache
|
88
|
+
# or
|
89
|
+
npm install @asaidimu/utils-cache
|
90
|
+
# or
|
91
|
+
yarn add @asaidimu/utils-cache
|
88
92
|
```
|
89
93
|
|
90
94
|
### Configuration
|
91
95
|
|
92
|
-
`Cache` is initialized with a `CacheOptions` object, allowing you to customize its behavior globally. Individual queries can override these options.
|
96
|
+
`Cache` is initialized with a `CacheOptions` object, allowing you to customize its behavior globally. Individual queries registered via `registerQuery` can override these options for specific data keys.
|
93
97
|
|
94
98
|
```typescript
|
95
|
-
import { Cache } from '@
|
96
|
-
|
97
|
-
|
99
|
+
import { Cache } from '@asaidimu/utils-cache';
|
100
|
+
// Example persistence layer (install separately, e.g., @asaidimu/utils-persistence)
|
101
|
+
import { IndexedDBPersistence } from '@asaidimu/utils-persistence'; // Example
|
98
102
|
|
99
103
|
const myCache = new Cache({
|
100
|
-
staleTime: 5 * 60 * 1000,
|
101
|
-
cacheTime: 30 * 60 * 1000,
|
102
|
-
retryAttempts: 2,
|
103
|
-
retryDelay: 2000,
|
104
|
-
maxSize: 500,
|
105
|
-
enableMetrics: true,
|
106
|
-
|
104
|
+
staleTime: 5 * 60 * 1000, // Data considered stale after 5 minutes (5 * 60 * 1000ms)
|
105
|
+
cacheTime: 30 * 60 * 1000, // Data evicted if not accessed for 30 minutes
|
106
|
+
retryAttempts: 2, // Retry fetch up to 2 times on failure
|
107
|
+
retryDelay: 2000, // 2-second initial delay between retries (doubles each attempt)
|
108
|
+
maxSize: 500, // Maximum 500 entries in cache (LRU eviction)
|
109
|
+
enableMetrics: true, // Enable performance tracking
|
110
|
+
|
111
|
+
// Persistence options (optional but recommended for stateful caches)
|
112
|
+
persistence: new IndexedDBPersistence('my-app-db'), // Plug in your persistence layer
|
107
113
|
persistenceId: 'my-app-cache-v1', // Unique ID for this cache instance in persistence
|
108
114
|
persistenceDebounceTime: 1000, // Debounce persistence writes by 1 second
|
109
|
-
|
110
|
-
|
115
|
+
|
116
|
+
// Custom serializers/deserializers for non-JSON-serializable data (optional)
|
117
|
+
serializeValue: (value: any) => {
|
111
118
|
if (value instanceof Map) return { _type: 'Map', data: Array.from(value.entries()) };
|
119
|
+
if (value instanceof Date) return { _type: 'Date', data: value.toISOString() };
|
112
120
|
return value;
|
113
121
|
},
|
114
|
-
deserializeValue: (value) => {
|
115
|
-
if (typeof value === 'object' && value !== null
|
116
|
-
return new Map(value.data);
|
122
|
+
deserializeValue: (value: any) => {
|
123
|
+
if (typeof value === 'object' && value !== null) {
|
124
|
+
if (value._type === 'Map') return new Map(value.data);
|
125
|
+
if (value._type === 'Date') return new Date(value.data);
|
117
126
|
}
|
118
127
|
return value;
|
119
128
|
},
|
120
129
|
});
|
130
|
+
|
131
|
+
// Negative option values are automatically clamped to 0 with a console warning.
|
132
|
+
const invalidCache = new Cache({ staleTime: -100, cacheTime: -1, maxSize: -5 });
|
133
|
+
// console.warn output for each negative value will appear
|
121
134
|
```
|
122
135
|
|
123
136
|
### Verification
|
@@ -125,15 +138,22 @@ const myCache = new Cache({
|
|
125
138
|
To verify that `Cache` is installed and initialized correctly, you can run a simple test:
|
126
139
|
|
127
140
|
```typescript
|
128
|
-
import { Cache } from '@
|
141
|
+
import { Cache } from '@asaidimu/utils-cache';
|
129
142
|
|
130
143
|
const cache = new Cache();
|
131
144
|
console.log('Cache initialized successfully!');
|
132
145
|
|
133
|
-
//
|
134
|
-
cache.registerQuery('hello', async () =>
|
146
|
+
// Register a simple query
|
147
|
+
cache.registerQuery('hello', async () => {
|
148
|
+
console.log('Fetching "hello" data...');
|
149
|
+
return 'world';
|
150
|
+
});
|
151
|
+
|
152
|
+
// Try to fetch data
|
135
153
|
cache.get('hello').then(data => {
|
136
|
-
console.log(`Fetched 'hello': ${data}`);
|
154
|
+
console.log(`Fetched 'hello': ${data}`); // Expected: Fetching "hello" data... \n Fetched 'hello': world
|
155
|
+
}).catch(error => {
|
156
|
+
console.error('Error fetching:', error);
|
137
157
|
});
|
138
158
|
```
|
139
159
|
|
@@ -143,47 +163,54 @@ cache.get('hello').then(data => {
|
|
143
163
|
|
144
164
|
### Basic Usage
|
145
165
|
|
146
|
-
The core of `Cache` involves registering queries and then retrieving data using those queries.
|
166
|
+
The core of `Cache` involves registering queries (data fetching functions) and then retrieving data using those queries.
|
147
167
|
|
148
168
|
```typescript
|
149
|
-
import { Cache } from '@
|
169
|
+
import { Cache } from '@asaidimu/utils-cache';
|
150
170
|
|
151
171
|
const myCache = new Cache({
|
152
|
-
staleTime: 5000,
|
153
|
-
cacheTime: 60000, // 1 minute
|
172
|
+
staleTime: 5000, // Data becomes stale after 5 seconds
|
173
|
+
cacheTime: 60000, // Data will be garbage collected if not accessed for 1 minute
|
154
174
|
});
|
155
175
|
|
156
|
-
// 1. Register a query with a unique key and
|
176
|
+
// 1. Register a query with a unique string key and an async function to fetch the data.
|
157
177
|
myCache.registerQuery('user/123', async () => {
|
158
|
-
console.log('Fetching user data...');
|
178
|
+
console.log('--- Fetching user data from API... ---');
|
159
179
|
// Simulate network delay
|
160
180
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
161
181
|
return { id: 123, name: 'Alice', email: 'alice@example.com' };
|
162
|
-
});
|
182
|
+
}, { staleTime: 2000 }); // Override staleTime for this specific query to 2 seconds
|
163
183
|
|
164
|
-
// 2. Retrieve data from the cache
|
165
|
-
|
166
|
-
|
167
|
-
console.log(
|
184
|
+
// 2. Retrieve data from the cache using `get()`.
|
185
|
+
|
186
|
+
async function getUserData(label: string) {
|
187
|
+
console.log(`\n${label}: Requesting user/123`);
|
188
|
+
const userData = await myCache.get('user/123'); // Default: stale-while-revalidate
|
189
|
+
console.log(`${label}: User data received:`, userData);
|
168
190
|
}
|
169
191
|
|
170
|
-
// First call:
|
171
|
-
getUserData();
|
192
|
+
// First call: Data is not in cache (miss). Triggers fetch.
|
193
|
+
getUserData('Initial Call');
|
172
194
|
|
173
|
-
// Subsequent calls (within staleTime): Data is returned instantly from cache
|
174
|
-
setTimeout(() => getUserData(), 500);
|
195
|
+
// Subsequent calls (within staleTime): Data is returned instantly from cache. No fetch.
|
196
|
+
setTimeout(() => getUserData('Cached Call'), 500);
|
175
197
|
|
176
|
-
// Call after staleTime: Data is returned instantly, but a background fetch is triggered
|
177
|
-
setTimeout(() => getUserData(),
|
198
|
+
// Call after query's staleTime: Data is returned instantly, but a background fetch is triggered.
|
199
|
+
setTimeout(() => getUserData('Stale & Background Fetch'), 2500);
|
178
200
|
|
179
201
|
// Example of waiting for fresh data
|
180
202
|
async function getFreshUserData() {
|
181
|
-
console.log('\
|
182
|
-
|
183
|
-
|
203
|
+
console.log('\n--- Requesting FRESH user data (waiting for fetch)... ---');
|
204
|
+
try {
|
205
|
+
const freshUserData = await myCache.get('user/123', { waitForFresh: true });
|
206
|
+
console.log('Fresh user data received:', freshUserData);
|
207
|
+
} catch (error) {
|
208
|
+
console.error('Failed to get fresh user data:', error);
|
209
|
+
}
|
184
210
|
}
|
185
211
|
|
186
|
-
|
212
|
+
// This will wait for the background fetch triggered by the previous call (if still ongoing) or trigger a new one.
|
213
|
+
setTimeout(() => getFreshUserData(), 3000);
|
187
214
|
```
|
188
215
|
|
189
216
|
### API Usage
|
@@ -193,7 +220,7 @@ setTimeout(() => getFreshUserData(), 7000);
|
|
193
220
|
Creates a new `Cache` instance with global default options.
|
194
221
|
|
195
222
|
```typescript
|
196
|
-
import { Cache } from '@
|
223
|
+
import { Cache } from '@asaidimu/utils-cache';
|
197
224
|
|
198
225
|
const cache = new Cache({
|
199
226
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
@@ -204,20 +231,20 @@ const cache = new Cache({
|
|
204
231
|
|
205
232
|
#### `cache.registerQuery<T>(key: string, fetchFunction: () => Promise<T>, options?: CacheOptions): void`
|
206
233
|
|
207
|
-
Registers a data fetching function associated with a `key`. This
|
234
|
+
Registers a data fetching function associated with a unique `key`. This `fetchFunction` will be called when data for the `key` is not in cache, is stale, or explicitly invalidated/refreshed.
|
208
235
|
|
209
236
|
- `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.
|
237
|
+
- `fetchFunction`: An `async` function that returns a `Promise` resolving to the data of type `T`.
|
238
|
+
- `options`: Optional `CacheOptions` to override the instance's default options for this specific query (e.g., a shorter `staleTime` for frequently changing data).
|
212
239
|
|
213
240
|
```typescript
|
214
241
|
cache.registerQuery('products/featured', async () => {
|
215
|
-
const response = await fetch('
|
242
|
+
const response = await fetch('https://api.example.com/products/featured');
|
216
243
|
if (!response.ok) throw new Error('Failed to fetch featured products');
|
217
244
|
return response.json();
|
218
245
|
}, {
|
219
246
|
staleTime: 60 * 1000, // This query's data is stale after 1 minute
|
220
|
-
retryAttempts: 5,
|
247
|
+
retryAttempts: 5, // It will retry fetching up to 5 times
|
221
248
|
});
|
222
249
|
```
|
223
250
|
|
@@ -226,10 +253,10 @@ cache.registerQuery('products/featured', async () => {
|
|
226
253
|
Retrieves data for a given `key`.
|
227
254
|
|
228
255
|
- 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
|
232
|
-
- `throwOnError`: If `true`, and the
|
256
|
+
- If data is stale (and `waitForFresh` is `false` or unset), returns it immediately and triggers a background refetch (stale-while-revalidate).
|
257
|
+
- If data is not in cache (miss), it triggers a fetch.
|
258
|
+
- `waitForFresh`: If `true`, the method will await the `fetchFunction` 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.
|
259
|
+
- `throwOnError`: If `true`, and the `fetchFunction` fails after all retries, 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
260
|
|
234
261
|
```typescript
|
235
262
|
// Basic usage (stale-while-revalidate)
|
@@ -238,17 +265,18 @@ const post = await cache.get('posts/latest');
|
|
238
265
|
// Wait for fresh data, throw if fetch fails
|
239
266
|
try {
|
240
267
|
const userProfile = await cache.get('user/profile', { waitForFresh: true, throwOnError: true });
|
268
|
+
console.log('Latest user profile:', userProfile);
|
241
269
|
} catch (error) {
|
242
|
-
console.error('Could not get fresh user profile:', error);
|
270
|
+
console.error('Could not get fresh user profile due to an error:', error);
|
243
271
|
}
|
244
272
|
```
|
245
273
|
|
246
274
|
#### `cache.peek<T>(key: string): T | undefined`
|
247
275
|
|
248
|
-
Retrieves data from the cache without triggering any fetches
|
276
|
+
Retrieves data from the cache without triggering any fetches, updating `lastAccessed` time, or `accessCount`. Useful for quick synchronous checks.
|
249
277
|
|
250
278
|
```typescript
|
251
|
-
const cachedValue = cache.peek('some-key');
|
279
|
+
const cachedValue = cache.peek('some-config-key');
|
252
280
|
if (cachedValue) {
|
253
281
|
console.log('Value is in cache:', cachedValue);
|
254
282
|
} else {
|
@@ -263,72 +291,77 @@ Checks if a non-stale, non-loading entry exists in the cache for the given `key`
|
|
263
291
|
```typescript
|
264
292
|
if (cache.has('config/app')) {
|
265
293
|
console.log('App config is ready and fresh.');
|
294
|
+
} else {
|
295
|
+
console.log('App config is missing, stale, or currently loading.');
|
266
296
|
}
|
267
297
|
```
|
268
298
|
|
269
|
-
#### `cache.invalidate(key: string, refetch
|
299
|
+
#### `cache.invalidate(key: string, refetch = true): Promise<void>`
|
270
300
|
|
271
|
-
Marks
|
301
|
+
Marks a specific cache entry as stale, forcing the next `get` call for that key to trigger a refetch. Optionally triggers an immediate background refetch.
|
272
302
|
|
273
303
|
- `key`: The cache key to invalidate.
|
274
|
-
- `refetch`: If `true` (default), triggers an immediate background fetch for the invalidated key
|
304
|
+
- `refetch`: If `true` (default), triggers an immediate background fetch for the invalidated key using its registered `fetchFunction`.
|
275
305
|
|
276
306
|
```typescript
|
277
|
-
//
|
278
|
-
await cache.invalidate('user/123/
|
307
|
+
// After updating a user, invalidate their profile data to ensure next fetch is fresh
|
308
|
+
await cache.invalidate('user/123/profile');
|
279
309
|
|
280
|
-
// Invalidate and don't refetch until `get` is called
|
310
|
+
// Invalidate and don't refetch until `get` is explicitly called later
|
281
311
|
await cache.invalidate('admin/dashboard/stats', false);
|
282
312
|
```
|
283
313
|
|
284
|
-
#### `cache.invalidatePattern(pattern: RegExp, refetch
|
314
|
+
#### `cache.invalidatePattern(pattern: RegExp, refetch = true): Promise<void>`
|
285
315
|
|
286
|
-
Invalidates all cache entries whose keys match the given regular expression.
|
316
|
+
Invalidates all cache entries whose keys match the given regular expression. Similar to `invalidate`, it optionally triggers immediate background refetches for all matched keys.
|
287
317
|
|
288
|
-
- `pattern`: A `RegExp` to match cache keys.
|
318
|
+
- `pattern`: A `RegExp` object to match against cache keys.
|
289
319
|
- `refetch`: If `true` (default), triggers immediate background fetches for all matched keys.
|
290
320
|
|
291
321
|
```typescript
|
292
|
-
// Invalidate all product-related data
|
293
|
-
await cache.invalidatePattern(/^products\//);
|
322
|
+
// Invalidate all product-related data (e.g., after a mass product update)
|
323
|
+
await cache.invalidatePattern(/^products\//); // Matches 'products/1', 'products/list', etc.
|
324
|
+
|
325
|
+
// Invalidate all items containing 'temp' in their key, without immediate refetch
|
326
|
+
await cache.invalidatePattern(/temp/, false);
|
294
327
|
```
|
295
328
|
|
296
329
|
#### `cache.prefetch(key: string): Promise<void>`
|
297
330
|
|
298
|
-
Triggers a background fetch for a `key` if it's not already in cache or is stale. Useful for loading data proactively.
|
331
|
+
Triggers a background fetch for a `key` if it's not already in cache or is stale. Useful for loading data proactively before it's explicitly requested.
|
299
332
|
|
300
333
|
```typescript
|
301
|
-
// On
|
334
|
+
// On application startup or route change, prefetch common data
|
302
335
|
cache.prefetch('static-content/footer');
|
303
336
|
cache.prefetch('user/notifications/unread');
|
304
337
|
```
|
305
338
|
|
306
339
|
#### `cache.refresh<T>(key: string): Promise<T | undefined>`
|
307
340
|
|
308
|
-
Forces a re-fetch of data for a given `key`, bypassing staleness checks and existing fetch promises. Returns the fresh data.
|
341
|
+
Forces a re-fetch of data for a given `key`, bypassing staleness checks and any existing fetch promises. This ensures you always get the latest data. Returns the fresh data or `undefined` if the fetch ultimately fails.
|
309
342
|
|
310
343
|
```typescript
|
311
|
-
//
|
344
|
+
// After an API call modifies a resource, force update its cached version
|
312
345
|
const updatedUser = await cache.refresh('user/current');
|
313
346
|
console.log('User data refreshed:', updatedUser);
|
314
347
|
```
|
315
348
|
|
316
349
|
#### `cache.setData<T>(key: string, data: T): void`
|
317
350
|
|
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.
|
351
|
+
Manually sets or updates data in the cache for a given `key`. This immediately updates the cache entry, marks it as fresh (by setting `lastUpdated` to `Date.now()`), and triggers persistence if configured. It bypasses any registered `fetchFunction`.
|
319
352
|
|
320
353
|
```typescript
|
321
|
-
// Manually update a shopping cart item count
|
354
|
+
// Manually update a shopping cart item count after a local UI interaction
|
322
355
|
cache.setData('cart/item-count', 5);
|
323
356
|
|
324
|
-
// Directly inject data fetched from another source
|
325
|
-
const
|
326
|
-
cache.setData('
|
357
|
+
// Directly inject data fetched from another source or computed locally
|
358
|
+
const localConfig = { theme: 'dark', fontSize: 'medium' };
|
359
|
+
cache.setData('app/settings', localConfig);
|
327
360
|
```
|
328
361
|
|
329
362
|
#### `cache.remove(key: string): boolean`
|
330
363
|
|
331
|
-
Removes
|
364
|
+
Removes a specific entry from the cache. Returns `true` if an entry was found and removed, `false` otherwise. Also clears any ongoing fetches for that key and triggers persistence.
|
332
365
|
|
333
366
|
```typescript
|
334
367
|
// When a user logs out, remove their specific session data
|
@@ -337,107 +370,124 @@ cache.remove('user/session');
|
|
337
370
|
|
338
371
|
#### `cache.on<EType extends CacheEventType>(event: EType, listener: (ev: Extract<CacheEvent, { type: EType }>) => void): void`
|
339
372
|
|
340
|
-
Subscribes to cache events.
|
373
|
+
Subscribes a listener function to specific cache events.
|
341
374
|
|
342
|
-
- `event`: The type of event to listen for (e.g., `'hit'`, `'miss'`, `'error'`).
|
343
|
-
- `listener`: A callback function that receives the event payload.
|
375
|
+
- `event`: The type of event to listen for (e.g., `'hit'`, `'miss'`, `'error'`, `'persistence'`). See `CacheEventType` in `types.ts` for all available types.
|
376
|
+
- `listener`: A callback function that receives the specific event payload for the subscribed event type.
|
344
377
|
|
345
378
|
```typescript
|
346
|
-
import { Cache, CacheEvent, CacheEventType } from '@
|
379
|
+
import { Cache, CacheEvent, CacheEventType } from '@asaidimu/utils-cache';
|
347
380
|
|
348
381
|
const myCache = new Cache();
|
349
382
|
|
350
383
|
myCache.on('hit', (e) => {
|
351
|
-
console.log(`
|
384
|
+
console.log(`[CacheEvent] HIT for ${e.key} (isStale: ${e.isStale})`);
|
352
385
|
});
|
353
386
|
|
354
387
|
myCache.on('miss', (e) => {
|
355
|
-
console.log(`
|
388
|
+
console.log(`[CacheEvent] MISS for ${e.key}`);
|
356
389
|
});
|
357
390
|
|
358
391
|
myCache.on('error', (e) => {
|
359
|
-
console.error(`
|
392
|
+
console.error(`[CacheEvent] ERROR for ${e.key} (attempt ${e.attempt}):`, e.error.message);
|
360
393
|
});
|
361
394
|
|
362
395
|
myCache.on('persistence', (e) => {
|
363
396
|
if (e.event === 'save_success') {
|
364
|
-
console.log(`Cache state saved successfully for ID: ${e.key}`);
|
397
|
+
console.log(`[CacheEvent] Persistence: Cache state saved successfully for ID: ${e.key}`);
|
365
398
|
} else if (e.event === 'load_fail') {
|
366
|
-
console.error(`Failed to load cache state for ID: ${e.key}`, e.error);
|
399
|
+
console.error(`[CacheEvent] Persistence: Failed to load cache state for ID: ${e.key}`, e.error);
|
400
|
+
} else if (e.event === 'remote_update') {
|
401
|
+
console.log(`[CacheEvent] Persistence: Cache state updated from remote source for ID: ${e.key}`);
|
367
402
|
}
|
368
403
|
});
|
404
|
+
|
405
|
+
// For demonstration, register a query and trigger events
|
406
|
+
myCache.registerQuery('demo-item', async () => {
|
407
|
+
console.log('--- Fetching demo-item ---');
|
408
|
+
await new Promise(r => setTimeout(r, 200));
|
409
|
+
return 'demo-data';
|
410
|
+
}, { staleTime: 100 });
|
411
|
+
|
412
|
+
myCache.get('demo-item'); // Triggers miss, fetch, set_data
|
413
|
+
setTimeout(() => myCache.get('demo-item'), 50); // Triggers hit
|
414
|
+
setTimeout(() => myCache.get('demo-item'), 150); // Triggers stale hit, background fetch
|
369
415
|
```
|
370
416
|
|
371
417
|
#### `cache.off<EType extends CacheEventType>(event: EType, listener: (ev: Extract<CacheEvent, { type: EType }>) => void): void`
|
372
418
|
|
373
|
-
Unsubscribes a listener from a cache event.
|
419
|
+
Unsubscribes a previously registered listener from a cache event. The `listener` reference must be the exact same function that was passed to `on()`.
|
374
420
|
|
375
421
|
```typescript
|
376
|
-
const
|
377
|
-
myCache.on('hit',
|
378
|
-
|
379
|
-
|
422
|
+
const myHitLogger = (e: any) => console.log(`[Log] Cache Hit: ${e.key}`);
|
423
|
+
myCache.on('hit', myHitLogger);
|
424
|
+
|
425
|
+
// Later, when you no longer need the listener:
|
426
|
+
myCache.off('hit', myHitLogger);
|
380
427
|
```
|
381
428
|
|
382
|
-
#### `cache.getStats(): { size: number; metrics: CacheMetrics; hitRate: number; staleHitRate: number; entries: Array<
|
429
|
+
#### `cache.getStats(): { size: number; metrics: CacheMetrics; hitRate: number; staleHitRate: number; entries: Array<{ key: string; lastAccessed: number; lastUpdated: number; accessCount: number; isStale: boolean; isLoading?: boolean; error?: boolean }> }`
|
383
430
|
|
384
|
-
Returns current cache statistics and metrics.
|
431
|
+
Returns current cache statistics and detailed metrics.
|
385
432
|
|
386
|
-
- `size`: Number of entries in the cache.
|
387
|
-
- `metrics`:
|
433
|
+
- `size`: Number of active entries in the cache.
|
434
|
+
- `metrics`: An object containing raw counts (`hits`, `misses`, `fetches`, `errors`, `evictions`, `staleHits`).
|
388
435
|
- `hitRate`: Ratio of hits to total requests (hits + misses).
|
389
436
|
- `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).
|
437
|
+
- `entries`: An array of objects providing details for each cached item (key, lastAccessed, lastUpdated, accessCount, isStale, isLoading, error status).
|
391
438
|
|
392
439
|
```typescript
|
393
440
|
const stats = myCache.getStats();
|
394
441
|
console.log('Cache Size:', stats.size);
|
395
442
|
console.log('Metrics:', stats.metrics);
|
396
|
-
console.log('Hit Rate:', (stats.hitRate * 100).toFixed(2) + '%');
|
443
|
+
console.log('Overall Hit Rate:', (stats.hitRate * 100).toFixed(2) + '%');
|
444
|
+
console.log('Entries details:', stats.entries);
|
397
445
|
```
|
398
446
|
|
399
447
|
#### `cache.clear(): Promise<void>`
|
400
448
|
|
401
|
-
Clears all data from the in-memory cache and attempts to clear the persisted state.
|
449
|
+
Clears all data from the in-memory cache, resets metrics, and attempts to clear the associated persisted state via the `persistence` layer.
|
402
450
|
|
403
451
|
```typescript
|
452
|
+
console.log('Clearing cache...');
|
404
453
|
await myCache.clear();
|
405
|
-
console.log('Cache cleared.');
|
454
|
+
console.log('Cache cleared. Current size:', myCache.getStats().size);
|
406
455
|
```
|
407
456
|
|
408
457
|
#### `cache.destroy(): void`
|
409
458
|
|
410
|
-
Shuts down the cache instance, clearing all data, stopping garbage collection,
|
459
|
+
Shuts down the cache instance, clearing all data, stopping the automatic garbage collection timer, unsubscribing from persistence updates, and clearing all internal maps. Call this when the cache instance is no longer needed (e.g., on application shutdown or component unmount) to prevent memory leaks and ensure proper cleanup.
|
411
460
|
|
412
461
|
```typescript
|
413
462
|
myCache.destroy();
|
414
|
-
console.log('Cache instance destroyed.');
|
463
|
+
console.log('Cache instance destroyed. All timers stopped and data cleared.');
|
415
464
|
```
|
416
465
|
|
417
466
|
### Configuration Examples
|
418
467
|
|
419
|
-
The `CacheOptions` interface provides extensive control:
|
468
|
+
The `CacheOptions` interface provides extensive control over the cache's behavior:
|
420
469
|
|
421
470
|
```typescript
|
422
|
-
import { CacheOptions, SimplePersistence, SerializableCacheState } from '@
|
471
|
+
import { CacheOptions, SimplePersistence, SerializableCacheState } from '@asaidimu/utils-cache';
|
423
472
|
|
424
|
-
// A mock persistence layer for demonstration
|
473
|
+
// A mock persistence layer for demonstration purposes.
|
474
|
+
// In a real application, you'd use an actual implementation like IndexedDBPersistence.
|
425
475
|
class MockPersistence implements SimplePersistence<SerializableCacheState> {
|
426
476
|
private store = new Map<string, SerializableCacheState>();
|
427
477
|
private subscribers = new Map<string, Array<(data: SerializableCacheState) => void>>();
|
428
478
|
|
429
479
|
async get(id: string): Promise<SerializableCacheState | undefined> {
|
430
|
-
console.log(`[MockPersistence] Getting state for ${id}`);
|
480
|
+
console.log(`[MockPersistence] Getting state for ID: ${id}`);
|
431
481
|
return this.store.get(id);
|
432
482
|
}
|
433
483
|
async set(id: string, data: SerializableCacheState): Promise<void> {
|
434
|
-
console.log(`[MockPersistence] Setting state for ${id}`);
|
484
|
+
console.log(`[MockPersistence] Setting state for ID: ${id}`);
|
435
485
|
this.store.set(id, data);
|
436
|
-
// Simulate remote update to
|
486
|
+
// Simulate remote update notification to all subscribed instances
|
437
487
|
this.subscribers.get(id)?.forEach(cb => cb(data));
|
438
488
|
}
|
439
489
|
async clear(id?: string): Promise<void> {
|
440
|
-
console.log(`[MockPersistence] Clearing state ${id ? 'for ' + id : 'all'}`);
|
490
|
+
console.log(`[MockPersistence] Clearing state ${id ? 'for ID: ' + id : '(all)'}`);
|
441
491
|
if (id) {
|
442
492
|
this.store.delete(id);
|
443
493
|
} else {
|
@@ -445,44 +495,55 @@ class MockPersistence implements SimplePersistence<SerializableCacheState> {
|
|
445
495
|
}
|
446
496
|
}
|
447
497
|
subscribe(id: string, callback: (data: SerializableCacheState) => void): () => void {
|
448
|
-
console.log(`[MockPersistence] Subscribing to ${id}`);
|
498
|
+
console.log(`[MockPersistence] Subscribing to ID: ${id}`);
|
449
499
|
if (!this.subscribers.has(id)) {
|
450
500
|
this.subscribers.set(id, []);
|
451
501
|
}
|
452
502
|
this.subscribers.get(id)?.push(callback);
|
503
|
+
// Return unsubscribe function
|
453
504
|
return () => {
|
454
505
|
const callbacks = this.subscribers.get(id);
|
455
506
|
if (callbacks) {
|
456
507
|
this.subscribers.set(id, callbacks.filter(cb => cb !== callback));
|
457
508
|
}
|
509
|
+
console.log(`[MockPersistence] Unsubscribed from ID: ${id}`);
|
458
510
|
};
|
459
511
|
}
|
460
512
|
}
|
461
513
|
|
462
|
-
|
463
514
|
const fullOptions: CacheOptions = {
|
464
|
-
staleTime: 1000 * 60 * 5,
|
465
|
-
cacheTime: 1000 * 60 * 60,
|
466
|
-
retryAttempts: 3,
|
467
|
-
retryDelay: 1000,
|
468
|
-
maxSize: 2000,
|
469
|
-
enableMetrics: true,
|
470
|
-
persistence: new MockPersistence(), // Provide an instance of your persistence layer
|
471
|
-
persistenceId: 'my-unique-cache-instance', //
|
472
|
-
persistenceDebounceTime: 750, // Wait 750ms before writing to persistence
|
515
|
+
staleTime: 1000 * 60 * 5, // 5 minutes: After this time, data is stale; a background fetch is considered.
|
516
|
+
cacheTime: 1000 * 60 * 60, // 1 hour: Items idle (not accessed) for this long are eligible for garbage collection.
|
517
|
+
retryAttempts: 3, // Max 3 fetch attempts (initial + 2 retries) on network/fetch failures.
|
518
|
+
retryDelay: 1000, // 1 second initial delay for retries (doubles each subsequent attempt).
|
519
|
+
maxSize: 2000, // Keep up to 2000 entries; LRU eviction kicks in beyond this limit.
|
520
|
+
enableMetrics: true, // Enable performance tracking (hits, misses, fetches, etc.).
|
521
|
+
persistence: new MockPersistence(), // Provide an instance of your persistence layer implementation.
|
522
|
+
persistenceId: 'my-unique-cache-instance', // A unique identifier for this cache instance within the persistence store.
|
523
|
+
persistenceDebounceTime: 750, // Wait 750ms after a cache change before writing to persistence to batch writes.
|
524
|
+
|
525
|
+
// Custom serializers/deserializers for data that isn't natively JSON serializable (e.g., Maps, Dates, custom classes).
|
473
526
|
serializeValue: (value: any) => {
|
474
527
|
// Example: Convert Date objects to ISO strings for JSON serialization
|
475
528
|
if (value instanceof Date) {
|
476
529
|
return { _type: 'Date', data: value.toISOString() };
|
477
530
|
}
|
478
|
-
|
531
|
+
// Example: Convert Map objects to an array for JSON serialization
|
532
|
+
if (value instanceof Map) {
|
533
|
+
return { _type: 'Map', data: Array.from(value.entries()) };
|
534
|
+
}
|
535
|
+
return value; // Return as is for other types
|
479
536
|
},
|
480
537
|
deserializeValue: (value: any) => {
|
481
538
|
// Example: Convert ISO strings back to Date objects
|
482
539
|
if (typeof value === 'object' && value !== null && value._type === 'Date') {
|
483
540
|
return new Date(value.data);
|
484
541
|
}
|
485
|
-
|
542
|
+
// Example: Convert array back to Map objects
|
543
|
+
if (typeof value === 'object' && value !== null && value._type === 'Map') {
|
544
|
+
return new Map(value.data);
|
545
|
+
}
|
546
|
+
return value; // Return as is for other types
|
486
547
|
},
|
487
548
|
};
|
488
549
|
|
@@ -491,81 +552,96 @@ const configuredCache = new Cache(fullOptions);
|
|
491
552
|
|
492
553
|
### Common Use Cases
|
493
554
|
|
494
|
-
#### Caching API Responses with SWR
|
555
|
+
#### Caching API Responses with SWR (Stale-While-Revalidate)
|
556
|
+
|
557
|
+
This is the default and most common pattern, where you prioritize immediate responsiveness while ensuring data freshness in the background.
|
495
558
|
|
496
559
|
```typescript
|
497
|
-
import { Cache } from '@
|
560
|
+
import { Cache } from '@asaidimu/utils-cache';
|
498
561
|
|
499
562
|
const apiCache = new Cache({
|
500
|
-
staleTime: 5 * 60 * 1000,
|
501
|
-
cacheTime: 30 * 60 * 1000, //
|
502
|
-
retryAttempts: 3,
|
563
|
+
staleTime: 5 * 60 * 1000, // Data considered stale after 5 minutes
|
564
|
+
cacheTime: 30 * 60 * 1000, // Idle data garbage collected after 30 minutes
|
565
|
+
retryAttempts: 3, // Retry fetching on network failures
|
503
566
|
});
|
504
567
|
|
568
|
+
// Register a query for a list of blog posts
|
505
569
|
apiCache.registerQuery('blog/posts', async () => {
|
506
|
-
console.log('Fetching ALL blog posts from API...');
|
570
|
+
console.log('--- Fetching ALL blog posts from API... ---');
|
507
571
|
const response = await fetch('https://api.example.com/blog/posts');
|
508
572
|
if (!response.ok) throw new Error('Failed to fetch blog posts');
|
509
573
|
return response.json();
|
510
574
|
});
|
511
575
|
|
512
|
-
//
|
513
|
-
async function displayBlogPosts() {
|
514
|
-
|
576
|
+
// Function to display blog posts
|
577
|
+
async function displayBlogPosts(source: string) {
|
578
|
+
console.log(`\nDisplaying blog posts from: ${source}`);
|
579
|
+
const posts = await apiCache.get('blog/posts'); // Uses SWR by default
|
515
580
|
if (posts) {
|
516
|
-
console.log(
|
581
|
+
console.log(`Received ${posts.length} posts (first 2):`, posts.slice(0, 2).map((p: any) => p.title));
|
517
582
|
} else {
|
518
|
-
console.log('No posts yet, waiting for fetch...');
|
583
|
+
console.log('No posts yet, waiting for initial fetch...');
|
519
584
|
}
|
520
585
|
}
|
521
586
|
|
522
|
-
displayBlogPosts(); // First
|
523
|
-
setTimeout(() => displayBlogPosts(),
|
524
|
-
setTimeout(() => displayBlogPosts(),
|
587
|
+
displayBlogPosts('Initial Load'); // First `get`: cache miss, triggers fetch.
|
588
|
+
setTimeout(() => displayBlogPosts('After 1 sec (cached)'), 1000); // Second `get`: cache hit, returns instantly.
|
589
|
+
setTimeout(() => displayBlogPosts('After 6 mins (stale & background fetch)'), 6 * 60 * 1000); // After `staleTime`: returns cached, triggers background fetch.
|
525
590
|
```
|
526
591
|
|
527
592
|
#### Using `waitForFresh` for Critical Data
|
528
593
|
|
594
|
+
For scenarios where serving outdated data is unacceptable (e.g., user permissions, critical configuration).
|
595
|
+
|
529
596
|
```typescript
|
530
|
-
import { Cache } from '@
|
597
|
+
import { Cache } from '@asaidimu/utils-cache';
|
531
598
|
|
532
599
|
const criticalCache = new Cache({ retryAttempts: 5, retryDelay: 1000 });
|
533
600
|
|
534
601
|
criticalCache.registerQuery('user/permissions', async () => {
|
535
|
-
console.log('Fetching user permissions...');
|
536
|
-
|
537
|
-
if (
|
538
|
-
|
602
|
+
console.log('--- Fetching user permissions from API... ---');
|
603
|
+
// Simulate potential network flakiness
|
604
|
+
if (Math.random() > 0.7) {
|
605
|
+
throw new Error('Network error during permission fetch!');
|
606
|
+
}
|
607
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
608
|
+
return { canEdit: true, canDelete: false, roles: ['user', 'editor'] };
|
539
609
|
});
|
540
610
|
|
541
611
|
async function checkPermissionsBeforeAction() {
|
612
|
+
console.log('\nAttempting to get FRESH user permissions...');
|
542
613
|
try {
|
543
|
-
// We MUST have the latest permissions before proceeding
|
614
|
+
// We MUST have the latest permissions before proceeding with a sensitive action
|
544
615
|
const permissions = await criticalCache.get('user/permissions', { waitForFresh: true, throwOnError: true });
|
545
|
-
console.log('User permissions:', permissions);
|
616
|
+
console.log('User permissions received:', permissions);
|
546
617
|
// Proceed with action based on permissions
|
547
618
|
} catch (error) {
|
548
|
-
console.error('Failed to load
|
549
|
-
// Redirect to error page or
|
619
|
+
console.error('CRITICAL: Failed to load user permissions:', error);
|
620
|
+
// Redirect to error page, show critical alert, or disable functionality
|
550
621
|
}
|
551
622
|
}
|
552
623
|
|
553
624
|
checkPermissionsBeforeAction();
|
625
|
+
// You might call this repeatedly in a test scenario to see retries and eventual success/failure
|
626
|
+
setInterval(() => checkPermissionsBeforeAction(), 3000);
|
554
627
|
```
|
555
628
|
|
556
629
|
#### Real-time Monitoring with Events
|
557
630
|
|
631
|
+
Utilize the comprehensive event system to log, monitor, or react to cache lifecycle events.
|
632
|
+
|
558
633
|
```typescript
|
559
|
-
import { Cache } from '@
|
634
|
+
import { Cache } from '@asaidimu/utils-cache';
|
560
635
|
|
561
636
|
const monitorCache = new Cache({ enableMetrics: true });
|
562
637
|
|
563
638
|
monitorCache.registerQuery('stock/AAPL', async () => {
|
564
639
|
const price = Math.random() * 100 + 150;
|
565
|
-
console.log(
|
640
|
+
console.log(`--- Fetching AAPL price: $${price.toFixed(2)} ---`);
|
566
641
|
return { symbol: 'AAPL', price: parseFloat(price.toFixed(2)), timestamp: Date.now() };
|
567
642
|
}, { staleTime: 1000 }); // Very short staleTime for frequent fetches
|
568
643
|
|
644
|
+
// Subscribe to various cache events
|
569
645
|
monitorCache.on('fetch', (e) => {
|
570
646
|
console.log(`[EVENT] Fetching ${e.key} (attempt ${e.attempt})`);
|
571
647
|
});
|
@@ -579,20 +655,31 @@ monitorCache.on('eviction', (e) => {
|
|
579
655
|
console.log(`[EVENT] Evicted ${e.key} due to ${e.reason}`);
|
580
656
|
});
|
581
657
|
monitorCache.on('set_data', (e) => {
|
582
|
-
console.log(`[EVENT] Data for ${e.key} manually set. Old: ${e.oldData?.price}, New: ${e.newData.price}`);
|
658
|
+
console.log(`[EVENT] Data for ${e.key} manually set. Old price: ${e.oldData?.price}, New price: ${e.newData.price}`);
|
659
|
+
});
|
660
|
+
monitorCache.on('persistence', (e) => {
|
661
|
+
if (e.event === 'save_success') console.log(`[EVENT] Persistence: ${e.message || 'Save successful'}`);
|
583
662
|
});
|
584
663
|
|
664
|
+
|
665
|
+
// Continuously try to get data (will trigger fetches due to short staleTime)
|
585
666
|
setInterval(() => {
|
586
667
|
monitorCache.get('stock/AAPL');
|
587
|
-
}, 500);
|
668
|
+
}, 500);
|
669
|
+
|
670
|
+
// Manually set data to trigger 'set_data' and 'persistence' events
|
671
|
+
setTimeout(() => {
|
672
|
+
monitorCache.setData('stock/AAPL', { symbol: 'AAPL', price: 160.00, timestamp: Date.now() });
|
673
|
+
}, 3000);
|
588
674
|
|
675
|
+
// Log cache statistics periodically
|
589
676
|
setInterval(() => {
|
590
677
|
const stats = monitorCache.getStats();
|
591
|
-
console.log(`\n--- STATS ---`);
|
678
|
+
console.log(`\n--- CACHE STATS ---`);
|
592
679
|
console.log(`Size: ${stats.size}, Hits: ${stats.metrics.hits}, Misses: ${stats.metrics.misses}, Fetches: ${stats.metrics.fetches}`);
|
593
680
|
console.log(`Hit Rate: ${(stats.hitRate * 100).toFixed(2)}%, Stale Hit Rate: ${(stats.staleHitRate * 100).toFixed(2)}%`);
|
594
681
|
console.log(`Active entries: ${stats.entries.map(e => `${e.key} (stale:${e.isStale})`).join(', ')}`);
|
595
|
-
console.log(
|
682
|
+
console.log(`-------------------\n`);
|
596
683
|
}, 5000); // Log stats every 5 seconds
|
597
684
|
```
|
598
685
|
|
@@ -600,117 +687,173 @@ setInterval(() => {
|
|
600
687
|
|
601
688
|
## 🏗️ Project Architecture
|
602
689
|
|
690
|
+
The `@asaidimu/utils-cache` library is structured to provide a clear separation of concerns, making it modular, testable, and extensible.
|
691
|
+
|
692
|
+
### Directory Structure
|
693
|
+
|
694
|
+
```
|
695
|
+
src/cache/
|
696
|
+
├── cache.ts # Main Cache class implementation
|
697
|
+
├── index.ts # Entry point for the module (re-exports Cache)
|
698
|
+
├── types.ts # TypeScript interfaces and types for options, entries, events, etc.
|
699
|
+
└── cache.test.ts # Unit tests for the Cache class
|
700
|
+
package.json # Package metadata and dependencies for this specific module
|
701
|
+
```
|
702
|
+
|
603
703
|
### Core Components
|
604
704
|
|
605
|
-
* **`Cache` Class (`
|
606
|
-
*
|
607
|
-
*
|
608
|
-
*
|
609
|
-
*
|
610
|
-
*
|
611
|
-
*
|
705
|
+
* **`Cache` Class (`cache.ts`)**: The central component of the library. It orchestrates all caching logic, including:
|
706
|
+
* Managing the in-memory `Map` (`this.cache`) that stores `CacheEntry` objects.
|
707
|
+
* Handling data fetching, retries, and staleness checks.
|
708
|
+
* Implementing time-based (TTL) and size-based (LRU) garbage collection.
|
709
|
+
* Integrating with the pluggable persistence layer.
|
710
|
+
* Emitting detailed cache events.
|
711
|
+
* Tracking performance metrics.
|
712
|
+
* **`CacheOptions` (`types.ts`)**: An interface defining the configurable parameters for a `Cache` instance or individual queries. This includes `staleTime`, `cacheTime`, `retryAttempts`, `maxSize`, persistence settings, and custom serialization/deserialization functions.
|
713
|
+
* **`CacheEntry` (`types.ts`)**: Represents a single item stored within the cache. It encapsulates the actual `data`, `lastUpdated` and `lastAccessed` timestamps, `accessCount`, and flags like `isLoading` or `error` status.
|
714
|
+
* **`QueryConfig` (`types.ts`)**: Stores the `fetchFunction` and the resolved `CacheOptions` (merged with instance defaults) for each registered query, enabling tailored behavior per data key.
|
715
|
+
* **`CacheMetrics` (`types.ts`)**: Defines the structure for tracking cache performance statistics, including hits, misses, fetches, errors, and evictions.
|
716
|
+
* **`SimplePersistence<SerializableCacheState>` (from `@asaidimu/utils-persistence`)**: An external interface that `Cache` relies on for persistent storage. It requires implementations of `get()`, `set()`, `clear()`, and optionally `subscribe()` methods to handle data serialization and deserialization for the specific storage medium (e.g., IndexedDB, LocalStorage, or a remote backend).
|
717
|
+
* **`CacheEvent` / `CacheEventType` (`types.ts`)**: A union type defining all possible events emitted by the cache (e.g., `'hit'`, `'miss'`, `'fetch'`, `'error'`, `'eviction'`, `'invalidation'`, `'set_data'`, `'persistence'`). This enables a fine-grained observability model for the cache's lifecycle.
|
612
718
|
|
613
719
|
### Data Flow
|
614
720
|
|
615
|
-
1. **Initialization**:
|
616
|
-
|
721
|
+
1. **Initialization**:
|
722
|
+
* The `Cache` constructor sets up global default options, initializes performance metrics, and starts the automatic garbage collection timer.
|
723
|
+
* If a `persistence` layer is configured, it attempts to load a previously saved state using `persistence.get()`.
|
724
|
+
* It then subscribes to `persistence.subscribe()` (if available) to listen for remote state changes from the underlying storage, ensuring cache consistency across multiple instances or processes.
|
725
|
+
|
726
|
+
2. **`registerQuery`**:
|
727
|
+
* When `registerQuery(key, fetchFunction, options)` is called, the `fetchFunction` and its specific `options` (merged with the global `defaultOptions`) are stored internally in the `this.queries` map. This prepares the cache to handle requests for that `key`.
|
728
|
+
|
617
729
|
3. **`get` Request**:
|
618
|
-
*
|
619
|
-
* If
|
620
|
-
|
621
|
-
|
622
|
-
|
623
|
-
* If no entry,
|
624
|
-
|
730
|
+
* When `get(key, options)` is invoked, `Cache` first checks `this.cache` for an existing `CacheEntry` for the `key`.
|
731
|
+
* **Cache Hit**: If an entry exists, `lastAccessed` and `accessCount` are updated, a `'hit'` event is emitted, and metrics are incremented. The entry's staleness is evaluated based on `staleTime`.
|
732
|
+
* If `waitForFresh` is `true` OR if the entry is stale/loading, it proceeds to `fetchAndWait`.
|
733
|
+
* If `waitForFresh` is `false` (default) and the entry is stale, the cached data is returned immediately, and a background `fetch` is triggered to update the data.
|
734
|
+
* If `waitForFresh` is `false` and the entry is fresh, the cached data is returned immediately.
|
735
|
+
* **Cache Miss**: If no entry exists, a `'miss'` event is emitted. A placeholder `CacheEntry` (marked `isLoading`) is created, and a `fetch` is immediately triggered to retrieve the data.
|
736
|
+
|
625
737
|
4. **`fetch` / `fetchAndWait`**:
|
626
|
-
*
|
627
|
-
*
|
738
|
+
* These methods ensure that only one `fetchFunction` runs concurrently for a given `key` by tracking ongoing fetches in `this.fetching`.
|
739
|
+
* They delegate the actual data retrieval and retry logic to `performFetchWithRetry`.
|
740
|
+
|
628
741
|
5. **`performFetchWithRetry`**:
|
629
|
-
*
|
630
|
-
*
|
631
|
-
* On
|
632
|
-
* On
|
633
|
-
|
634
|
-
|
635
|
-
|
636
|
-
|
742
|
+
* This is where the registered `fetchFunction` is executed. It attempts to call the `fetchFunction` multiple times (up to `retryAttempts`) with exponential backoff (`retryDelay`).
|
743
|
+
* Before each attempt, a `'fetch'` event is emitted, and `fetches` metrics are updated.
|
744
|
+
* **On Success**: The `CacheEntry` is updated with the new `data`, `lastUpdated` timestamp, and its `isLoading` status is set to `false`. The cache then calls `schedulePersistState()` to save the updated state and `enforceSizeLimit()` to maintain the `maxSize`.
|
745
|
+
* **On Failure**: If the `fetchFunction` fails, an `'error'` event is emitted, and `errors` metrics are updated. If `retryAttempts` are remaining, it waits (`delay`) and retries. After all attempts, the `CacheEntry` is updated with the last `error`, `isLoading` is set to `false`, and `schedulePersistState()` is called.
|
746
|
+
|
747
|
+
6. **`schedulePersistState`**:
|
748
|
+
* This method debounces write operations to the `persistence` layer. It prevents excessive writes by waiting for a configurable `persistenceDebounceTime` before serializing the current cache state (using `serializeCache` and `serializeValue`) and writing it via `persistence.set()`. Appropriate `'persistence'` events (`save_success`/`save_fail`) are emitted.
|
749
|
+
|
750
|
+
7. **`handleRemoteStateChange`**:
|
751
|
+
* This callback is invoked by the `persistence` layer's `subscribe` mechanism when an external change to the persisted state is detected. It deserializes the `remoteState` (using `deserializeValue`) and intelligently updates the local `this.cache` to reflect these external changes, emitting a `'persistence'` event (`remote_update`).
|
752
|
+
|
753
|
+
8. **`garbageCollect`**:
|
754
|
+
* Running on a `setInterval` timer (`gcTimer`), this method periodically scans `this.cache`. It removes any `CacheEntry` that has not been `lastAccessed` for longer than its (or global) `cacheTime`, emitting `'eviction'` events.
|
755
|
+
|
756
|
+
9. **`enforceSizeLimit`**:
|
757
|
+
* Triggered after successful data updates (`fetch` success or `setData`). If the `cache.size` exceeds `maxSize`, it evicts the Least Recently Used (LRU) entries until the `maxSize` is satisfied, emitting `'eviction'` events.
|
637
758
|
|
638
759
|
### Extension Points
|
639
760
|
|
640
|
-
|
641
|
-
|
642
|
-
*
|
761
|
+
The design of `@asaidimu/utils-cache` provides several powerful extension points for customization and integration:
|
762
|
+
|
763
|
+
* **`SimplePersistence` Interface**: This is the primary mechanism for integrating `Cache` with various storage backends. By implementing this interface, you can use `Cache` with `localStorage`, `IndexedDB` (e.g., via `@asaidimu/utils-persistence`), a custom database, a server-side cache, or any other persistent storage solution.
|
764
|
+
* **`serializeValue` / `deserializeValue` Options**: These functions within `CacheOptions` allow you to define custom logic for how your specific data types are converted to and from a serializable format (e.g., JSON-compatible strings or objects) before being passed to and received from the `persistence` layer. This is crucial for handling `Date` objects, `Map`s, `Set`s, or custom class instances.
|
765
|
+
* **Event Listeners (`on`/`off`)**: The comprehensive event system allows you to subscribe to a wide range of cache lifecycle events. This enables powerful integrations for:
|
766
|
+
* **Logging**: Detailed logging of cache activity (hits, misses, errors, evictions).
|
767
|
+
* **Analytics**: Feeding cache performance metrics into an analytics platform.
|
768
|
+
* **UI Reactivity**: Updating UI components in response to cache changes (e.g., showing a "stale data" indicator or a "refreshing" spinner).
|
769
|
+
* **Debugging**: Gaining deep insights into cache behavior during development.
|
770
|
+
* **External Synchronization**: Triggering side effects or synchronizing with other systems based on cache events.
|
643
771
|
|
644
772
|
---
|
645
773
|
|
646
774
|
## 🤝 Development & Contributing
|
647
775
|
|
776
|
+
We welcome contributions to `@asaidimu/utils-cache`! Whether it's a bug fix, a new feature, or an improvement to the documentation, your help is appreciated.
|
777
|
+
|
648
778
|
### Development Setup
|
649
779
|
|
650
|
-
To set up the development environment
|
780
|
+
To set up the development environment for `@asaidimu/utils-cache`:
|
651
781
|
|
652
|
-
1. **Clone the
|
782
|
+
1. **Clone the monorepo:**
|
783
|
+
```bash
|
784
|
+
git clone https://github.com/asaidimu/erp-utils.git
|
785
|
+
cd erp-utils
|
786
|
+
```
|
787
|
+
2. **Navigate to the cache package:**
|
653
788
|
```bash
|
654
|
-
|
655
|
-
cd utils-src/cache
|
789
|
+
cd src/cache
|
656
790
|
```
|
657
|
-
|
791
|
+
3. **Install dependencies:**
|
658
792
|
```bash
|
659
793
|
npm install
|
660
794
|
# or
|
661
795
|
yarn install
|
796
|
+
# or
|
797
|
+
bun install
|
662
798
|
```
|
663
|
-
|
799
|
+
4. **Build the project:**
|
664
800
|
```bash
|
665
801
|
npm run build
|
666
802
|
# or
|
667
803
|
yarn build
|
804
|
+
# or
|
805
|
+
bun run build
|
668
806
|
```
|
669
807
|
|
670
808
|
### Scripts
|
671
809
|
|
672
|
-
The following `npm` scripts are available:
|
810
|
+
The following `npm` scripts are typically available in this project's setup:
|
673
811
|
|
674
|
-
* `npm run build`: Compiles TypeScript source files to JavaScript
|
675
|
-
* `npm run test`: Runs the test suite
|
676
|
-
* `npm run
|
677
|
-
* `npm run
|
812
|
+
* `npm run build`: Compiles TypeScript source files from `src/` to JavaScript output in `dist/`.
|
813
|
+
* `npm run test`: Runs the test suite using `Vitest`.
|
814
|
+
* `npm run test:watch`: Runs tests in watch mode for continuous feedback during development.
|
815
|
+
* `npm run lint`: Runs ESLint to check for code style and potential errors.
|
816
|
+
* `npm run format`: Formats code using Prettier according to the project's style guidelines.
|
678
817
|
|
679
818
|
### Testing
|
680
819
|
|
681
|
-
Tests are written using
|
820
|
+
Tests are written using **Vitest**. To run tests:
|
682
821
|
|
683
822
|
```bash
|
684
823
|
npm test
|
685
824
|
# or
|
686
825
|
yarn test
|
826
|
+
# or
|
827
|
+
bun test
|
687
828
|
```
|
688
829
|
|
689
|
-
We aim for high test coverage. Please ensure new features or bug fixes come with appropriate tests.
|
830
|
+
We aim for high test coverage. Please ensure that new features or bug fixes come with appropriate unit and/or integration tests to maintain code quality and prevent regressions.
|
690
831
|
|
691
832
|
### Contributing Guidelines
|
692
833
|
|
693
|
-
|
834
|
+
Please follow these steps to contribute:
|
694
835
|
|
695
|
-
1. Fork the repository.
|
696
|
-
2. Create a new branch for your feature or bug fix: `git checkout -b feature/my-
|
697
|
-
3. Make your changes
|
698
|
-
4. Write or update tests to cover your changes.
|
699
|
-
5. Ensure all tests pass
|
700
|
-
6. Run lint and format checks (`npm run lint
|
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
|
836
|
+
1. **Fork the repository** on GitHub.
|
837
|
+
2. **Create a new branch** for your feature or bug fix: `git checkout -b feature/my-awesome-feature` or `bugfix/resolve-issue-123`.
|
838
|
+
3. **Make your changes**, ensuring they adhere to the existing code style and architecture.
|
839
|
+
4. **Write or update tests** to cover your changes and ensure existing functionality is not broken.
|
840
|
+
5. **Ensure all tests pass** locally by running `npm test`.
|
841
|
+
6. **Run lint and format checks** (`npm run lint` and `npm run format`) and fix any reported issues.
|
842
|
+
7. **Write clear, concise commit messages** following the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification (e.g., `feat: add new caching strategy`, `fix: correct staleTime calculation`).
|
843
|
+
8. **Push your branch** to your fork.
|
844
|
+
9. **Open a Pull Request** to the `main` branch of the original repository. Provide a detailed description of your changes and why they are necessary.
|
703
845
|
|
704
846
|
### Issue Reporting
|
705
847
|
|
706
|
-
Found a bug
|
848
|
+
Found a bug, have a feature request, or need clarification? Please open an issue on our [GitHub Issues page](https://github.com/asaidimu/erp-utils/issues).
|
849
|
+
|
707
850
|
When reporting a bug, please include:
|
708
851
|
|
709
852
|
* A clear and concise description of the issue.
|
710
|
-
*
|
711
|
-
*
|
712
|
-
*
|
713
|
-
* Your environment details (Node.js version, OS, browser).
|
853
|
+
* Detailed steps to reproduce the behavior.
|
854
|
+
* The expected behavior.
|
855
|
+
* Any relevant screenshots or code snippets.
|
856
|
+
* Your environment details (Node.js version, OS, browser, package version).
|
714
857
|
|
715
858
|
---
|
716
859
|
|
@@ -718,30 +861,47 @@ When reporting a bug, please include:
|
|
718
861
|
|
719
862
|
### Troubleshooting
|
720
863
|
|
721
|
-
* **"No query registered for key: [key]" Error**:
|
864
|
+
* **"No query registered for key: [key]" Error**:
|
865
|
+
* **Cause**: This error occurs if you try to `get()`, `prefetch()`, or `refresh()` a `key` that has not been previously associated with a `fetchFunction` using `cache.registerQuery()`.
|
866
|
+
* **Solution**: Ensure you call `cache.registerQuery(key, fetchFunction)` for every `key` you intend to use with the cache before attempting to retrieve data.
|
722
867
|
* **Data not persisting**:
|
723
|
-
*
|
724
|
-
*
|
725
|
-
|
726
|
-
|
868
|
+
* **Cause**: The cache state is not being correctly saved or loaded from the underlying storage.
|
869
|
+
* **Solution**:
|
870
|
+
1. **`persistence` instance**: Double-check that you are passing a valid `SimplePersistence` instance to the `Cache` constructor's `persistence` option.
|
871
|
+
2. **`persistenceId`**: Ensure you've provided a unique `persistenceId` if multiple cache instances share the same persistence layer.
|
872
|
+
3. **Serialization**: Verify that your data types are correctly handled by `serializeValue` and `deserializeValue` options, especially for non-JSON-serializable types like `Map`s, `Date` objects, or custom classes.
|
873
|
+
4. **Persistence Layer**: Confirm your `SimplePersistence` implementation correctly handles `get()`, `set()`, `clear()`, and `subscribe()` operations for the specific storage medium (e.g., local storage quota, IndexedDB permissions).
|
874
|
+
5. **Event Errors**: Check for `persistence` event errors in your browser's or Node.js console (`cache.on('persistence', ...)`).
|
727
875
|
* **Cache not evicting data**:
|
728
|
-
*
|
729
|
-
*
|
730
|
-
|
876
|
+
* **Cause**: Eviction policies might be disabled or configured with very long durations.
|
877
|
+
* **Solution**:
|
878
|
+
1. **`cacheTime`**: Ensure `cacheTime` in `CacheOptions` is set to a finite, non-zero positive number (in milliseconds). `Infinity` or `0` for `cacheTime` disables time-based garbage collection.
|
879
|
+
2. **`maxSize`**: Ensure `maxSize` is set to a finite, non-zero positive number. `Infinity` disables size-based LRU eviction, and `0` means the cache will always be empty (evicting immediately).
|
880
|
+
3. **Garbage Collection Interval**: The garbage collection runs periodically. While generally sufficient, verify that `cacheTime` isn't so large that you rarely hit the GC interval.
|
881
|
+
* **Event listeners not firing**:
|
882
|
+
* **Cause**: The listener might be removed, or the expected event is not actually occurring.
|
883
|
+
* **Solution**:
|
884
|
+
1. **Correct Event Type**: Ensure you are subscribing to the exact `CacheEventType` you expect (e.g., `'hit'`, `'error'`).
|
885
|
+
2. **`enableMetrics`**: If you expect metric-related events or updates, ensure `enableMetrics` is not set to `false` in your `CacheOptions`.
|
886
|
+
3. **Listener Reference**: When using `off()`, ensure the listener function is the exact same reference passed to `on()`.
|
731
887
|
|
732
888
|
### FAQ
|
733
889
|
|
734
890
|
**Q: How does `staleTime` differ from `cacheTime`?**
|
735
|
-
A: `staleTime` determines when data is considered "stale"
|
891
|
+
A: `staleTime` determines when a cached data entry is considered "stale." Once `Date.now() - entry.lastUpdated` exceeds `staleTime`, the data is marked stale. If `waitForFresh` is `false` (default), `get()` will return the stale data immediately while triggering a background refetch. `cacheTime`, on the other hand, determines how long an item can remain unaccessed (idle) before it's eligible for garbage collection and removal from the cache. An item can be stale but still within its `cacheTime`.
|
736
892
|
|
737
893
|
**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.
|
894
|
+
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` successfully resolves. For most UI display purposes where latency is critical and a slightly outdated display is acceptable, `waitForFresh: false` (the default SWR behavior) is usually preferred, as it provides an immediate response.
|
739
895
|
|
740
896
|
**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
|
897
|
+
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 communicating with the main thread via `postMessage`).
|
742
898
|
|
743
899
|
**Q: Is `Cache` thread-safe (or safe with concurrent access)?**
|
744
|
-
A: `Cache` manages internal state with `Map`s and `Promise`s
|
900
|
+
A: JavaScript is single-threaded. `Cache` manages its internal state with `Map`s and `Promise`s. For concurrent `get` requests to the *same key*, it ensures only one `fetchFunction` runs via the `this.fetching` map, preventing redundant fetches. Therefore, it is safe for concurrent access within a single JavaScript runtime context. For multiple JavaScript runtimes (e.g., different browser tabs or Node.js processes), the `persistence` layer's `subscribe` mechanism handles synchronization.
|
901
|
+
|
902
|
+
### Changelog/Roadmap
|
903
|
+
|
904
|
+
For a detailed history of changes, new features, and bug fixes, please refer to the [CHANGELOG.md](CHANGELOG.md) file in the repository root. Our future plans are outlined in the [ROADMAP.md](ROADMAP.md) (if available).
|
745
905
|
|
746
906
|
### License
|
747
907
|
|
@@ -749,5 +909,5 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|
749
909
|
|
750
910
|
### Acknowledgments
|
751
911
|
|
752
|
-
* Inspired by modern caching libraries like React Query and SWR.
|
753
|
-
* Uses `uuid` for generating unique cache IDs.
|
912
|
+
* Inspired by modern data fetching and caching libraries like [React Query](https://react-query.tanstack.com/) and [SWR](https://swr.vercel.app/).
|
913
|
+
* Uses the `uuid` library for generating unique cache instance IDs.
|
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@asaidimu/utils-cache",
|
3
|
-
"version": "
|
3
|
+
"version": "2.0.1",
|
4
4
|
"description": "Caching utilities for @asaidimu applications.",
|
5
5
|
"main": "index.js",
|
6
6
|
"module": "index.mjs",
|
@@ -29,7 +29,7 @@
|
|
29
29
|
"access": "public"
|
30
30
|
},
|
31
31
|
"dependencies": {
|
32
|
-
"@asaidimu/utils-persistence": "
|
32
|
+
"@asaidimu/utils-persistence": "2.0.0",
|
33
33
|
"uuid": "^11.1.0"
|
34
34
|
},
|
35
35
|
"exports": {
|
@@ -49,7 +49,7 @@
|
|
49
49
|
[
|
50
50
|
"@semantic-release/npm",
|
51
51
|
{
|
52
|
-
"pkgRoot": "
|
52
|
+
"pkgRoot": "./dist"
|
53
53
|
}
|
54
54
|
],
|
55
55
|
[
|