Cache

One cache API. Memory or disk.

createCache() gives you a driver-agnostic store — get/set/has/delete/flush, a remember() read-through helper, TTL expiry, and tag invalidation. The zero-config in-memory driver is perfect for a single process; switch to the file driver to survive a restart — same interface, one config line.

Setup #

createCache lives in the server entry. Call it with no arguments for the in-memory driver (the zero-config default), or pass a driver discriminant. Values round-trip through JSON, so anything serializable is fair game.

import { createCache } from '@apex-stack/core/server'

const cache = createCache()                       // memory driver — the default
const disk  = createCache({ driver: 'file', dir: '.apex/cache' })

Read & write #

The whole surface is async. set takes an optional third argument — TTL in seconds; omit it and the entry never expires. A miss returns undefined.

await cache.set('user:1', { id: 1, name: 'Ada' })
await cache.get('user:1')        // → { id: 1, name: 'Ada' }
await cache.get('missing')       // → undefined
await cache.has('user:1')        // → true

await cache.set('otp:9', '123456', 60)  // expires 60s from now

await cache.delete('user:1')     // drop one key
await cache.flush()             // drop everything
TTL is lazy and correct

An expired entry returns undefined and is evicted on the next read — no background sweeper. The clock is injectable (createCache({ driver: 'memory', now })), so cache expiry is deterministic under test.

remember() #

The read-through pattern in one call: on a miss, run the factory and cache its result under the given TTL; on a hit, skip the factory entirely. The factory runs exactly once until the value expires.

const report = await cache.remember('report:q3', 300, async () => {
  return await computeExpensiveReport()   // only runs on a miss
})

Tag invalidation #

Group related keys under one or more tags via cache.tags([...]), then flush the whole group in one call. Flushing a tag drops every key registered under it and leaves other tags untouched — a key tagged under several tags is dropped when any of them is flushed.

await cache.tags(['users']).set('user:1', { id: 1 })
await cache.tags(['users']).set('user:2', { id: 2 })
await cache.tags(['posts']).set('post:1', { id: 1 })

await cache.tags(['users']).flush()   // user:1 + user:2 gone; post:1 survives

// remember() works through a tag view too — the key is registered under the tag.
await cache.tags(['reports']).remember('report:q3', 60, async () => compute())

File driver #

The file driver writes each entry as a JSON file under a base directory, so the cache survives a process restart — a fresh createCache({ driver: 'file', dir }) over the same directory reads the persisted data. TTL, delete, and flush behave exactly as the memory driver; expired files are evicted on read.

const cache = createCache({ driver: 'file', dir: '.apex/cache' })
await cache.set('k', { hello: 'world' })

// A new instance over the same dir sees the persisted value.
const reopened = createCache({ driver: 'file', dir: '.apex/cache' })
await reopened.get('k')   // → { hello: 'world' }

Both drivers are pure and clock-injectable — the same store powers rate limiting and idempotency (see Auth → Hardening).