Cache
A unified caching layer with swappable backends, Memory, Redis, and Database.
Introduction
@tekir/cache provides a single Cache class that sits in front of any store (backend). You interact with one consistent API regardless of whether values are kept in process memory, Redis, or an SQLite table. Stores can be mixed within the same application, a hot memory store for ephemeral flags and a shared redis store for session data, for example.
Access the cache via #services:
import { service } from '@tekir/core'
import type { Cache } from '@tekir/cache'
export const cache = service<Cache>('cache')Configuration
Create config/cache.ts and export a plain object. The CacheProvider reads this file automatically at boot.
import type { CacheConfig } from '@tekir/cache'
export default {
default: 'memory',
ttl: 3600,
stores: {
memory: { driver: 'memory' }
}
} satisfies CacheConfigFor production with Redis (requires bun add @tekir/redis):
import env from '#env'
import type { CacheConfig } from '@tekir/cache'
export default {
default: 'redis',
ttl: 3600,
stores: {
memory: { driver: 'memory' },
redis: {
driver: 'redis',
url: env.REDIS_URL, // e.g. 'redis://localhost:6379'
prefix: 'myapp:cache:' // key prefix in Redis
}
}
} satisfies CacheConfigOr with a database-backed store (uses the registered db service):
import type { CacheConfig } from '@tekir/cache'
export default {
default: 'database',
ttl: 3600,
stores: {
database: {
driver: 'database',
table: 'cache' // auto-created if it doesn't exist
}
}
} satisfies CacheConfig- default: the name of the store that
cache.get()/cache.set()etc. operate on. - ttl: fallback TTL in seconds used by
cache.set()when no explicit TTL is passed. - stores: a map of store name to
CacheStoreinstance.
Register CacheProvider in your kernel:
import type { TekirApp } from '@tekir/core'
import { CacheProvider } from '@tekir/cache'
export default function({ app }: TekirApp) {
app.registerAll([CacheProvider])
}Basic Usage
get / set
get returns the stored value or null on a miss (or expiry). set accepts an optional TTL (seconds); if omitted the config default is used.
import { cache } from '#services'
// Store a string
await cache.set('site:name', 'tekir Framework')
// Retrieve it
const name = await cache.get<string>('site:name')
// => 'tekir Framework'
// Returns null when the key does not exist or has expired
const missing = await cache.get('does:not:exist')
// => nullhas / delete / flush
has returns a boolean without transferring the value. delete removes a single key. flush wipes every key in the active store, use with care in production.
import { cache } from '#services'
// Check existence without fetching the value
const exists = await cache.has('site:name') // true
// Remove a single key
await cache.delete('site:name')
// Wipe every key in the active store
await cache.flush()getOrSet
The most useful cache primitive: return the cached value if it exists, otherwise run a factory function, store the result, and return it. The factory is only called on a cache miss.
import { cache, db } from '#services'
// Fetch from cache; if missing, run the factory and store the result.
// Signature: getOrSet(key, ttlSeconds, factory)
const user = await cache.getOrSet('user:42', 300, async () => {
return await db.queryOne('SELECT * FROM users WHERE id = ?', [42])
})
// The factory only runs on a cache miss: subsequent calls return the
// cached value without touching the database.pull
pull reads a value and immediately deletes it, useful for one-time tokens, flash messages, or CSRF nonces.
import { cache } from '#services'
// Read the value AND delete it in one operation (like a one-time token)
const token = await cache.pull<string>('password-reset:abc123')
if (!token) {
throw new Error('Token expired or already used')
}
// token is now gone from the cacheTTL
TTL values are always expressed in seconds. Every store respects TTL independently, MemoryCacheStore checks expiry on each read; RedisCacheStore delegates to Redis EXPIRE; DatabaseCacheStore stores an expires_at timestamp and prunes stale rows lazily on read.
import { cache } from '#services'
// Store for 60 seconds (overrides the default TTL from config)
await cache.set('flash:message', 'Saved!', 60)
// Store with config default TTL (pass no third argument)
await cache.set('app:version', '1.0.0')
// getOrSet with an explicit TTL
const data = await cache.getOrSet('expensive:query', 600, async () => {
return await db.query('SELECT * FROM analytics WHERE date = CURRENT_DATE')
})Cache Stores
MemoryCacheStore
An in-process Map-backed store. Zero dependencies, fastest possible reads. Data is lost on process restart and not shared between workers. Ideal for local development, tests, and short-lived caches.
import { MemoryCacheStore } from '@tekir/cache'
const store = new MemoryCacheStore()
await store.set('foo', { bar: 1 }, 60) // expires in 60 s
const val = await store.get('foo') // { bar: 1 }
await store.flush() // clear everythingRedisCacheStore
Wraps any Redis client that exposes get, set, expire, exists, del, and send. Use with @tekir/redis(Bun's native Redis client) for best performance. Values are JSON-serialized automatically. All keys are prefixed (default cache:) to avoid collisions.
import { RedisCacheStore } from '@tekir/cache'
import { redis } from '#services'
// Second argument is an optional key prefix (default: 'cache:')
const store = new RedisCacheStore(redis, 'myapp:')
await store.set('session:abc', { userId: 1 }, 900)
const session = await store.get<{ userId: number }>('session:abc')DatabaseCacheStore
Persists cache entries in a SQL table (default name: cache). The constructor creates the table with CREATE TABLE IF NOT EXISTS on first use. Values are JSON-serialized; expiry is checked on read with lazy deletion.
import { DatabaseCacheStore } from '@tekir/cache'
import { db } from '#services'
// The constructor creates the cache table automatically if it does not exist.
// Second argument is the table name (default: 'cache')
const store = new DatabaseCacheStore(db, 'cache')
await store.set('config:flags', { darkMode: true }, 86400)
const flags = await store.get<{ darkMode: boolean }>('config:flags')Switching Stores at Runtime
Call cache.store(name) to get the underlying CacheStore for a named backend. All store methods (get, set, has, delete, flush) are available on the returned object.
import { cache } from '#services'
// Use a specific store by name
await cache.store('memory').set('local:flag', true)
await cache.store('redis').set('shared:flag', true)
// The default store methods are proxied on the Cache instance itself
await cache.set('key', 'value') // writes to the default storeHTTP Response Cache
The cache() middleware caches an entire HTTP response (status, headers, body) under a key derived from the request and replays it on subsequent hits. It pairs with the same CacheStore backends, so cached responses can live in memory, Redis, or your database.
Only safe methods (GET, HEAD) are cached by default. Mutating methods, error responses (5xx), empty bodies (204), and requests carrying Cache-Control: no-store all skip the cache automatically.
Adding the middleware
Once CacheProvider is registered the middleware auto-resolves the default store, so the only argument you usually need is ttl:
import { cache } from '@tekir/cache'
// Routes file
router.get(
'/api/posts',
cache({ ttl: 60 }),
async () => Post.all(),
)
// First request: handler runs, response stored, x-tekir-cache: MISS
// Next 60 seconds: handler skipped, x-tekir-cache: HIT
// After ttl: next request misses again, repopulates the entryWant to use a specific store instance instead of the global default? Pass it via store:
Options
Every option is optional. Defaults work for typical read endpoints: 60-second TTL, GET/HEAD only, key built from method + URL.
import { cache } from '@tekir/cache'
router.get(
'/api/users/:id',
cache({
ttl: 300, // seconds
methods: ['GET', 'HEAD'], // default
vary: ['accept-language'], // separate entry per header value
key: (ctx) => `user:${ctx.params.id}`, // custom cache key
skip: (ctx) => ctx.auth?.user?.role === 'admin', // bypass for admins
prefix: 'http:', // namespace inside the store
setCacheControl: true, // emits Cache-Control: public, max-age=ttl
}),
async ({ params }) => User.find(params.id),
)- ttl: seconds the entry stays in the store, also mirrored on the response as
Cache-Control: max-age. - methods: which HTTP methods are cacheable. Default
['GET', 'HEAD']. - vary: list of request headers to fold into the cache key. Useful for content negotiation (language, theme).
- key: function that returns a custom key string. Override when route params or query parameters should participate.
- skip: predicate that bypasses caching for the current request. Use it for authenticated or per-user views.
- prefix: namespace prefix for keys inside the store. Default
http:. - setCacheControl: emits the
Cache-Control: public, max-age=ttlheader. Defaulttrue.
@Cache decorator
Controller methods can opt into the same middleware via the @Cache decorator from @tekir/http-decorators:
import { Controller, Get, Cache } from '@tekir/http-decorators'
@Controller('/api/posts')
class PostsController {
@Get('/')
@Cache({ ttl: 60 })
async list() {
return Post.all()
}
@Get('/:id')
@Cache({ ttl: 300, key: (ctx) => `post:${ctx.params.id}` })
async show({ params }: HttpContext) {
return Post.find(params.id)
}
}Conditional requests
Cached responses include a weak ETag. When the client echoes that ETag back via If-None-Match, the middleware returns 304 Not Modified with no body, which keeps bandwidth use to a minimum:
// First request gets a fresh body and an ETag
// $ curl -i http://localhost:3000/api/posts
// HTTP/1.1 200 OK
// etag: W/"a1b2c3d4"
// x-tekir-cache: MISS
// cache-control: public, max-age=60
// [...body...]
// Subsequent requests with If-None-Match short-circuit to 304
// $ curl -i -H 'If-None-Match: W/"a1b2c3d4"' http://localhost:3000/api/posts
// HTTP/1.1 304 Not Modified
// etag: W/"a1b2c3d4"
// x-tekir-cache: REVALIDATEDThe x-tekir-cache response header reports the path taken: MISS on first hit, HIT on cached replay, REVALIDATED on a 304.