Low-level cache (wrap)
The ctx.cache facade and the Effect Cache service — wrap (cache-aside), tags, stale-while-revalidate, and per-key single-flight de-duplication.
The low-level cache is available in every handler two ways:
- async handlers →
ctx.cache(the facade) - Effect handlers →
yield* Cachefrom@voltro/cache
Both wrap the same backend; pick whichever matches the handler you're writing.
wrap — the cache-aside combinator
wrap(key, options, compute) returns the cached value if present, otherwise runs compute, stores the result under key with the given TTL + tags, and returns it.
// async handler
export default async (input: { orgId: string }, ctx) =>
ctx.cache.wrap(
`dashboard:${input.orgId}`,
{ ttlMs: 60_000, tags: [`org:${input.orgId}`, 'metrics'] },
async () => expensiveAggregation(ctx, input.orgId), // runs only on a miss
)// Effect handler
import { Cache } from '@voltro/cache'
import { Effect } from 'effect'
export default (input: { orgId: string }) =>
Effect.gen(function* () {
const cache = yield* Cache
return yield* cache.wrap(
`dashboard:${input.orgId}`,
{ ttlMs: 60_000, tags: [`org:${input.orgId}`] },
buildDashboard(input.orgId), // an Effect; runs only on a miss
)
})options:
ttlMs— fresh window in milliseconds. Omit for no expiry (lives until invalidated or evicted).tags— labels for invalidation (typically the tables the compute reads).invalidateTag(tag)drops every entry carrying that tag.swrMs— stale-while-revalidate window pastttlMs. Within it, the stale value is served immediately and a refresh runs in the background, so callers never wait on the slow recompute.
Tags + invalidation
Tags are the headline feature. Instead of tracking every derived key, invalidate by concept:
// write path — a raw-SQL admin action the framework can't observe
await ctx.cache.invalidateTag('metrics') // drops every entry tagged 'metrics'You rarely call invalidateTag by hand: an ordinary mutation that writes a table auto-drops every cache entry tagged with that table name (the same bus that powers query caching). Reach for the manual call only for writes the framework doesn't see — raw SQL via sql.unsafe, external systems, etc. Tag your wrap entries with the table names they derive from and invalidation is automatic.
Single-flight (thundering-herd protection)
When N concurrent callers hit the same cold key, wrap runs compute once and shares the one result with all of them (per-process). This matters most exactly when the cache is empty — without it, a cache miss under load fans out into N identical expensive computes against your database.
Other operations
await ctx.cache.get<T>(key) // T | undefined
await ctx.cache.set(key, value, { ttlMs }) // no tags
await ctx.cache.setWithTags(key, value, { ttlMs, tags })
await ctx.cache.has(key) // boolean
await ctx.cache.remove(key) // boolean (existed?)
await ctx.cache.invalidateTag(tag) // number dropped
await ctx.cache.clear() // whole namespaceIn Effect handlers the same methods live on the Cache service and return Effects. One return-type difference: the Effect service's get yields Effect<Option<A>, CacheError> — an Option, not a bare value — so reach for Option.getOrUndefined(...) (or pattern-match) where the ctx.cache facade hands you A | undefined directly:
import { Cache } from '@voltro/cache'
import { Effect, Option } from 'effect'
const cache = yield* Cache
const hit = yield* cache.get<Dashboard>(key) // Option<Dashboard>
const value = Option.getOrUndefined(hit) // Dashboard | undefinedCache failures surface as a typed CacheError; Effect.catchTag('CacheError', () => compute) makes the cache best-effort (degrade to always-recompute) when a backend is briefly down.
Partitions — one namespace per concern
cache.partition(name, defaults?) returns a key-prefixed VIEW over the cache — one logical namespace per concern, each with its own default ttl/swr:
import { Cache } from '@voltro/cache'
import { Effect } from 'effect'
const program = Effect.gen(function* () {
const cache = yield* Cache
const sessions = cache.partition('sessions', { ttlMs: 60_000 })
const reports = cache.partition('reports', { ttlMs: 3_600_000, swrMs: 86_400_000 })
yield* sessions.set('u1', { token: 'x' }) // stored under `sessions:u1`
const r = yield* reports.wrap('q1', {}, Effect.succeed({ rows: 1 })) // inherits the partition's ttl+swr
return r
})Keys become <name>:<key> so partitions can't collide; per-call options override the partition defaults. Tags are NOT prefixed — they stay global on purpose, so a table-change invalidation through the bus still drops matching entries in every partition. A partition shares the parent cache's backend; to put a concern on a different backend or server, configure it as its own concern (the durable Kv, or a separately-provided cache) via named connections. Methods on a partition: get / set / has / remove / wrap / invalidateTag.
Don't
- Don't mutate a value you got from the cache. On the memory backend it's the live reference. Clone if you must edit.
- Don't cache per-subject data under a global key. If the value differs per user/tenant, put the subject id in the key (
summary:${tenantId}). The automatic query cache handles this for you viascope; with rawwrapit's your responsibility.