cache
| Package | @theoven/cache |
| Adds to context | ctx.cache |
| Endpoints | none |
| Creates files | none |
| Creates tables | none |
| Status | shipped |
Install
Section titled “Install”bun add @theoven/cacheimport { 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 }, ),)What it does
Section titled “What it does”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.
Endpoints
Section titled “Endpoints”This brick adds no endpoints.
cached() is the whole API
Section titled “cached() is the whole API”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.
Concurrent misses share one call
Section titled “Concurrent misses share one call”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.
Invalidating by tag
Section titled “Invalidating by tag”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 goneTag 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.
The rest
Section titled “The rest”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:
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}`] }, )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.
What it creates
Section titled “What it creates”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.
Drivers
Section titled “Drivers”| 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.
Configuration
Section titled “Configuration”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() |
Capabilities
Section titled “Capabilities”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.
Limitations
Section titled “Limitations”- 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
DELof many keys, which is fine at thousands and not at millions. - Values must survive
JSONround-tripping on the Redis driver. ADatecomes back as a string; the memory driver keeps the original reference.
How it is verified
Section titled “How it is verified”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.