cachified

wrap virtually everything that can store by key to act as cache with ttl/ma...

README

cachified


🧙One API to cache them all


wrap virtually everything that can store by key to act as cache with ttl/max-age, stale-while-validate, parallel fetch protection and type-safety support

🤔Idea and 💻initial implementation by@kentcdodds 👏💜


Install


  1. ``` shell
  2. npm install cachified
  3. # yarn add cachified
  4. ```

Usage


  1. ``` ts
  2. import { LRUCache } from 'lru-cache';
  3. import { cachified, CacheEntry } from 'cachified';

  4. /* lru cache is not part of this package but a simple non-persistent cache */
  5. const lru = new LRUCache<string, CacheEntry>({ max: 1000 });

  6. function getUserById(userId: number) {
  7.   return cachified({
  8.     key: `user-${userId}`,
  9.     cache: lru,
  10.     async getFreshValue() {
  11.       /* Normally we want to either use a type-safe API or `checkValue` but
  12.          to keep this example simple we work with `any` */
  13.       const response = await fetch(
  14.         `https://jsonplaceholder.typicode.com/users/${userId}`,
  15.       );
  16.       return response.json();
  17.     },
  18.     /* 5 minutes until cache gets invalid
  19.      * Optional, defaults to Infinity */
  20.     ttl: 300_000,
  21.   });
  22. }

  23. // Let's get through some calls of `getUserById`:

  24. console.log(await getUserById(1));
  25. // > logs the user with ID 1
  26. // Cache was empty, `getFreshValue` got invoked and fetched the user-data that
  27. // is now cached for 5 minutes

  28. // 2 minutes later
  29. console.log(await getUserById(1));
  30. // > logs the exact same user-data
  31. // Cache was filled an valid. `getFreshValue` was not invoked

  32. // 10 minutes later
  33. console.log(await getUserById(1));
  34. // > logs the user with ID 1 that might have updated fields
  35. // Cache timed out, `getFreshValue` got invoked to fetch a fresh copy of the user
  36. // that now replaces current cache entry and is cached for 5 minutes
  37. ```

Options


  1. ``` ts
  2. interface CachifiedOptions<Value> {
  3.   /**
  4.    * Required
  5.    *
  6.    * The key this value is cached by
  7.    * Must be unique for each value
  8.    */
  9.   key: string;
  10.   /**
  11.    * Required
  12.    *
  13.    * Cache implementation to use
  14.    *
  15.    * Must conform with signature
  16.    *  - set(key: string, value: object): void | Promise
  17.    *  - get(key: string): object | Promise
  18.    *  - delete(key: string): void | Promise
  19.    */
  20.   cache: Cache;
  21.   /**
  22.    * Required
  23.    *
  24.    * Function that is called when no valid value is in cache for given key
  25.    * Basically what we would do if we wouldn't use a cache
  26.    *
  27.    * Can be async and must return fresh value or throw
  28.    *
  29.    * receives context object as argument
  30.    *  - context.metadata.ttl?: number
  31.    *  - context.metadata.swr?: number
  32.    *  - context.metadata.createdTime: number
  33.    *  - context.background: boolean
  34.    */
  35.   getFreshValue: GetFreshValue<Value>;
  36.   /**
  37.    * Time To Live; often also referred to as max age
  38.    *
  39.    * Amount of milliseconds the value should stay in cache
  40.    * before we get a fresh one
  41.    *
  42.    * Setting any negative value will disable caching
  43.    * Can be infinite
  44.    *
  45.    * Default: `Infinity`
  46.    */
  47.   ttl?: number;
  48.   /**
  49.    * Amount of milliseconds that a value with exceeded ttl is still returned
  50.    * while a fresh value is refreshed in the background
  51.    *
  52.    * Should be positive, can be infinite
  53.    *
  54.    * Default: `0`
  55.    */
  56.   staleWhileRevalidate?: number;
  57.   /**
  58.    * Alias for staleWhileRevalidate
  59.    */
  60.   swr?: number;
  61.   /**
  62.    * Validator that checks every cached and fresh value to ensure type safety
  63.    *
  64.    * Can be a zod schema or a custom validator function
  65.    *
  66.    * Value considered ok when:
  67.    *  - zod schema.parseAsync succeeds
  68.    *  - validator returns
  69.    *    - true
  70.    *    - migrate(newValue)
  71.    *    - undefined
  72.    *    - null
  73.    *
  74.    * Value considered bad when:
  75.    *  - zod schema.parseAsync throws
  76.    *  - validator:
  77.    *    - returns false
  78.    *    - returns reason as string
  79.    *    - throws
  80.    *
  81.    * A validator function receives two arguments:
  82.    *  1. the value
  83.    *  2. a migrate callback, see https://github.com/Xiphe/cachified#migrating-values
  84.    *
  85.    * Default: `undefined` - no validation
  86.    */
  87.   checkValue?: CheckValue<Value> | Schema<Value, unknown>;
  88.   /**
  89.    * Set true to not even try reading the currently cached value
  90.    *
  91.    * Will write new value to cache even when cached value is
  92.    * still valid.
  93.    *
  94.    * Default: `false`
  95.    */
  96.   forceFresh?: boolean;
  97.   /**
  98.    * Weather of not to fall back to cache when getting a forced fresh value
  99.    * fails
  100.    *
  101.    * Can also be a positive number as the maximum age in milliseconds that a
  102.    * fallback value might have
  103.    *
  104.    * Default: `Infinity`
  105.    */
  106.   fallbackToCache?: boolean | number;
  107.   /**
  108.    * Amount of time in milliseconds before revalidation of a stale
  109.    * cache entry is started
  110.    *
  111.    * Must be positive and finite
  112.    *
  113.    * Default: `0`
  114.    */
  115.   staleRefreshTimeout?: number;
  116.   /**
  117.    * A reporter receives events during the runtime of
  118.    * cachified and can be used for debugging and monitoring
  119.    *
  120.    * Default: `undefined` - no reporting
  121.    */
  122.   reporter?: CreateReporter<Value>;
  123. }
  124. ```

  125. Adapters


    There are some build-in adapters for common caches, using them makes sure the used caches cleanup outdated values themselves.

    Adapter for lru-cache


    1. ``` ts
    2. import { LRUCache } from 'lru-cache';
    3. import { cachified, lruCacheAdapter, CacheEntry } from 'cachified';

    4. const lru = new LRUCache<string, CacheEntry>({ max: 1000 });
    5. const cache = lruCacheAdapter(lru);

    6. await cachified({
    7.   cache,
    8.   key: 'user-1',
    9.   getFreshValue() {
    10.     return 'user@example.org';
    11.   },
    12. });
    13. ```

    Adapter for redis


    1. ``` ts
    2. import { createClient } from 'redis';
    3. import { cachified, redisCacheAdapter } from 'cachified';

    4. const redis = createClient({
    5.   /* ...opts */
    6. });
    7. const cache = redisCacheAdapter(redis);

    8. await cachified({
    9.   cache,
    10.   key: 'user-1',
    11.   getFreshValue() {
    12.     return 'user@example.org';
    13.   },
    14. });
    15. ```

    Adapter for redis@3


    1. ``` ts
    2. import { createClient } from 'redis';
    3. import { cachified, redis3CacheAdapter } from 'cachified';

    4. const redis = createClient({
    5.   /* ...opts */
    6. });
    7. const cache = redis3CacheAdapter(redis);

    8. const data = await cachified({
    9.   cache,
    10.   key: 'user-1',
    11.   getFreshValue() {
    12.     return 'user@example.org';
    13.   },
    14. });
    15. ```

    Advanced Usage


    Stale while revalidate


    Specify a time window in which a cached value is returned even though it's ttl is exceeded while the cache is updated in the background for the next
    call.

    1. ``` ts
    2. import { cachified } from 'cachified';

    3. const cache = new Map();

    4. function getUserById(userId: number) {
    5.   return cachified({
    6.     ttl: 120_000 /* Two minutes */,
    7.     staleWhileRevalidate: 300_000 /* Five minutes */,

    8.     cache,
    9.     key: `user-${userId}`,
    10.     async getFreshValue() {
    11.       const response = await fetch(
    12.         `https://jsonplaceholder.typicode.com/users/${userId}`,
    13.       );
    14.       return response.json();
    15.     },
    16.   });
    17. }

    18. console.log(await getUserById(1));
    19. // > logs the user with ID 1
    20. // Cache is empty, `getFreshValue` gets invoked and and its value returned and
    21. // cached for 7 minutes total. After 2 minutes the cache will start refreshing in background

    22. // 30 seconds later
    23. console.log(await getUserById(1));
    24. // > logs the exact same user-data
    25. // Cache is filled an valid. `getFreshValue` is not invoked, cached value is returned

    26. // 4 minutes later
    27. console.log(await getUserById(1));
    28. // > logs the exact same user-data
    29. // Cache timed out but stale while revalidate is not exceeded.
    30. // cached value is returned immediately, `getFreshValue` gets invoked in the
    31. // background and its value is cached for the next 7 minutes

    32. // 30 seconds later
    33. console.log(await getUserById(1));
    34. // > logs fresh user-data from the previous call
    35. // Cache is filled an valid. `getFreshValue` is not invoked, cached value is returned
    36. ```

    Forcing fresh values and falling back to cache


    We can use forceFreshto get a fresh value regardless of the values ttl or stale while validate

    1. ``` ts
    2. import { cachified } from 'cachified';

    3. const cache = new Map();

    4. function getUserById(userId: number, forceFresh?: boolean) {
    5.   return cachified({
    6.     forceFresh,
    7.     /* when getting a forced fresh value fails we fall back to cached value
    8.        as long as it's not older then 5 minutes */
    9.     fallbackToCache: 300_000 /* 5 minutes, defaults to Infinity */,

    10.     cache,
    11.     key: `user-${userId}`,
    12.     async getFreshValue() {
    13.       const response = await fetch(
    14.         `https://jsonplaceholder.typicode.com/users/${userId}`,
    15.       );
    16.       return response.json();
    17.     },
    18.   });
    19. }

    20. console.log(await getUserById(1));
    21. // > logs the user with ID 1
    22. // Cache is empty, `getFreshValue` gets invoked and and its value returned

    23. console.log(await getUserById(1, true));
    24. // > logs fresh user with ID 1
    25. // Cache is filled an valid. but we forced a fresh value, so `getFreshValue` is invoked
    26. ```

    Type-safety


    In practice we can not be entirely sure that values from cache are of the types we assume. For example other parties could also write to the cache or code is changed while cache
    stays the same.

    1. ``` ts
    2. import { cachified, createCacheEntry } from 'cachified';

    3. const cache = new Map();

    4. /* Assume something bad happened and we have an invalid cache entry... */
    5. cache.set('user-1', createCacheEntry('INVALID') as any);

    6. function getUserById(userId: number) {
    7.   return cachified({
    8.     checkValue(value: unknown) {
    9.       if (!isRecord(value)) {
    10.         /* We can either throw to indicate a bad value */
    11.         throw new Error(`Expected user to be object, got ${typeof value}`);
    12.       }

    13.       if (typeof value.email !== 'string') {
    14.         /* Or return a reason/message string */
    15.         return `Expected user-${userId} to have an email`;
    16.       }

    17.       if (typeof value.username !== 'string') {
    18.         /* Or just say no... */
    19.         return false;
    20.       }

    21.       /* undefined, true or null are considered OK */
    22.     },

    23.     cache,
    24.     key: `user-${userId}`,
    25.     async getFreshValue() {
    26.       const response = await fetch(
    27.         `https://jsonplaceholder.typicode.com/users/${userId}`,
    28.       );
    29.       return response.json();
    30.     },
    31.   });
    32. }

    33. function isRecord(value: unknown): value is Record<string, unknown> {
    34.   return typeof value === 'object' && value !== null && !Array.isArray(value);
    35. }

    36. console.log(await getUserById(1));
    37. // > logs the user with ID 1
    38. // Cache was not empty but value was invalid, `getFreshValue` got invoked and
    39. // and the cache was updated

    40. console.log(await getUserById(1));
    41. // > logs the exact same data as above
    42. // Cache was filled an valid. `getFreshValue` was not invoked
    43. ```

    ℹ️checkValueis also invoked with the return value of getFreshValue


    Type-safety with zod


    We can also use zod schemas to ensure correct types

    1. ``` ts
    2. import { cachified, createCacheEntry } from 'cachified';
    3. import z from 'zod';

    4. const cache = new Map();
    5. /* Assume something bad happened and we have an invalid cache entry... */
    6. cache.set('user-1', createCacheEntry('INVALID') as any);

    7. function getUserById(userId: number) {
    8.   return cachified({
    9.     checkValue: z.object({
    10.       email: z.string(),
    11.     }),

    12.     cache,
    13.     key: `user-${userId}`,
    14.     async getFreshValue() {
    15.       const response = await fetch(
    16.         `https://jsonplaceholder.typicode.com/users/${userId}`,
    17.       );
    18.       return response.json();
    19.     },
    20.   });
    21. }

    22. console.log(await getUserById(1));
    23. // > logs the user with ID 1
    24. // Cache was not empty but value was invalid, `getFreshValue` got invoked and
    25. // and the cache was updated

    26. console.log(await getUserById(1));
    27. // > logs the exact same data as above
    28. // Cache was filled an valid. `getFreshValue` was not invoked
    29. ```

    Manually working with the cache


    During normal app lifecycle there usually is no need for this but for maintenance and testing these helpers might come handy.

    1. ``` ts
    2. import { createCacheEntry, assertCacheEntry, cachified } from 'cachified';

    3. const cache = new Map();

    4. /* Manually set an entry to cache */
    5. cache.set(
    6.   'user-1',
    7.   createCacheEntry(
    8.     'someone@example.org',
    9.     /* Optional CacheMetadata */
    10.     { ttl: 300_000, swr: Infinity },
    11.   ),
    12. );

    13. /* Receive the value with cachified */
    14. const value: string = await cachified({
    15.   cache,
    16.   key: 'user-1',
    17.   getFreshValue() {
    18.     throw new Error('This is not called since cache is set earlier');
    19.   },
    20. });
    21. console.log(value);
    22. // > logs "someone@example.org"

    23. /* Manually get a value from cache */
    24. const entry: unknown = cache.get('user-1');
    25. assertCacheEntry(entry); // will throw when entry is not a valid CacheEntry
    26. console.log(entry.value);
    27. // > logs "someone@example.org"

    28. /* Manually remove an entry from cache */
    29. cache.delete('user-1');
    30. ```

    Migrating Values


    When the format of cached values is changed during the apps lifetime they can be migrated on read like this:

    1. ``` ts
    2. import { cachified, createCacheEntry } from 'cachified';

    3. const cache = new Map();

    4. /* Let's assume we've previously only stored emails not user objects */
    5. cache.set('user-1', createCacheEntry('someone@example.org'));

    6. function getUserById(userId: number) {
    7.   return cachified({
    8.     checkValue(value, migrate) {
    9.       if (typeof value === 'string') {
    10.         return migrate({ email: value });
    11.       }
    12.       /* other validations... */
    13.     },

    14.     key: 'user-1',
    15.     cache,
    16.     getFreshValue() {
    17.       throw new Error('This is never called');
    18.     },
    19.   });
    20. }

    21. console.log(await getUserById(1));
    22. // > logs { email: 'someone@example.org' }
    23. // Cache is filled and invalid but value can be migrated from email to user-object
    24. // `getFreshValue` is not invoked

    25. console.log(await getUserById(1));
    26. // > logs the exact same data as above
    27. // Cache is filled an valid.
    28. ```

    Soft-purging entries


    Soft-purging cached data has the benefit of not immediately putting pressure on the app to update all cached values at once and instead allows to get them updated over time.

    More details: Soft vs. hard purge

    1. ``` ts
    2. import { cachified, softPurge } from 'cachified';

    3. const cache = new Map();

    4. function getUserById(userId: number) {
    5.   return cachified({
    6.     cache,
    7.     key: `user-${userId}`,
    8.     ttl: 300_000,
    9.     async getFreshValue() {
    10.       const response = await fetch(
    11.         `https://jsonplaceholder.typicode.com/users/${userId}`,
    12.       );
    13.       return response.json();
    14.     },
    15.   });
    16. }

    17. console.log(await getUserById(1));
    18. // > logs user with ID 1
    19. // cache was empty, fresh value was requested and is cached for 5 minutes

    20. await softPurge({
    21.   cache,
    22.   key: 'user-1',
    23. });
    24. // This internally sets the ttl to 0 and staleWhileRevalidate to 300_000

    25. // 10 seconds later
    26. console.log(await getUserById(1));
    27. // > logs the outdated, soft-purged data
    28. // cache has been soft-purged, the cached value got returned and a fresh value
    29. // is requested in the background and again cached for 5 minutes

    30. // 1 minute later
    31. console.log(await getUserById(1));
    32. // > logs the fresh data that got refreshed by the previous call

    33. await softPurge({
    34.   cache,
    35.   key: 'user-1',
    36.   // manually overwrite how long the stale data should stay in cache
    37.   staleWhileRevalidate: 60_000 /* one minute from now on */,
    38. });

    39. // 2 minutes later
    40. console.log(await getUserById(1));
    41. // > logs completely fresh data
    42. ```

    ℹ️In case we need to fully purge the value, we delete the key directly from our cache


    Fine-tuning cache metadata based on fresh values


    There are scenarios where we want to change the cache time based on the fresh value (ref #25 ). For example when an API might either provide our data or nulland in case we get an empty result we want to retry the API much faster.

    1. ``` ts
    2. import { cachified } from 'cachified';

    3. const cache = new Map();

    4. const value: null | string = await cachified({
    5.   ttl: 60_000 /* Default cache of one minute... */,
    6.   async getFreshValue(context) {
    7.     const response = await fetch(
    8.       `https://jsonplaceholder.typicode.com/users/1`,
    9.     );
    10.     const data = await response.json();

    11.     if (data === null) {
    12.       /* On an empty result, prevent caching */
    13.       context.metadata.ttl = -1;
    14.     }

    15.     return data;
    16.   },

    17.   cache,
    18.   key: 'user-1',
    19. });
    20. ```

    Batch requesting values


    In case multiple values can be requested in a batch action, but it's not clear which values are currently in cache we can use the createBatchhelper

    1. ``` ts
    2. import { cachified, createBatch } from 'cachified';

    3. const cache = new Map();

    4. async function getFreshValues(idsThatAreNotInCache: number[]) {
    5.   const res = await fetch(
    6.     `https://example.org/api?ids=${idsThatAreNotInCache.join(',')}`,
    7.   );
    8.   const data = await res.json();

    9.   // Validate data here...

    10.   return data;
    11. }

    12. function getUsersWithId(ids: number[]) {
    13.   const batch = createBatch(getFreshValues);

    14.   return Promise.all(
    15.     ids.map((id) =>
    16.       cachified({
    17.         getFreshValue: batch.add(
    18.           id,
    19.           /* onValue callback is optional but can be used to manipulate
    20.            * cache metadata based on the received value. (see section above) */
    21.           ({ value, ...context }) => {},
    22.         ),

    23.         cache,
    24.         key: `entry-${id}`,
    25.         ttl: 60_000,
    26.       }),
    27.     ),
    28.   );
    29. }

    30. console.log(await getUsersWithId([1, 2]));
    31. // > logs user objects for ID 1 & ID 2
    32. // Caches is completely empty. `getFreshValues` is invoked with `[1, 2]`
    33. // and its return values cached separately

    34. // 1 minute later
    35. console.log(await getUsersWithId([2, 3]));
    36. // > logs user objects for ID 2 & ID 3
    37. // User with ID 2 is in cache, `getFreshValues` is invoked with `[3]`
    38. // cachified returns with one value from cache and one fresh value
    39. ```

    Reporting


    A reporter might be passed to cachified to log caching events, we ship a reporter resembling the logging from Kents implementation

    1. ``` ts
    2. import { cachified, verboseReporter } from 'cachified';

    3. const cache = new Map();

    4. await cachified({
    5.   reporter: verboseReporter(),

    6.   cache,
    7.   key: 'user-1',
    8.   async getFreshValue() {
    9.     const response = await fetch(
    10.       `https://jsonplaceholder.typicode.com/users/1`,
    11.     );
    12.     return response.json();
    13.   },
    14. });
    15. ```

    please refer to the implementation of verboseReporter when you want to implement a custom reporter.