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 handlersctx.cache (the facade)
  • Effect handlersyield* Cache from @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 past ttlMs. 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 namespace

In 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 | undefined

Cache 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 via scope; with raw wrap it's your responsibility.