@epic-web/cachified

repository·main·Indexed 21 days ago

https://github.com/epicweb-dev/cachified

A type-safe API wrapper for various caches providing advanced features such as TTL (Time To Live), stale-while-revalidate (SWR), value validation via Standard Schema, and support for multiple adapters including Redis, Cloudflare KV, and SQLite. It abstracts cache management complexities, allowing for forced fresh fetches, fallback mechanisms, and batch requests.

Tokens
10.2K
Snippets
31
Records
35
Agent score
76%

What's inside @epic-web/cachified

  1. Use stale-while-revalidate to return cached values during background updates

    main

    The staleWhileRevalidate option allows you to specify a time window where a cached value is returned even if its ttl has expired. During this window, cachified triggers a background refresh via getFreshValue so the next caller receives the updated data. This prevents latency spikes for users when the cache expires.

    import { cachified } from '@epic-web/cachified';
    
    const cache = new Map();
    
    function getUserById(userId: number) {
      return cachified({
        ttl: 120_000 /* Two minutes */,
        staleWhileRevalidate: 300_000 /* Five minutes */,
    
        cache,
        key: `user-${userId}`,
        async getFreshValue() {
          const response = await fetch(
            `https://jsonplaceholder.typicode.com/users/${userId}`,
          );
          return response.json();
        },
      });
    }
  2. Basic usage of cachified()

    main

    The cachified function is the primary entry point. It manages the lifecycle of a cached value by checking the cache, potentially fetching a fresh value via getFreshValue, and updating the cache.

    To use it, you must provide a key, a cache implementation conforming to the Cache interface, and a getFreshValue function. You can also specify a ttl (Time To Live) in milliseconds.

    import { cachified, Cache, totalTtl } from '@epic-web/cachified';
    
    // A simple Cache implementation example
    const lru: Cache = {
      set(key, value) { /* implementation */ },
      get(key) { /* implementation */ },
      delete(key) { /* implementation */ },
    };
    
    async function getUserById(userId: number) {
      return cachified({
        key: `user-${userId}`,
        cache: lru,
        async getFreshValue() {
          const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);
          return response.json();
        },
        ttl: 300_000, // 5 minutes
      });
    }
  3. Fine-tune cache metadata based on fresh values

    main

    Inside getFreshValue, you can access the context object to modify the metadata of the cache entry. This is useful for adjusting ttl dynamically based on the data returned (e.g., setting ttl to -1 to prevent caching an empty/null result).

    import { cachified } from '@epic-web/cachified';
    
    const cache = new Map();
    
    const value: null | string = await cachified({
      ttl: 60_000,
      async getFreshValue(context) {
        const response = await fetch(
          `https://jsonplaceholder.typicode.com/users/1`,
        );
        const data = await response.json();
    
        if (data === null) {
          /* On an empty result, prevent caching */
          context.metadata.ttl = -1;
        }
    
        return data;
      },
    
      cache,
      key: 'user-1',
    });
  4. Validate cached values with schema libraries (Zod, Valibot, etc.)

    main

    You can pass a schema object (compatible with the Standard Schema spec) to checkValue to automatically validate cached data. If the schema validation fails, cachified will treat the entry as invalid and trigger getFreshValue.

    import { cachified, createCacheEntry } from '@epic-web/cachified';
    import z from 'zod';
    
    const cache = new Map();
    
    /* Assume something bad happened and we have an invalid cache entry... */
    cache.set('user-1', createCacheEntry('INVALID') as any);
    
    function getUserById(userId: number) {
      return cachified({
        checkValue: z.object({
          email: z.string(),
        }),
    
        cache,
        key: `user-${userId}`,
        async getFreshValue() {
          const response = await fetch(
            `https://jsonplaceholder.typicode.com/users/${userId}`,
          );
          return response.json();
        },
      });
    }
  5. Force fresh values and fallback to cache

    main

    Use the forceFresh option to bypass the cache and trigger getFreshValue immediately. If the attempt to fetch a fresh value fails, you can use fallbackToCache to return a cached value instead. fallbackToCache accepts a duration in milliseconds; if the cached value is older than this duration, it will not be used as a fallback. The default is Infinity.

    import { cachified } from '@epic-web/cachified';
    
    const cache = new Map();
    
    function getUserById(userId: number, forceFresh?: boolean) {
      return cachified({
        forceFresh,
        /* when getting a forced fresh value fails we fall back to cached value
           as long as it's not older then 5 minutes */
        fallbackToCache: 300_000 /* 5 minutes, defaults to Infinity */,
    
        cache,
        key: `user-${userId}`,
        async getFreshValue() {
          const response = await fetch(
            `https://jsonplaceholder.typicode.com/users/${userId}`,
          );
          return response.json();
        },
      });
    }
  6. Soft-purge cached entries

    main

    Instead of deleting a key (hard purge), use softPurge to mark an entry as stale. This sets the ttl to 0 but keeps the staleWhileRevalidate window active. The next request will return the outdated data immediately but trigger a background refresh, preventing a sudden surge of requests to your data source.

    import { cachified, softPurge } from '@epic-web/cachified';
    
    const cache = new Map();
    
    function getUserById(userId: number) {
      return cachified({
        cache,
        key: `user-${userId}`,
        ttl: 300_000,
        async getFreshValue() {
          const response = await fetch(
            `https://jsonplaceholder.typicode.com/users/${userId}`,
          );
          return response.json();
        },
      });
    }
    
    console.log(await getUserById(1));
    
    await softPurge({
      cache,
      key: 'user-1',
    });
    
    // You can also manually overwrite the SWR window during soft purge
    await softPurge({
      cache,
      key: 'user-1',
      staleWhileRevalidate: 60_000,
    });
  7. Use verboseReporter for debugging cache events

    main

    To log caching events (hits, misses, refreshes) to the console, pass verboseReporter() as the second argument to the cachified function.

    import { cachified, verboseReporter } from '@epic-web/cachified';
    
    const cache = new Map();
    
    await cachified(
      {
        cache,
        key: 'user-1',
        async getFreshValue() {
          const response = await fetch(
            `https://jsonplaceholder.typicode.com/users/1`,
          );
          return response.json();
        },
      },
      verboseReporter(),
    );
  8. Batch request multiple values with createBatch

    main

    When you need to fetch multiple items that might or might not be in the cache, use createBatch. This allows you to group multiple cachified calls into a single getFreshValue execution, reducing the number of network requests.

    import { cachified, createBatch } from '@epic-web/cachified';
    
    const cache = new Map();
    
    async function getFreshValues(idsThatAreNotInCache: number[]) {
      const res = await fetch(`https://example.org/api?ids=${idsThatAreNotInCache.join(',')}`);
      const data = await res.json();
      return data;
    }
    
    function getUsersWithId(ids: number[]) {
      const batch = createBatch(getFreshValues);
    
      return Promise.all(
        ids.map((id) =>
          cachified({
            getFreshValue: batch.add(id),
            cache,
            key: `entry-${id}`,
            ttl: 60_000,
          }),
        ),
      );
    }
  9. Migrate cached values during read

    main

    If your data format changes, you can use the migrate function inside checkValue to transform old cached values into the new format. If migrate is called, the new value is returned immediately and the cache is updated with the migrated version without calling getFreshValue.

    import { cachified, createCacheEntry } from '@epic-web/cachified';
    
    const cache = new Map();
    
    /* Let's assume we've previously only stored emails not user objects */
    cache.set('user-1', createCacheEntry('someone@example.org'));
    
    function getUserById(userId: number) {
      return cachified({
        checkValue(value, migrate) {
          if (typeof value === 'string') {
            return migrate({ email: value });
          }
        },
    
        key: 'user-1',
        cache,
        getFreshValue() {
          throw new Error('This is never called');
        },
      });
    }
    
    console.log(await getUserById(1));
    // > logs { email: 'someone@example.org' }
  10. Validate cached values with checkValue

    main

    Since cache contents can be modified by other processes or become outdated due to code changes, use the checkValue function to ensure data integrity. checkValue is called with the value retrieved from the cache (and also with the value returned by getFreshValue).

    To invalidate a bad value and trigger a refresh, checkValue can:

    • Throw an Error.
    • Return a string (the reason/message).
    • Return false.
    • Return undefined, true, or null to indicate the value is valid.
    import { cachified, createCacheEntry } from '@epic-web/cachified';
    
    const cache = new Map();
    
    /* Assume something bad happened and we have an invalid cache entry... */
    cache.set('user-1', createCacheEntry('INVALID') as any);
    
    function getUserById(userId: number) {
      return cachified({
        checkValue(value: unknown) {
          if (!isRecord(value)) {
            throw new Error(`Expected user to be object, got ${typeof value}`);
          }
    
          if (typeof value.email !== 'string') {
            return `Expected user-${userId} to have an email`;
          }
    
          if (typeof value.username !== 'string') {
            return false;
          }
        },
    
        cache,
        key: `user-${userId}`,
        async getFreshValue() {
          const response = await fetch(
            `https://jsonplaceholder.typicode.com/users/${userId}`,
          );
          return response.json();
        },
      });
    }
    
    function isRecord(value: unknown): value is Record<string, unknown> {
      return typeof value === 'object' && value !== null && !Array.isArray(value);
    }
  11. Pre-configure cachified with default options

    main

    Use the configure function to create a new version of cachified that has pre-set options (like a specific cache instance). This avoids repeating the same configuration in every call.

    import { configure } from '@epic-web/cachified';
    import { LRUCache } from 'lru-cache';
    
    /* lruCachified now has a default cache */
    const lruCachified = configure({
      cache: new LRUCache<string, CacheEntry>({ max: 1000 }),
    });
    
    const value = await lruCachified({
      key: 'user-1',
      getFreshValue: async () => 'ONE',
    });