2.0.4 • Published 5 months ago

@asaidimu/utils-cache v2.0.4

Weekly downloads
-
License
MIT
Repository
github
Last release
5 months ago

@asaidimu/utils-cache

An intelligent, configurable in-memory cache library for Node.js and browser environments, designed for optimal performance, data consistency, and developer observability.

npm version License Build Status TypeScript


šŸš€ Quick Links


šŸ“¦ Overview & Features

Detailed Description

@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.

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.

Key Features

  • Configurable In-Memory Store: Provides fast access to cached data with an underlying Map structure.
  • Stale-While-Revalidate (SWR): Serve existing data immediately while fetching new data in the background, minimizing perceived latency and improving user experience.
  • Automatic Retries with Exponential Backoff: Configurable retry attempts and an exponentially increasing delay between retries for fetchFunction failures, enhancing resilience to transient network issues.
  • 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.
    • Debounced Persistence Writes: Optimizes write frequency to the underlying persistence layer, reducing I/O operations and improving performance.
    • Remote Update Handling: Automatically synchronizes cache state when the persistence layer is updated externally by other instances or processes.
    • Custom Serialization/Deserialization: Provides options to serialize and deserialize complex data types (e.g., Date, Map, custom classes) for proper storage and retrieval.
  • Configurable Eviction Policies:
    • Time-Based (TTL): Automatically evicts entries that haven't been accessed for a specified cacheTime, managing memory efficiently.
    • Size-Based (LRU): Evicts least recently used items when the maxSize limit is exceeded, preventing unbounded memory growth.
  • 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.
  • Performance Metrics: Built-in tracking for hits, misses, fetches, errors, evictions, and staleHits, providing insights into cache efficiency with calculated hit rates.
  • Flexible Query Management: Register asynchronous fetchFunctions for specific keys, allowing the Cache instance to intelligently manage their data lifecycle, including fetching, caching, and invalidation.
  • 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.
  • TypeScript Support: Fully typed API for enhanced developer experience, compile-time safety, and autocompletion.

šŸ› ļø Installation & Setup

Prerequisites

  • Node.js (v14.x or higher)
  • npm, yarn, or bun

Installation Steps

Install @asaidimu/utils-cache using your preferred package manager:

bun add @asaidimu/utils-cache
# or
npm install @asaidimu/utils-cache
# or
yarn add @asaidimu/utils-cache

Configuration

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.

import { Cache } from '@asaidimu/utils-cache';
// Example persistence layer (install separately, e.g., @asaidimu/utils-persistence)
import { IndexedDBPersistence } from '@asaidimu/utils-persistence'; // Example

const myCache = new Cache({
  staleTime: 5 * 60 * 1000,   // Data considered stale after 5 minutes (5 * 60 * 1000ms)
  cacheTime: 30 * 60 * 1000,  // Data evicted if not accessed for 30 minutes
  retryAttempts: 2,           // Retry fetch up to 2 times on failure
  retryDelay: 2000,           // 2-second initial delay between retries (doubles each attempt)
  maxSize: 500,               // Maximum 500 entries in cache (LRU eviction)
  enableMetrics: true,        // Enable performance tracking
  
  // Persistence options (optional but recommended for stateful caches)
  persistence: new IndexedDBPersistence('my-app-db'), // Plug in your persistence layer
  persistenceId: 'my-app-cache-v1', // Unique ID for this cache instance in persistence
  persistenceDebounceTime: 1000, // Debounce persistence writes by 1 second

  // Custom serializers/deserializers for non-JSON-serializable data (optional)
  serializeValue: (value: any) => {
    if (value instanceof Map) return { _type: 'Map', data: Array.from(value.entries()) };
    if (value instanceof Date) return { _type: 'Date', data: value.toISOString() };
    return value;
  },
  deserializeValue: (value: any) => {
    if (typeof value === 'object' && value !== null) {
      if (value._type === 'Map') return new Map(value.data);
      if (value._type === 'Date') return new Date(value.data);
    }
    return value;
  },
});

// Negative option values are automatically clamped to 0 with a console warning.
const invalidCache = new Cache({ staleTime: -100, cacheTime: -1, maxSize: -5 });
// console.warn output for each negative value will appear

Verification

To verify that Cache is installed and initialized correctly, you can run a simple test:

import { Cache } from '@asaidimu/utils-cache';

const cache = new Cache();
console.log('Cache initialized successfully!');

// Register a simple query
cache.registerQuery('hello', async () => {
  console.log('Fetching "hello" data...');
  return 'world';
});

// Try to fetch data
cache.get('hello').then(data => {
  console.log(`Fetched 'hello': ${data}`); // Expected: Fetching "hello" data... \n Fetched 'hello': world
}).catch(error => {
  console.error('Error fetching:', error);
});

šŸ“– Usage Documentation

Basic Usage

The core of Cache involves registering queries (data fetching functions) and then retrieving data using those queries.

import { Cache } from '@asaidimu/utils-cache';

const myCache = new Cache({
  staleTime: 5000,  // Data becomes stale after 5 seconds
  cacheTime: 60000, // Data will be garbage collected if not accessed for 1 minute
});

// 1. Register a query with a unique string key and an async function to fetch the data.
myCache.registerQuery('user/123', async () => {
  console.log('--- Fetching user data from API... ---');
  // Simulate network delay
  await new Promise(resolve => setTimeout(resolve, 1000));
  return { id: 123, name: 'Alice', email: 'alice@example.com' };
}, { staleTime: 2000 }); // Override staleTime for this specific query to 2 seconds

// 2. Retrieve data from the cache using `get()`.

async function getUserData(label: string) {
  console.log(`\n${label}: Requesting user/123`);
  const userData = await myCache.get('user/123'); // Default: stale-while-revalidate
  console.log(`${label}: User data received:`, userData);
}

// First call: Data is not in cache (miss). Triggers fetch.
getUserData('Initial Call');

// Subsequent calls (within staleTime): Data is returned instantly from cache. No fetch.
setTimeout(() => getUserData('Cached Call'), 500);

// Call after query's staleTime: Data is returned instantly, but a background fetch is triggered.
setTimeout(() => getUserData('Stale & Background Fetch'), 2500);

// Example of waiting for fresh data
async function getFreshUserData() {
  console.log('\n--- Requesting FRESH user data (waiting for fetch)... ---');
  try {
    const freshUserData = await myCache.get('user/123', { waitForFresh: true });
    console.log('Fresh user data received:', freshUserData);
  } catch (error) {
    console.error('Failed to get fresh user data:', error);
  }
}

// This will wait for the background fetch triggered by the previous call (if still ongoing) or trigger a new one.
setTimeout(() => getFreshUserData(), 3000);

API Usage

new Cache(defaultOptions?: CacheOptions)

Creates a new Cache instance with global default options.

import { Cache } from '@asaidimu/utils-cache';

const cache = new Cache({
  staleTime: 5 * 60 * 1000, // 5 minutes
  cacheTime: 30 * 60 * 1000, // 30 minutes
  maxSize: 1000,
});

cache.registerQuery<T>(key: string, fetchFunction: () => Promise<T>, options?: CacheOptions): void

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.

  • key: A unique string identifier for the data.
  • fetchFunction: An async function that returns a Promise resolving to the data of type T.
  • options: Optional CacheOptions to override the instance's default options for this specific query (e.g., a shorter staleTime for frequently changing data).
cache.registerQuery('products/featured', async () => {
  const response = await fetch('https://api.example.com/products/featured');
  if (!response.ok) throw new Error('Failed to fetch featured products');
  return response.json();
}, {
  staleTime: 60 * 1000, // This query's data is stale after 1 minute
  retryAttempts: 5,     // It will retry fetching up to 5 times
});

cache.get<T>(key: string, options?: { waitForFresh?: boolean; throwOnError?: boolean }): Promise<T | undefined>

Retrieves data for a given key.

  • If data is fresh, returns it immediately.
  • If data is stale (and waitForFresh is false or unset), returns it immediately and triggers a background refetch (stale-while-revalidate).
  • If data is not in cache (miss), it triggers a fetch.
  • 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.
  • 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.
// Basic usage (stale-while-revalidate)
const post = await cache.get('posts/latest');

// Wait for fresh data, throw if fetch fails
try {
  const userProfile = await cache.get('user/profile', { waitForFresh: true, throwOnError: true });
  console.log('Latest user profile:', userProfile);
} catch (error) {
  console.error('Could not get fresh user profile due to an error:', error);
}

cache.peek<T>(key: string): T | undefined

Retrieves data from the cache without triggering any fetches, updating lastAccessed time, or accessCount. Useful for quick synchronous checks.

const cachedValue = cache.peek('some-config-key');
if (cachedValue) {
  console.log('Value is in cache:', cachedValue);
} else {
  console.log('Value not found in cache.');
}

cache.has(key: string): boolean

Checks if a non-stale, non-loading entry exists in the cache for the given key.

if (cache.has('config/app')) {
  console.log('App config is ready and fresh.');
} else {
  console.log('App config is missing, stale, or currently loading.');
}

cache.invalidate(key: string, refetch = true): Promise<void>

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.

  • key: The cache key to invalidate.
  • refetch: If true (default), triggers an immediate background fetch for the invalidated key using its registered fetchFunction.
// After updating a user, invalidate their profile data to ensure next fetch is fresh
await cache.invalidate('user/123/profile');

// Invalidate and don't refetch until `get` is explicitly called later
await cache.invalidate('admin/dashboard/stats', false);

cache.invalidatePattern(pattern: RegExp, refetch = true): Promise<void>

Invalidates all cache entries whose keys match the given regular expression. Similar to invalidate, it optionally triggers immediate background refetches for all matched keys.

  • pattern: A RegExp object to match against cache keys.
  • refetch: If true (default), triggers immediate background fetches for all matched keys.
// Invalidate all product-related data (e.g., after a mass product update)
await cache.invalidatePattern(/^products\//); // Matches 'products/1', 'products/list', etc.

// Invalidate all items containing 'temp' in their key, without immediate refetch
await cache.invalidatePattern(/temp/, false);

cache.prefetch(key: string): Promise<void>

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.

// On application startup or route change, prefetch common data
cache.prefetch('static-content/footer');
cache.prefetch('user/notifications/unread');

cache.refresh<T>(key: string): Promise<T | undefined>

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.

// After an API call modifies a resource, force update its cached version
const updatedUser = await cache.refresh('user/current');
console.log('User data refreshed:', updatedUser);

cache.setData<T>(key: string, data: T): void

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.

// Manually update a shopping cart item count after a local UI interaction
cache.setData('cart/item-count', 5);

// Directly inject data fetched from another source or computed locally
const localConfig = { theme: 'dark', fontSize: 'medium' };
cache.setData('app/settings', localConfig);

cache.remove(key: string): boolean

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.

// When a user logs out, remove their specific session data
cache.remove('user/session');

cache.on<EType extends CacheEventType>(event: EType, listener: (ev: Extract<CacheEvent, { type: EType }>) => void): void

Subscribes a listener function to specific cache events.

  • event: The type of event to listen for (e.g., 'hit', 'miss', 'error', 'persistence'). See CacheEventType in types.ts for all available types.
  • listener: A callback function that receives the specific event payload for the subscribed event type.
import { Cache, CacheEvent, CacheEventType } from '@asaidimu/utils-cache';

const myCache = new Cache();

myCache.on('hit', (e) => {
  console.log(`[CacheEvent] HIT for ${e.key} (isStale: ${e.isStale})`);
});

myCache.on('miss', (e) => {
  console.log(`[CacheEvent] MISS for ${e.key}`);
});

myCache.on('error', (e) => {
  console.error(`[CacheEvent] ERROR for ${e.key} (attempt ${e.attempt}):`, e.error.message);
});

myCache.on('persistence', (e) => {
  if (e.event === 'save_success') {
    console.log(`[CacheEvent] Persistence: Cache state saved successfully for ID: ${e.key}`);
  } else if (e.event === 'load_fail') {
    console.error(`[CacheEvent] Persistence: Failed to load cache state for ID: ${e.key}`, e.error);
  } else if (e.event === 'remote_update') {
    console.log(`[CacheEvent] Persistence: Cache state updated from remote source for ID: ${e.key}`);
  }
});

// For demonstration, register a query and trigger events
myCache.registerQuery('demo-item', async () => {
    console.log('--- Fetching demo-item ---');
    await new Promise(r => setTimeout(r, 200));
    return 'demo-data';
}, { staleTime: 100 });

myCache.get('demo-item'); // Triggers miss, fetch, set_data
setTimeout(() => myCache.get('demo-item'), 50); // Triggers hit
setTimeout(() => myCache.get('demo-item'), 150); // Triggers stale hit, background fetch

cache.off<EType extends CacheEventType>(event: EType, listener: (ev: Extract<CacheEvent, { type: EType }>) => void): void

Unsubscribes a previously registered listener from a cache event. The listener reference must be the exact same function that was passed to on().

const myHitLogger = (e: any) => console.log(`[Log] Cache Hit: ${e.key}`);
myCache.on('hit', myHitLogger);

// Later, when you no longer need the listener:
myCache.off('hit', myHitLogger);

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 }> }

Returns current cache statistics and detailed metrics.

  • size: Number of active entries in the cache.
  • metrics: An object containing raw counts (hits, misses, fetches, errors, evictions, staleHits).
  • hitRate: Ratio of hits to total requests (hits + misses).
  • staleHitRate: Ratio of stale hits to total hits.
  • entries: An array of objects providing details for each cached item (key, lastAccessed, lastUpdated, accessCount, isStale, isLoading, error status).
const stats = myCache.getStats();
console.log('Cache Size:', stats.size);
console.log('Metrics:', stats.metrics);
console.log('Overall Hit Rate:', (stats.hitRate * 100).toFixed(2) + '%');
console.log('Entries details:', stats.entries);

cache.clear(): Promise<void>

Clears all data from the in-memory cache, resets metrics, and attempts to clear the associated persisted state via the persistence layer.

console.log('Clearing cache...');
await myCache.clear();
console.log('Cache cleared. Current size:', myCache.getStats().size);

cache.destroy(): void

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.

myCache.destroy();
console.log('Cache instance destroyed. All timers stopped and data cleared.');

Configuration Examples

The CacheOptions interface provides extensive control over the cache's behavior:

import { CacheOptions, SimplePersistence, SerializableCacheState } from '@asaidimu/utils-cache';

// A mock persistence layer for demonstration purposes.
// In a real application, you'd use an actual implementation like IndexedDBPersistence.
class MockPersistence implements SimplePersistence<SerializableCacheState> {
    private store = new Map<string, SerializableCacheState>();
    private subscribers = new Map<string, Array<(data: SerializableCacheState) => void>>();

    async get(id: string): Promise<SerializableCacheState | undefined> {
        console.log(`[MockPersistence] Getting state for ID: ${id}`);
        return this.store.get(id);
    }
    async set(id: string, data: SerializableCacheState): Promise<void> {
        console.log(`[MockPersistence] Setting state for ID: ${id}`);
        this.store.set(id, data);
        // Simulate remote update notification to all subscribed instances
        this.subscribers.get(id)?.forEach(cb => cb(data));
    }
    async clear(id?: string): Promise<void> {
        console.log(`[MockPersistence] Clearing state ${id ? 'for ID: ' + id : '(all)'}`);
        if (id) {
            this.store.delete(id);
        } else {
            this.store.clear();
        }
    }
    subscribe(id: string, callback: (data: SerializableCacheState) => void): () => void {
        console.log(`[MockPersistence] Subscribing to ID: ${id}`);
        if (!this.subscribers.has(id)) {
            this.subscribers.set(id, []);
        }
        this.subscribers.get(id)?.push(callback);
        // Return unsubscribe function
        return () => {
            const callbacks = this.subscribers.get(id);
            if (callbacks) {
                this.subscribers.set(id, callbacks.filter(cb => cb !== callback));
            }
            console.log(`[MockPersistence] Unsubscribed from ID: ${id}`);
        };
    }
}

const fullOptions: CacheOptions = {
  staleTime: 1000 * 60 * 5,    // 5 minutes: After this time, data is stale; a background fetch is considered.
  cacheTime: 1000 * 60 * 60,   // 1 hour: Items idle (not accessed) for this long are eligible for garbage collection.
  retryAttempts: 3,            // Max 3 fetch attempts (initial + 2 retries) on network/fetch failures.
  retryDelay: 1000,            // 1 second initial delay for retries (doubles each subsequent attempt).
  maxSize: 2000,               // Keep up to 2000 entries; LRU eviction kicks in beyond this limit.
  enableMetrics: true,         // Enable performance tracking (hits, misses, fetches, etc.).
  persistence: new MockPersistence(), // Provide an instance of your persistence layer implementation.
  persistenceId: 'my-unique-cache-instance', // A unique identifier for this cache instance within the persistence store.
  persistenceDebounceTime: 750, // Wait 750ms after a cache change before writing to persistence to batch writes.
  
  // Custom serializers/deserializers for data that isn't natively JSON serializable (e.g., Maps, Dates, custom classes).
  serializeValue: (value: any) => {
    // Example: Convert Date objects to ISO strings for JSON serialization
    if (value instanceof Date) {
      return { _type: 'Date', data: value.toISOString() };
    }
    // Example: Convert Map objects to an array for JSON serialization
    if (value instanceof Map) {
      return { _type: 'Map', data: Array.from(value.entries()) };
    }
    return value; // Return as is for other types
  },
  deserializeValue: (value: any) => {
    // Example: Convert ISO strings back to Date objects
    if (typeof value === 'object' && value !== null && value._type === 'Date') {
      return new Date(value.data);
    }
    // Example: Convert array back to Map objects
    if (typeof value === 'object' && value !== null && value._type === 'Map') {
      return new Map(value.data);
    }
    return value; // Return as is for other types
  },
};

const configuredCache = new Cache(fullOptions);

Common Use Cases

Caching API Responses with SWR (Stale-While-Revalidate)

This is the default and most common pattern, where you prioritize immediate responsiveness while ensuring data freshness in the background.

import { Cache } from '@asaidimu/utils-cache';

const apiCache = new Cache({
  staleTime: 5 * 60 * 1000,  // Data considered stale after 5 minutes
  cacheTime: 30 * 60 * 1000, // Idle data garbage collected after 30 minutes
  retryAttempts: 3,          // Retry fetching on network failures
});

// Register a query for a list of blog posts
apiCache.registerQuery('blog/posts', async () => {
  console.log('--- Fetching ALL blog posts from API... ---');
  const response = await fetch('https://api.example.com/blog/posts');
  if (!response.ok) throw new Error('Failed to fetch blog posts');
  return response.json();
});

// Function to display blog posts
async function displayBlogPosts(source: string) {
  console.log(`\nDisplaying blog posts from: ${source}`);
  const posts = await apiCache.get('blog/posts'); // Uses SWR by default
  if (posts) {
    console.log(`Received ${posts.length} posts (first 2):`, posts.slice(0, 2).map((p: any) => p.title));
  } else {
    console.log('No posts yet, waiting for initial fetch...');
  }
}

displayBlogPosts('Initial Load'); // First `get`: cache miss, triggers fetch.
setTimeout(() => displayBlogPosts('After 1 sec (cached)'), 1000); // Second `get`: cache hit, returns instantly.
setTimeout(() => displayBlogPosts('After 6 mins (stale & background fetch)'), 6 * 60 * 1000); // After `staleTime`: returns cached, triggers background fetch.

Using waitForFresh for Critical Data

For scenarios where serving outdated data is unacceptable (e.g., user permissions, critical configuration).

import { Cache } from '@asaidimu/utils-cache';

const criticalCache = new Cache({ retryAttempts: 5, retryDelay: 1000 });

criticalCache.registerQuery('user/permissions', async () => {
  console.log('--- Fetching user permissions from API... ---');
  // Simulate potential network flakiness
  if (Math.random() > 0.7) {
    throw new Error('Network error during permission fetch!');
  }
  await new Promise(resolve => setTimeout(resolve, 500));
  return { canEdit: true, canDelete: false, roles: ['user', 'editor'] };
});

async function checkPermissionsBeforeAction() {
  console.log('\nAttempting to get FRESH user permissions...');
  try {
    // We MUST have the latest permissions before proceeding with a sensitive action
    const permissions = await criticalCache.get('user/permissions', { waitForFresh: true, throwOnError: true });
    console.log('User permissions received:', permissions);
    // Proceed with action based on permissions
  } catch (error) {
    console.error('CRITICAL: Failed to load user permissions:', error);
    // Redirect to error page, show critical alert, or disable functionality
  }
}

checkPermissionsBeforeAction();
// You might call this repeatedly in a test scenario to see retries and eventual success/failure
setInterval(() => checkPermissionsBeforeAction(), 3000);

Real-time Monitoring with Events

Utilize the comprehensive event system to log, monitor, or react to cache lifecycle events.

import { Cache } from '@asaidimu/utils-cache';

const monitorCache = new Cache({ enableMetrics: true });

monitorCache.registerQuery('stock/AAPL', async () => {
  const price = Math.random() * 100 + 150;
  console.log(`--- Fetching AAPL price: $${price.toFixed(2)} ---`);
  return { symbol: 'AAPL', price: parseFloat(price.toFixed(2)), timestamp: Date.now() };
}, { staleTime: 1000 }); // Very short staleTime for frequent fetches

// Subscribe to various cache events
monitorCache.on('fetch', (e) => {
  console.log(`[EVENT] Fetching ${e.key} (attempt ${e.attempt})`);
});
monitorCache.on('hit', (e) => {
  console.log(`[EVENT] Cache hit for ${e.key}. Stale: ${e.isStale}`);
});
monitorCache.on('miss', (e) => {
  console.log(`[EVENT] Cache miss for ${e.key}`);
});
monitorCache.on('eviction', (e) => {
  console.log(`[EVENT] Evicted ${e.key} due to ${e.reason}`);
});
monitorCache.on('set_data', (e) => {
    console.log(`[EVENT] Data for ${e.key} manually set. Old price: ${e.oldData?.price}, New price: ${e.newData.price}`);
});
monitorCache.on('persistence', (e) => {
  if (e.event === 'save_success') console.log(`[EVENT] Persistence: ${e.message || 'Save successful'}`);
});


// Continuously try to get data (will trigger fetches due to short staleTime)
setInterval(() => {
  monitorCache.get('stock/AAPL');
}, 500);

// Manually set data to trigger 'set_data' and 'persistence' events
setTimeout(() => {
    monitorCache.setData('stock/AAPL', { symbol: 'AAPL', price: 160.00, timestamp: Date.now() });
}, 3000);

// Log cache statistics periodically
setInterval(() => {
  const stats = monitorCache.getStats();
  console.log(`\n--- CACHE STATS ---`);
  console.log(`Size: ${stats.size}, Hits: ${stats.metrics.hits}, Misses: ${stats.metrics.misses}, Fetches: ${stats.metrics.fetches}`);
  console.log(`Hit Rate: ${(stats.hitRate * 100).toFixed(2)}%, Stale Hit Rate: ${(stats.staleHitRate * 100).toFixed(2)}%`);
  console.log(`Active entries: ${stats.entries.map(e => `${e.key} (stale:${e.isStale})`).join(', ')}`);
  console.log(`-------------------\n`);
}, 5000); // Log stats every 5 seconds

šŸ—ļø Project Architecture

The @asaidimu/utils-cache library is structured to provide a clear separation of concerns, making it modular, testable, and extensible.

Directory Structure

src/cache/
ā”œā”€ā”€ cache.ts            # Main Cache class implementation
ā”œā”€ā”€ index.ts            # Entry point for the module (re-exports Cache)
ā”œā”€ā”€ types.ts            # TypeScript interfaces and types for options, entries, events, etc.
└── cache.test.ts       # Unit tests for the Cache class
package.json            # Package metadata and dependencies for this specific module

Core Components

  • Cache Class (cache.ts): The central component of the library. It orchestrates all caching logic, including:
    • Managing the in-memory Map (this.cache) that stores CacheEntry objects.
    • Handling data fetching, retries, and staleness checks.
    • Implementing time-based (TTL) and size-based (LRU) garbage collection.
    • Integrating with the pluggable persistence layer.
    • Emitting detailed cache events.
    • Tracking performance metrics.
  • 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.
  • 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.
  • QueryConfig (types.ts): Stores the fetchFunction and the resolved CacheOptions (merged with instance defaults) for each registered query, enabling tailored behavior per data key.
  • CacheMetrics (types.ts): Defines the structure for tracking cache performance statistics, including hits, misses, fetches, errors, and evictions.
  • 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).
  • 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.

Data Flow

  1. Initialization:

    • The Cache constructor sets up global default options, initializes performance metrics, and starts the automatic garbage collection timer.
    • If a persistence layer is configured, it attempts to load a previously saved state using persistence.get().
    • 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.
  2. registerQuery:

    • 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.
  3. get Request:

    • When get(key, options) is invoked, Cache first checks this.cache for an existing CacheEntry for the key.
    • 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.
      • If waitForFresh is true OR if the entry is stale/loading, it proceeds to fetchAndWait.
      • 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.
      • If waitForFresh is false and the entry is fresh, the cached data is returned immediately.
    • 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.
  4. fetch / fetchAndWait:

    • These methods ensure that only one fetchFunction runs concurrently for a given key by tracking ongoing fetches in this.fetching.
    • They delegate the actual data retrieval and retry logic to performFetchWithRetry.
  5. performFetchWithRetry:

    • This is where the registered fetchFunction is executed. It attempts to call the fetchFunction multiple times (up to retryAttempts) with exponential backoff (retryDelay).
    • Before each attempt, a 'fetch' event is emitted, and fetches metrics are updated.
    • 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.
    • 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.
  6. schedulePersistState:

    • 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.
  7. handleRemoteStateChange:

    • 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).
  8. garbageCollect:

    • 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.
  9. enforceSizeLimit:

    • 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.

Extension Points

The design of @asaidimu/utils-cache provides several powerful extension points for customization and integration:

  • 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.
  • 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, Maps, Sets, or custom class instances.
  • 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:
    • Logging: Detailed logging of cache activity (hits, misses, errors, evictions).
    • Analytics: Feeding cache performance metrics into an analytics platform.
    • UI Reactivity: Updating UI components in response to cache changes (e.g., showing a "stale data" indicator or a "refreshing" spinner).
    • Debugging: Gaining deep insights into cache behavior during development.
    • External Synchronization: Triggering side effects or synchronizing with other systems based on cache events.

šŸ¤ Development & Contributing

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.

Development Setup

To set up the development environment for @asaidimu/utils-cache:

  1. Clone the monorepo:
    git clone https://github.com/asaidimu/erp-utils.git
    cd erp-utils
  2. Navigate to the cache package:
    cd src/cache
  3. Install dependencies:
    npm install
    # or
    yarn install
    # or
    bun install
  4. Build the project:
    npm run build
    # or
    yarn build
    # or
    bun run build

Scripts

The following npm scripts are typically available in this project's setup:

  • npm run build: Compiles TypeScript source files from src/ to JavaScript output in dist/.
  • npm run test: Runs the test suite using Vitest.
  • npm run test:watch: Runs tests in watch mode for continuous feedback during development.
  • npm run lint: Runs ESLint to check for code style and potential errors.
  • npm run format: Formats code using Prettier according to the project's style guidelines.

Testing

Tests are written using Vitest. To run tests:

npm test
# or
yarn test
# or
bun test

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.

Contributing Guidelines

Please follow these steps to contribute:

  1. Fork the repository on GitHub.
  2. Create a new branch for your feature or bug fix: git checkout -b feature/my-awesome-feature or bugfix/resolve-issue-123.
  3. Make your changes, ensuring they adhere to the existing code style and architecture.
  4. Write or update tests to cover your changes and ensure existing functionality is not broken.
  5. Ensure all tests pass locally by running npm test.
  6. Run lint and format checks (npm run lint and npm run format) and fix any reported issues.
  7. Write clear, concise commit messages following the Conventional Commits specification (e.g., feat: add new caching strategy, fix: correct staleTime calculation).
  8. Push your branch to your fork.
  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.

Issue Reporting

Found a bug, have a feature request, or need clarification? Please open an issue on our GitHub Issues page.

When reporting a bug, please include:

  • A clear and concise description of the issue.
  • Detailed steps to reproduce the behavior.
  • The expected behavior.
  • Any relevant screenshots or code snippets.
  • Your environment details (Node.js version, OS, browser, package version).

šŸ“š Additional Information

Troubleshooting

  • "No query registered for key: key" Error:
    • 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().
    • Solution: Ensure you call cache.registerQuery(key, fetchFunction) for every key you intend to use with the cache before attempting to retrieve data.
  • Data not persisting:
    • Cause: The cache state is not being correctly saved or loaded from the underlying storage.
    • Solution:
      1. persistence instance: Double-check that you are passing a valid SimplePersistence instance to the Cache constructor's persistence option.
      2. persistenceId: Ensure you've provided a unique persistenceId if multiple cache instances share the same persistence layer.
      3. Serialization: Verify that your data types are correctly handled by serializeValue and deserializeValue options, especially for non-JSON-serializable types like Maps, Date objects, or custom classes.
      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).
      5. Event Errors: Check for persistence event errors in your browser's or Node.js console (cache.on('persistence', ...)).
  • Cache not evicting data:
    • Cause: Eviction policies might be disabled or configured with very long durations.
    • Solution:
      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.
      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).
      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.
  • Event listeners not firing:
    • Cause: The listener might be removed, or the expected event is not actually occurring.
    • Solution:
      1. Correct Event Type: Ensure you are subscribing to the exact CacheEventType you expect (e.g., 'hit', 'error').
      2. enableMetrics: If you expect metric-related events or updates, ensure enableMetrics is not set to false in your CacheOptions.
      3. Listener Reference: When using off(), ensure the listener function is the exact same reference passed to on().

FAQ

Q: How does staleTime differ from cacheTime? 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.

Q: When should I use waitForFresh? 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.

Q: Can I use Cache in a web worker? 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).

Q: Is Cache thread-safe (or safe with concurrent access)? A: JavaScript is single-threaded. Cache manages its internal state with Maps and Promises. 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.

Changelog/Roadmap

For a detailed history of changes, new features, and bug fixes, please refer to the CHANGELOG.md file in the repository root. Our future plans are outlined in the ROADMAP.md (if available).

License

This project is licensed under the MIT License - see the LICENSE file for details.

Acknowledgments

  • Inspired by modern data fetching and caching libraries like React Query and SWR.
  • Uses the uuid library for generating unique cache instance IDs.
2.0.4

5 months ago

2.0.3

5 months ago

2.0.2

5 months ago

2.0.1

5 months ago

2.0.0

5 months ago

1.0.0

5 months ago