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:

services.ts
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.

config/cache.ts
import type { CacheConfig } from '@tekir/cache'

export default {
  default: 'memory',
  ttl: 3600,
  stores: {
    memory: { driver: 'memory' }
  }
} satisfies CacheConfig

For production with Redis (requires bun add @tekir/redis):

config/cache.ts
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 CacheConfig

Or with a database-backed store (uses the registered db service):

config/cache.ts
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 CacheStore instance.

Register CacheProvider in your kernel:

start/kernel.ts
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')
// => null

has / 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 cache

TTL

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 everything

RedisCacheStore

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 store

HTTP 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:

start/routes.ts
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 entry

Want 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=ttl header. Default true.

@Cache decorator

Controller methods can opt into the same middleware via the @Cache decorator from @tekir/http-decorators:

core/controllers/posts_controller.ts
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: REVALIDATED

The x-tekir-cache response header reports the path taken: MISS on first hit, HIT on cached replay, REVALIDATED on a 304.