#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 devcurl 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 articleThe 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 devThe 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
)
})