Key-value backends
The KvStore port and its backends — database (durable, _voltro_kv), redis (shared), and memory — plus the KV_BACKEND selector, the @voltro/kv package, and custom backends.
ctx.kv runs on a KvStore — a small Effect-native port with three built-in backends. Which one you get is a configuration choice; the handler code is identical, exactly like the cache.
Selecting the backend
KV_BACKEND picks it (the kv: field in app.config.ts seeds the env when unset):
// app.config.ts
export default { type: 'api', name: 'myApi', store: 'postgres', kv: 'database' }| Backend | Durable? | What it is | Use |
|---|---|---|---|
database (default, sql apps) |
yes — persists in _voltro_kv |
rows in your own SQL store | the default; survives restarts, shared across replicas |
redis |
no | keys on a RESP server | shared/cross-instance but deliberately ephemeral state |
memory |
no | an in-process map | dev / tests / single-process |
Unknown values fall back to database. On an app with no SQL store, use memory or redis.
database — the durable default
The database backend stores each entry as a row in _voltro_kv, a framework-managed table created for every SQL app (empty until ctx.kv is used). The value is a JSON string in a text column — exact round-trip for any value — with an optional expiresAt (lazy eviction on read) and a UNIQUE(key) that makes set an atomic upsert. Because it lives in your store, it survives restarts and every replica sees the same data.
redis — shared, non-durable
The redis backend keys entries under a prefix (default voltro:kv) on any RESP server (Redis / Valkey / KeyDB / Dragonfly / Upstash). It draws its client from the shared connection registry — one socket per named connection, reused across the cache, KV, and rate limiter. Redis is a cache, not a durable store, so pick it only for state you can afford to lose on a flush.
memory — dev / tests
An in-process map, eviction-free (entries live until deleted or TTL-expired). Lost on restart and invisible to other replicas — for a single process, tests, or throwaway state. Under the test harness (makeTestContext) ctx.kv is a memory KV whose TTLs honour ctx.clock.
The @voltro/kv package
The primitive ships in @voltro/kv (a leaf package — effect is the only runtime dependency; the redis drivers are lazily-imported optional deps). Wire the layers by hand only when embedding the KV outside the framework path; inside a Voltro app you just use ctx.kv / Kv.
import { Kv, KvStore, layerMemory, layerRedis } from '@voltro/kv'
import { Layer } from 'effect'
// memory-backed Kv service
const MemoryKv = Kv.Default.pipe(Layer.provide(layerMemory()))Key exports: the Kv service, the KvStore port (+ KvStoreShape, KvSetOptions), KvError (the single typed failure), and the backend layers layerMemory / layerRedis (plus makeRedisKvStore over an existing client). The database backend + the ctx.kv facade live in @voltro/runtime (they need @voltro/database).
The KvStore port
KvStore is a Context.Tag of the KvStoreShape interface (get / set / delete / has / list / clear, all Effect-returning). Both built-in backends are just a Layer<KvStore>; a custom backend plugs into the same slot:
import { Kv, KvStore, type KvStoreShape } from '@voltro/kv'
import { Layer } from 'effect'
const myBackend: KvStoreShape = {
// get / set / delete / has / list / clear — each returns an Effect<…, KvError>
} as KvStoreShape
const MyKv = Kv.Default.pipe(Layer.provide(Layer.succeed(KvStore, KvStore.of(myBackend))))KvError
Every KV operation fails with one typed error — a Schema.TaggedError with { operation, key, cause }, mirroring the cache's CacheError. Declare it in a handler's error: schema and the rpc encoder marshals it across the wire; Effect.catchTag('KvError', …) catches it on the Effect path.