Cache factory

A little abstraction for caching values that works in all environments, implementing stale-while-revalidate mechanism.

Cache factory
export type CacheKey = (string | number | boolean | undefined)[];
export type CacheMetadata = { createdAt: number; maxAge: number | null };
export type CacheValue<T> = { metadata: CacheMetadata; value: T };

export interface CacheClient {
  get<T>(key: string): Promise<T | null>;
  set<T>(key: string, value: T): Promise<any>;
  delete(key: string): Promise<any>;
}

export interface CacheOptions<T> {
  key: CacheKey;
  fetchFn: () => Promise<T>;
  maxAge?: number;
}

function assertCacheValue<T>(
  entry: unknown,
  key: string
): asserts entry is CacheValue<T> {
  if (typeof entry !== 'object' || entry === null) {
    throw new Error(
      `Cache entry for ${key} is not a cache entry object, it's a ${typeof entry}`
    );
  }
  if (!('metadata' in entry)) {
    throw new Error(`Cache entry for ${key} does not have a metadata property`);
  }
  if (!('value' in entry)) {
    throw new Error(`Cache entry for ${key} does not have a value property`);
  }
}

const log = (message: string) => {
  const isServer = typeof window === 'undefined';
  if (isServer && process.env.NODE_ENV === 'development') {
    console.log(message);
  }
};

export const getCacheKey = (keyParts: CacheKey) => keyParts.join('-');

export const createCache = (client: CacheClient) => {
  const keysRefreshing = new Set<string>();

  return {
    get: async <T>({ maxAge, ...options }: CacheOptions<T>) => {
      const key = getCacheKey(options.key);
      log(`\n--------------------CAHCE: "${key}"--------------------\n`);

      const refreshValue = async () => {
        log(`Fetching fresh value for "${key}"...`);
        const value = await options.fetchFn();
        const cacheValue: CacheValue<T> = {
          value,
          metadata: { maxAge: maxAge ?? null, createdAt: Date.now() }
        };
        await client.set(key, cacheValue);
        log(`Fresh value "${key}" set to cache`);
        return cacheValue;
      };

      try {
        const cacheValue = await client.get<T>(key);
        if (cacheValue) {
          log(`Got ${key} value from cache`);
          assertCacheValue<T>(cacheValue, key);
          const { value, metadata } = cacheValue;

          const isExpired =
            metadata.maxAge &&
            Date.now() > metadata.createdAt + metadata.maxAge;

          log(
            `"${key}" parsed and validated, expiring in: ${
              metadata.maxAge
                ? (metadata.createdAt + metadata.maxAge - Date.now()) / 1000 +
                  's'
                : 'never'
            }`
          );

          if (isExpired && !keysRefreshing.has(key)) {
            log(`"${key}" expired, refreshing...`);
            // refresh async, and return stale value
            // keep track of refreshing keys to avoid refreshing key multiple times simultaneously
            keysRefreshing.add(key);
            refreshValue().finally(() => {
              keysRefreshing.delete(key);
            });
          }

          log(`Returning "${key}" value from cache`);

          return value;
        }
      } catch (err) {
        log(
          `Something went wrong while getting "${key}" from cache, deleting it...`
        );
        await client.delete(key);
      }

      const { value } = await refreshValue();
      log(`Returning fresh "${key}" value`);
      return value;
    },
    delete: (cacheKey: CacheKey) => {
      const key = getCacheKey(cacheKey);
      return client.delete(key);
    }
  };
};

Example usage

Caching Prisma/SQL data with Redis:

import Redis from 'ioredis';
import { prisma } from './prisma';
import { CacheClient, createCache } from './cache-factory';

const redis = new Redis(process.env.REDIS_URL ?? undefined);

export const redisCacheClient = createCache({
  get: async (key) => {
    const rawValue = await redis.get(key);
    if (rawValue) {
      const parsedValue = JSON.parse(rawValue);
      return parsedValue;
    }
    return null;
  },
  set: async (key, value) => redis.set(key, JSON.stringify(value)),
  delete: (key) => redis.del(key)
});

export const getFeaturedPosts = () =>
  redisCacheClient.get({
    key: ['featured-posts'],
    maxAge: 1000 * 60 * 60, // 1 hour
    fetchFn: () => prisma.post.findMany({ where: { isFeatured: true } })
  });