Durable key-value (ctx.kv)
ctx.kv — the durable key-value primitive from @voltro/kv, distinct from the cache (never evicted). Full API, TTL, tenant namespacing, and the Effect-native Kv service.
The cache is for data you can recompute. Some state you can't — onboarding progress, a webhook cursor, a sync watermark, a per-user feature toggle. Losing a cached dashboard is free; losing that is data loss. For it Voltro ships a second primitive in @voltro/kv, reached as ctx.kv (async handlers) or Kv (Effect handlers).
The contract — durable, never evicted
ctx.kv is a key-value store with the opposite contract to the cache: entries are never evicted for capacity. They live until you delete them or their (optional) TTL lapses. It is always present, and on a SQL app its default backend is the database — so values survive restarts and are shared across every replica.
| Aspect | ctx.cache |
ctx.kv |
ctx.store |
|---|---|---|---|
| For | recomputable data | state you can't recompute | typed relational rows |
| A miss means | recompute it | never written / TTL lapsed | row absent |
| Eviction | yes (capacity, LRU-ish) | never | n/a |
| Default backend | memory | database (durable) | your SQL store |
| Shape | opaque JSON + tags | opaque JSON | typed tables, queries, joins |
Usage
// any async handler
export default async (input: { userId: string }, ctx) => {
const state = await ctx.kv.getOrElse(`onboarding:${input.userId}`, () => ({ step: 0 }))
await ctx.kv.set(`onboarding:${input.userId}`, { step: state.step + 1 })
return state
}In an Effect handler, reach the service directly:
import { Kv } from '@voltro/kv'
import { Effect } from 'effect'
export default (input: { userId: string }) =>
Effect.gen(function* () {
const kv = yield* Kv
const seen = yield* kv.getOrElse(`seen:${input.userId}`, () => 0)
yield* kv.set(`seen:${input.userId}`, seen + 1)
return seen
})The full surface
| Method | Returns | Notes |
|---|---|---|
get<A>(key) |
A | undefined (async) · Option<A> (Effect) |
miss/expiry → absent |
getOrElse(key, orElse) |
A |
orElse() runs only on a miss |
set(key, value, { ttlMs? }) |
void |
overwrites; omit ttlMs for no expiry |
delete(key) |
boolean |
true if it existed (and hadn't expired) |
has(key) |
boolean |
honours expiry, no deserialize |
list(prefix) |
string[] |
keys under a prefix ('' = all in this namespace) |
clear() |
void |
drops this app's KV namespace only |
Values are opaque JSON — a genuine string round-trips exactly (it is never re-parsed to a number, the way a dialect-dependent json read might be).
TTL
set(key, value, { ttlMs }) gives an entry an expiry; without it, the entry is permanent. Expiry is lazy — an expired entry reads as a miss and is dropped on the next get/has (no background sweeper). This is durability with an optional lifetime, not the cache's capacity-driven eviction.
await ctx.kv.set(`otp:${email}`, code, { ttlMs: 10 * 60_000 }) // 10-minute one-time codeTenant namespacing
ctx.kv keys are app-global — namespace by tenant yourself, exactly as you do with ctx.cache:
const key = `${ctx.request.subject.tenantId ?? 'anon'}:onboarding:${userId}`
await ctx.kv.set(key, state)The facade never prefixes for you, so two tenants sharing a bare key would collide — always fold the tenant (or actor) into the key for per-tenant state.
When to use which
ctx.kv— durable scratch state you can't rebuild: onboarding/wizard progress, external-sync cursors and watermarks, idempotency-ish markers, per-user flags, short-lived tokens (with TTL).ctx.cache— anything you can recompute; a miss just recomputes. See low-levelwrapand query caching.ctx.store— when the data is relational, queried, joined, or reported on — model it as a table.
Where to go next
- Key-value backends —
database(durable) vsredisvsmemory, theKV_BACKENDselector, theKvStoreport, custom backends. - Named connections — how the cache, KV, rate limiter, and broadcast share one connection registry, and how to point each concern at its own server.