For AI agents: the complete documentation index is available at https://dotpress.dev/llms.txt, the full documentation bundle is available at https://dotpress.dev/llms-full.txt, and this page is available as Markdown at https://dotpress.dev/examples/cache.md.

Cache

An articles API with @dotpress/cache, on Express, using the two tools of the package:

  • the memo (ctx.memo): a value loaded once per request. The middleware and the handler share the current user, and a list loads the authors of its articles with one query, not one per article;
  • the cache (ctx.cache): values kept across requests and instances. Articles are cached for 10 minutes; editing an article expires it, and renaming a user expires every article at once, with tags;
  • a grace period: when the database fails, an expired article is still served for one more day, instead of an error;
  • Redis with REDIS_URL, the memory of the process otherwise.

A fake database records its queries: the tests count them, as you would watch the queries of your database.

Source: examples/cache.

Run it

npx degit dotpressjs/dotpress/examples/cache my-api
cd my-api
npm install
npm run dev
curl localhost:3000/articles # loads, then caches for 1 minute
curl localhost:3000/articles/a1 # cached for 10 minutes
curl -X PATCH localhost:3000/articles/a1 -H 'x-user-id: u1' \
  -H 'content-type: application/json' -d '{"title":"Caching 102"}' # expires it
curl -X PATCH localhost:3000/users/me -H 'x-user-id: u1' \
  -H 'content-type: application/json' -d '{"name":"Alice Martin"}' # expires every article

The x-user-id header stands in for authentication: see the Authentication example for the real thing.

With Redis

docker compose up -d # Redis
REDIS_URL=redis://localhost:6379 npm run dev

The cache now survives restarts, and is shared by every instance of the API.

The memo and the cache entries

import { defineCacheEntry, defineMemo, type Memo } from '@dotpress/cache'
import { z } from 'zod'
import type { Article, Database } from './db.ts'

/**
 * Memo of the users: loaded once per request, and in batches. The ids
 * requested in the same tick are loaded with one query.
 */
export const userMemo = (db: Database) =>
  defineMemo({
    name: 'user',
    batch: async (ids: readonly string[]) => {
      const users = await db.users.findManyById(ids)
      // A Map by id: unknown ids give undefined.
      return new Map(users.map((user) => [user.id, user]))
    },
  })

export type UserMemo = ReturnType<typeof userMemo>

export const ArticleView = z
  .object({
    id: z.string(),
    title: z.string(),
    author: z.object({ id: z.string(), name: z.string() }).nullable(),
    updatedAt: z.iso.datetime(),
  })
  .meta({ id: 'Article' })

/** An article with the name of its author, as the API returns it. */
export const toView = async (
  memo: Memo,
  users: UserMemo,
  article: Article
): Promise<z.infer<typeof ArticleView>> => {
  const author = await memo(users, article.authorId)
  return {
    id: article.id,
    title: article.title,
    author: author ? { id: author.id, name: author.name } : null,
    updatedAt: article.updatedAt,
  }
}

// Values shared across requests and instances. Every view of an article
// holds the name of its author: renaming a user expires them all with the
// `articles` tag.

/** One article: 10 minutes, then served for one more day if the database fails. */
export const articleCache = defineCacheEntry({
  name: 'article',
  key: (id: string) => id,
  // Values read back are checked: a value written by an older version of
  // the app that no longer matches is a miss, not a crash.
  schema: ArticleView,
  ttl: '10m',
  grace: '1d',
  tags: (id: string) => [`article:${id}`, 'articles'],
})

/** The list of the articles. */
export const articleListCache = defineCacheEntry({
  name: 'article-list',
  key: () => 'all',
  schema: z.array(ArticleView),
  ttl: '1m',
  tags: () => ['articles', 'article-list'],
})

The routes

The list combines both tools: the cache keeps it for a minute, and on a miss, the memo loads its authors with one query. Updates expire what they change.

import {
  createRouter,
  defineMiddleware,
  forbiddenError,
  notFoundError,
  unauthorizedError,
  type RequestContext,
} from '@dotpress/core'
import type { Database } from './db.ts'
import {
  articleCache,
  articleListCache,
  ArticleView,
  toView,
  type UserMemo,
} from './loaders.ts'

/**
 * The current user, from the `x-user-id` header: a stand-in for real
 * authentication (see the auth example). Memoized: the middleware and the
 * handler share one query.
 */
const viewerOf = (ctx: RequestContext, users: UserMemo) => {
  const id = ctx.req.header('x-user-id')
  return id ? ctx.memo(users, id) : undefined
}

export const articlesRouter = (db: Database, users: UserMemo) => {
  const router = createRouter()

  const requireViewer = defineMiddleware(async (ctx) =>
    (await viewerOf(ctx, users)) ? undefined : unauthorizedError()
  )

  router.defineRoute({
    path: '/articles',
    schema: (z) => ({ response: z.array(ArticleView) }),
    meta: { summary: 'List the articles', tags: ['articles'] },
    handler: ({ cache, memo }) =>
      cache.getOrSet(articleListCache, async () => {
        const articles = await db.articles.list()
        // One query for every author, not one per article: the memo
        // batches the ids requested together.
        return Promise.all(
          articles.map((article) => toView(memo, users, article))
        )
      }),
  })

  router.defineRoute({
    path: '/articles/:id',
    schema: () => ({ response: ArticleView }),
    meta: {
      summary: 'Get an article',
      tags: ['articles'],
      responses: { 404: 'Article not found' },
    },
    handler: async ({ req, cache, memo }) => {
      const { id } = req.params
      // `undefined` (unknown article) is not cached.
      const view = await cache.getOrSet(articleCache, id, async () => {
        const article = await db.articles.findById(id)
        return article && toView(memo, users, article)
      })
      return view ?? notFoundError('Article not found')
    },
  })

  router.defineRoute({
    method: 'patch',
    path: '/articles/:id',
    middlewares: [requireViewer],
    schema: (z) => ({
      body: z.object({ title: z.string().trim().min(1).max(200) }),
      response: ArticleView,
    }),
    meta: {
      summary: 'Rename an article (its author only)',
      tags: ['articles'],
      responses: { 403: 'Not the author', 404: 'Article not found' },
    },
    handler: async (ctx) => {
      const { req, cache, memo } = ctx
      const { id } = req.params
      const article = await db.articles.findById(id)
      if (!article) return notFoundError('Article not found')
      // Loaded by the middleware: no new query.
      const viewer = await viewerOf(ctx, users)
      if (article.authorId !== viewer?.id) return forbiddenError()

      const updated = await db.articles.update(id, req.body.title)
      // This article, and the list that shows its title.
      await cache.invalidateTags([`article:${id}`, 'article-list'])
      return toView(memo, users, updated!)
    },
  })

  router.defineRoute({
    method: 'patch',
    path: '/users/me',
    middlewares: [requireViewer],
    schema: (z) => ({
      body: z.object({ name: z.string().trim().min(1).max(100) }),
    }),
    meta: { summary: 'Rename the current user', tags: ['users'] },
    handler: async (ctx) => {
      const viewer = await viewerOf(ctx, users)
      const user = await db.users.rename(viewer!.id, ctx.req.body.name)
      // Every cached article shows the name of its author.
      await ctx.cache.invalidateTags(['articles'])
      ctx.memo.set(users, user!.id, user)
      return user
    },
  })

  return router
}

The app

The cache fails open: when Redis is down or slower than 200 ms, requests go to the database, and the failures are logged.

import { createCache, type CacheStore } from '@dotpress/cache'
import { cachePlugin } from '@dotpress/cache/plugin'
import { createApp, type HttpAdapter, type Logger } from '@dotpress/core'
import { createDatabase, type Database } from './db.ts'
import { userMemo } from './loaders.ts'
import { articlesRouter } from './routes.ts'

export interface ArticlesAppOptions<TAdapter extends HttpAdapter> {
  adapter: TAdapter
  store: CacheStore
  db?: Database
  /** Prefix of the keys in the store. Defaults to `articles`. */
  prefix?: string
  logger?: Logger
}

/**
 * Creates the app on any adapter: Express in `server.ts`, the memory
 * adapter of `@dotpress/testing` in the tests.
 */
export const createArticlesApp = <TAdapter extends HttpAdapter>({
  adapter,
  store,
  db = createDatabase(),
  prefix = 'articles',
  logger,
}: ArticlesAppOptions<TAdapter>) => {
  const cache = createCache({
    store,
    prefix,
    // A slow store counts as a failure: the cache fails open, the request
    // goes on with the database.
    storeTimeout: '200ms',
  })

  return createApp({
    adapter,
    ...(logger && { logger }),
    routers: [articlesRouter(db, userMemo(db))],
    // ctx.memo: a new memo for each request. ctx.cache: the cache, logging
    // with the request logger.
    plugins: [cachePlugin({ cache })],
  })
}

What the tests check

The tests count the queries of the fake database:

import { afterEach, beforeEach, expect, it } from 'vitest'
import { startApp } from './support.ts'

// ctx.memo: a value is loaded once per request, and the ids requested
// together are loaded in one query.
let api: Awaited<ReturnType<typeof startApp>>

beforeEach(async () => {
  api = await startApp()
})

afterEach(() => api.close())

it('loads the authors of a list with one query, not one per article', async () => {
  const queries = await api.queriesOf(async () => {
    const res = await api.request('/articles')
    expect(await res.json()).toEqual([
      expect.objectContaining({
        id: 'a1',
        author: { id: 'u1', name: 'Alice' },
      }),
      expect.objectContaining({ id: 'a2', author: { id: 'u2', name: 'Bob' } }),
      expect.objectContaining({
        id: 'a3',
        author: { id: 'u1', name: 'Alice' },
      }),
    ])
  })

  // 3 articles, 2 distinct authors: one batch.
  expect(queries).toEqual(['articles.list', 'users.findManyById'])
})

it('shares the current user between the middleware and the handler', async () => {
  const queries = await api.queriesOf(async () => {
    const res = await api.patch('/articles/a1', { title: 'Caching 102' }, 'u1')
    expect(res.status).toBe(200)
  })

  // The middleware loads the user, the handler reads it from the memo, and
  // the view of the article reuses it for the author.
  expect(queries.filter((query) => query.startsWith('users.'))).toEqual([
    'users.findManyById',
  ])
})

it('starts a new memo for each request', async () => {
  const queries = await api.queriesOf(async () => {
    await api.patch('/articles/a1', { title: 'One' }, 'u1')
    await api.patch('/articles/a1', { title: 'Two' }, 'u1')
  })

  expect(queries.filter((query) => query.startsWith('users.'))).toHaveLength(2)
})

it('rejects unknown users, and other authors', async () => {
  expect((await api.patch('/articles/a1', { title: 'X' })).status).toBe(401)
  expect(
    (await api.patch('/articles/a1', { title: 'X' }, 'nobody')).status
  ).toBe(401)
  expect((await api.patch('/articles/a1', { title: 'X' }, 'u2')).status).toBe(
    403
  )
})

Learn more