Skip to content

cache

Package @theoven/cache
Adds to context ctx.cache
Endpoints none
Creates files none
Creates tables none
Status shipped
Terminal window
bun add @theoven/cache
src/app.ts
import { cache, memoryCache, redisCache } from '@theoven/cache'
export const app = createApp().use(
cache(
env.has('REDIS_URL') ? redisCache({ url: env.string('REDIS_URL') }) : memoryCache(),
{ ttl: 60_000 },
),
)

Gives you ctx.cache — get, set, delete, tag-invalidate, and cached(), which is the one you will actually use. Two drivers ship: an in-process LRU and Redis, behind the same interface, so moving from one to the other is a config change.

The two things it does that a Map does not: collapsing concurrent misses so a cold popular key does not stampede your database, and invalidating by tag so you do not have to remember every key derived from a thing that changed.

This brick adds no endpoints.

const user = await ctx.cache.cached(
`user:${id}`,
() => ctx.db.select().from(users).where(eq(users.id, id)),
{ ttl: 60_000, tags: [`user:${id}`] },
)

Hit, and you get the value. Miss, and the producer runs once and the result is stored.

This is the part worth having a library for. A popular key expires, fifty requests miss it in the same instant, and all fifty recompute — a cache stampede, which is how a cache turns a slow query into an outage.

cached() collapses them: the first miss starts the work, the other forty-nine await the same promise.

A producer that throws does not poison the key: the next caller gets a fresh attempt rather than the same rejection forever.

await ctx.cache.set('user:1:profile', profile, { tags: ['user:1'] })
await ctx.cache.set('user:1:posts', posts, { tags: ['user:1'] })
await ctx.cache.invalidate('user:1') // both gone

Tag the thing that can change, and invalidate that — rather than remembering every key derived from it, which is the version everyone gets wrong on the third feature.

await ctx.cache.get<User>('user:1')
await ctx.cache.set('user:1', user, { ttl: 30_000 })
await ctx.cache.delete('user:1')
await ctx.cache.clear()

The common shape — an expensive read, cached and tagged so a write can invalidate it:

src/routes/users/[id].get.ts
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { users } from '../../schema'
export const params = z.object({ id: z.uuid() })
export default async ({ cache, db, params }) =>
cache.cached(
`user:${params.id}`,
async () => {
const [user] = await db.select().from(users).where(eq(users.id, params.id))
if (!user) throw new NotFound(`No user ${params.id}.`)
return user
},
{ ttl: 60_000, tags: [`user:${params.id}`] },
)
src/routes/users/[id].patch.ts
export default async ({ cache, db, params, body }) => {
const updated = await db.update(users).set(body).where(eq(users.id, params.id)).returning()
// Every key tagged with this user goes, whichever route cached it.
await cache.invalidate(`user:${params.id}`)
return updated[0]
}

Note the throw inside the producer: it propagates to the caller and nothing is cached, so a missing row does not become a cached 404.

Caching something that is not a database row

Section titled “Caching something that is not a database row”
const rates = await ctx.cache.cached(
'fx:usd',
async () => (await fetch('https://api.example.com/fx/usd')).json(),
{ ttl: 5 * 60_000 },
)

This is where stampede protection earns its place: a third-party API that rate-limits you will otherwise get one request per concurrent miss the moment the entry expires.

Files: none. Tables: none.

With redisCache, keys are written under the configured prefix (default oven:cache), plus one Redis set per tag. Nothing else in your Redis is touched, and clear() removes only prefixed keys.

Driver Stores in Use for
memoryCache({ max }) this process development and tests, the default
redisCache({ url }) Redis production, and anything shared across instances

memoryCache evicts least-recently-used at max (default 10 000). An unbounded in-process cache is a memory leak with a friendly name — it grows until the process dies, and the failure looks like a leak somewhere else entirely.

cache(redisCache({ url: env.string('REDIS_URL'), prefix: 'myapp:' }), {
ttl: 60_000,
allowMemoryInProduction: false,
})

The brick:

Option Default
ttl none default lifetime in ms when a call does not give one
allowMemoryInProduction false permit the in-process driver outside development

memoryCache(options):

Option Default
max 10000 entries kept before least-recently-used eviction

redisCache(options):

Option Default
url REDIS_URL connection string
prefix oven:cache namespace for every key this cache writes
client — an existing Bun.RedisClient to adopt instead of connecting

Per call, set() and cached() take:

Option Default
ttl the brick’s ttl lifetime in ms for this entry
tags none tags this entry belongs to, for invalidate()
get, set, delete, clear ✓ both drivers
cached() with stampede collapsing ✓ both, within one process
invalidate(tag) ✓ both
Shared across instances Redis only
LRU eviction memory only — Redis has its own maxmemory policy
stale-while-revalidate ✗
Cross-instance stampede lock ✗ by choice — see above

What fails at boot: memoryCache in production without allowMemoryInProduction; and a Redis URL that cannot be reached.

  • No stale-while-revalidate. An expired entry is a miss; serving stale while refreshing in the background is not implemented.
  • No cross-instance stampede lock, by choice — see above.
  • Redis tags are sets of key names. Invalidating a tag with a very large membership is one DEL of many keys, which is fine at thousands and not at millions.
  • Values must survive JSON round-tripping on the Redis driver. A Date comes back as a string; the memory driver keeps the original reference.

Both drivers run the same conformance suite from @theoven/cache/testing — expiry, tag invalidation, detaching a re-set key from its old tags, and that clearing empties everything.

The stampede test fires twenty concurrent requests at a cold key and asserts the producer ran once. The poisoning test was written first and failed: an async function body runs synchronously up to its first await, so a producer throwing immediately cleared the in-flight entry before it was ever recorded — and the rejected promise was then served forever. Both are regression tests now.