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 code

Tenant 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-level wrap and 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 backendsdatabase (durable) vs redis vs memory, the KV_BACKEND selector, the KvStore port, 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.