# Caching

> Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation.



---

<!-- source: en/caching/overview.md -->
## Overview

_Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation._

Voltro ships a first-class cache in **`@voltro/cache`**. It is **always on** — the in-process `memory` backend is the zero-config default, so `ctx.cache` works in every handler from the first boot with nothing to install. You opt into a *distributed* backend (Redis & friends) only when you need cross-instance sharing.

There are two ways to use it, and they share one backend:

1. **Low-level** — `ctx.cache.wrap(key, { ttl, tags }, () => compute())` in any handler. Cache-aside with tags, stale-while-revalidate, and single-flight de-duplication. Use it for expensive derived work: aggregations, external-API enrichment, rendered fragments.
2. **Automatic query caching** — add a `cache` field to a `defineQuery` and the framework caches the server snapshot and **invalidates it automatically** when a mutation writes any table the query depends on. Zero manual busting.

The defining idea mirrors the rest of Voltro: **the backend is a configuration choice, the code is identical.** The same `wrap` call and the same `cache:` field run against the in-process map in dev and against Redis (or Valkey / KeyDB / Dragonfly / Upstash) in production — you flip `CACHE_BACKEND`, not your handlers.

```ts
// app.config.ts — memory (default) or redis
export default { type: 'api', name: 'myApi', store: 'postgres', cache: 'redis' }
```

```ts
// any handler — works on every backend
export default async (input: { orgId: string }, ctx) =>
  ctx.cache.wrap(
    `dashboard:${input.orgId}`,
    { ttlMs: 60_000, tags: [`org:${input.orgId}`] },
    async () => expensiveDashboard(ctx, input.orgId),
  )
```

### What it is not

- **Not the reactive query engine.** Live `useSubscription` queries already stay fresh by pushing deltas — caching is for *non-live* derived work and for sharing a query's initial snapshot across many subscribers/instances.
- **Not a runtime plugin.** A cache backend is infrastructure, so it's an `app.config` + baseline concern (`voltro add redis`), not a `plugins:` entry.

### Where to go next

- **[Backends & engines](/docs/caching/backends)** — memory vs Redis, the five RESP-compatible engines, connection presets, env vars.
- **[Low-level `wrap`](/docs/caching/wrap)** — `ctx.cache` / the Effect `Cache` service, tags, SWR, single-flight.
- **[Query caching](/docs/caching/query-cache)** — the `cache:` field, the `scope` security rule, auto-invalidation.
- **[Durable key-value](/docs/caching/key-value)** — `ctx.kv` (durable, never evicted — for state you can't recompute): the full API, TTL, tenant namespacing.
- **[Key-value backends](/docs/caching/kv-backends)** — `database` (durable) vs `redis` vs `memory`, the `KV_BACKEND` selector, the `KvStore` port, custom backends.
- **[Named connections](/docs/caching/connections)** — the shared registry behind the cache / KV / rate limiter / broadcast, the `<NAME>_REDIS_URL` → `REDIS_URL` scheme, and per-concern enablement.
- **[Enabling Redis](/docs/caching/enabling)** — `voltro add redis`, `create-project --cache=redis`, the `voltro cache` CLI, the dashboard panel.



---

<!-- source: en/caching/backends.md -->
## Backends & engines

_The two cache backends (memory + RESP) and the five Redis-compatible engines they cover — Redis, Valkey, KeyDB, Dragonfly, Upstash — plus every env var and connection preset._

The backend is chosen by environment variable (or `app.config.ts`) and is shared by both caching modes. Everything else — `wrap`, the `cache:` query field, tag invalidation — is backend-agnostic.

## Selecting a backend

```
CACHE_BACKEND       memory | redis            (default: memory)
CACHE_REDIS_URL     redis/rediss/REST url     (falls back to REDIS_URL)
CACHE_REDIS_DRIVER  resp | http               (default: resp; http = Upstash REST, for edge)
CACHE_REDIS_TOKEN   http-driver auth          (falls back to UPSTASH_REDIS_REST_TOKEN)
CACHE_KEY_PREFIX    namespace                 (redis backend only; default: voltro:cache)
CACHE_MAX_ENTRIES   memory capacity cap       (optional, memory backend only)
```

`CACHE_KEY_PREFIX` is consumed only by the **redis** backend (it namespaces the RESP keyspace). Under `CACHE_BACKEND=memory` it parses but is a no-op — the memory map isn't a shared keyspace, so there is nothing to prefix.

```ts
// app.config.ts — env always wins over this
export default { type: 'api', name: 'myApi', store: 'postgres', cache: 'redis' }
```

The resolved backend is printed at boot — `voltro logs --tail 50 | grep 'cache backend'`:

```
[voltro:dev] cache backend resolved: redis (driver resp)
```

## memory (default)

In-process Map with lazy expiry. **Single instance only** — entries live in the process, invisible to other nodes. Zero-config, perfect for dev and single-pod deployments. `CACHE_MAX_ENTRIES` bounds it for write-heavy workloads. Resets on restart.

Capacity eviction is **oldest-by-insertion (FIFO)** — reads do not bump recency, so under cap pressure a hot frequently-read entry is dropped before a cold never-read one. This is NOT LRU: it's a deliberate simplicity trade for a single-process dev/cache tier (the cross-instance tier is redis). Size the cap above your hot-set, or rely on per-entry TTL, if that matters.

Use it when: you're in dev, or you run exactly one app instance and don't need the cache to survive a restart.

## redis — one backend, five engines

The `redis` backend speaks the **RESP wire protocol**, so a single implementation drives five engines with **no per-engine code**:

| Engine | Driver | Notes |
|---|---|---|
| **Redis** | `resp` (TCP) | The reference implementation. |
| **Valkey** | `resp` (TCP) | The Linux-Foundation fork of Redis — drop-in. |
| **KeyDB** | `resp` (TCP) | Multithreaded Redis fork — drop-in. |
| **Dragonfly** | `resp` (TCP) | Modern high-throughput drop-in. |
| **Upstash** | `resp` (TCP) **or** `http` (REST) | Use `http` for serverless/edge runtimes where a persistent TCP socket isn't viable. |

Connection presets:

```sh
# Redis / Valkey / KeyDB / Dragonfly (all identical — just point at the server)
CACHE_BACKEND=redis CACHE_REDIS_URL=redis://localhost:6379

# TLS
CACHE_REDIS_URL=rediss://default:password@host:6379

# Upstash over the edge HTTP/REST API
CACHE_BACKEND=redis \
CACHE_REDIS_DRIVER=http \
CACHE_REDIS_URL=https://your-db.upstash.io \
CACHE_REDIS_TOKEN=********
```

Use it when: you run more than one app instance (k8s replicas, multi-PM2, ECS tasks) and need a shared cache, or you want the cache to survive restarts.

### Storage layout

Under `CACHE_KEY_PREFIX` (default `voltro:cache`):

- value keys → `voltro:cache:v:<key>` (TTL via `PSETEX`; JSON-encoded)
- tag sets → `voltro:cache:t:<tag>` (a Redis SET of the raw keys carrying that tag)

`clear()` is scoped to the prefix via `SCAN` — it never runs `FLUSHDB`, so a Redis shared with other framework state (e.g. the read-replica RYW store) is safe.

> **Reference semantics differ by backend.** The memory backend returns the *same object reference* you stored (fast, but mutating a cached object mutates the cache — don't). The Redis backends JSON round-trip, so they always return a fresh copy. Treat cached values as immutable on both.

## Choosing

- **One instance / dev** → `memory`. Nothing to run.
- **Multiple instances, or restart-survival** → `redis` (pick any of the five engines by URL).
- **Serverless / edge** (no persistent TCP) → `redis` with `CACHE_REDIS_DRIVER=http` (Upstash).

To provision the infra (a Redis container in compose / a Deployment in helm) plus the env, see **[Enabling Redis](/docs/caching/enabling)** — `voltro add redis` wires it all.



---

<!-- source: en/caching/wrap.md -->
## 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* 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.

```ts
// 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
  )
```

```ts
// 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:

```ts
// 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](/docs/caching/query-cache)). 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

```ts
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 `Effect`s. 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:

```ts
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:

```ts
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`](/docs/caching/kv-backends), or a separately-provided cache) via [named connections](/docs/caching/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](/docs/caching/query-cache) handles this for you via `scope`; with raw `wrap` it's your responsibility.



---

<!-- source: en/caching/query-cache.md -->
## Query caching

_Opt a query into server-side snapshot caching with the cache field — automatic table-tag invalidation and the required scope security rule (subject vs global)._

Add a `cache` field to a `defineQuery` and the framework caches the query's **server snapshot** and **auto-invalidates it when a mutation writes any table the query depends on**. No manual busting, no glue.

```ts
// queries/todos.listByTenant.query.ts
export const listTodos = defineQuery({
  name:   'todos.listByTenant',
  source: 'todos',
  guards: [{ scope: 'todos:read' }],                     // who may call it
  input:  Schema.Struct({ done: Schema.optional(Schema.Boolean) }),
  output: Todo,
  cache:  { ttl: '30s', swr: '5m', scope: 'subject' },   // tenant-filtered → subject
})
```

The matching `.<primitive>.server.ts` is **unchanged** — caching is a descriptor concern. `ttl` / `swr` accept seconds (`30`) or a duration string (`'30s'`, `'5m'`, `'1h'`).

## `scope` is required — and it's a security decision

`scope` has **no default**, because guessing wrong leaks one user's rows to another. The rubric is one question, with **three** answers:

> **Does the resolved query depend on the caller — and on what about them?**

- **On the PERSON** — `where authorId = me`, anything row-scoped → **`scope: 'subject'`**. The cache key includes the caller's subject id, so two subjects can never share an entry.
- **On their ORG only** — an org-wide figure every colleague sees identically → **`scope: 'tenant'`**. One entry per `tenantId`, never shared across orgs.
- **On neither** — the same rows for everyone (reference / lookup data) → **`scope: 'global'`**. One entry shared across all callers.

```ts
// reference data — identical for everyone → global
export const listCountries = defineQuery({
  name:   'reference.countries',
  source: 'countries',
  openAccess: 'a static country reference list — the same rows for every caller, '
    + 'no tenant rows and nothing caller-derived',
  input:  Schema.Void,
  output: Country,
  cache:  { ttl: '1h', scope: 'global' },
})

// an org-wide statistic — same for all 18 colleagues, never across orgs
export const last12Months = defineQuery({
  name:   'globalStatistics.last12Months',
  source: ['invoices', 'employees'],
  guards: [{ scope: 'analytics:read' }],
  input:  Schema.Struct({}),
  output: Stats,
  cache:  { ttl: '5m', scope: 'tenant' },
})
```

**`cache.scope` and the access decision are two questions, and they line up here by coincidence rather than by rule.** Every wire-exposed query must also declare `guards:`, `openAccess: '<reason>'` or `internal: true` or the boot refuses it — and the reasoning that made `scope: 'global'` correct for `reference.countries` (the same rows for everyone, nothing caller-derived) is the same reasoning that makes `openAccess` honest there. It does not generalise: a query can be perfectly cacheable per subject *and* need a scope to call, which is `todos.listByTenant` above. Decide them separately; see [Authorization](/docs/authentication/authorization#every-procedure-decides-guards-or-openaccess).

`'tenant'` exists because the other two were the only options and neither fit an org-wide figure: `'subject'` recomputes it per person — eighteen identical computations of the same nine-table statistic for an eighteen-person org — and `'global'` shares one entry across tenant boundaries, which for data derived from `subject.tenantId` is not a cache but a leak.

A caller with no `tenantId` (an anonymous or system subject) **bypasses** a `'tenant'` cache rather than sharing a null-keyed entry.

**Never put `scope: 'global'` on a subject- or tenant-filtered query.** Tenant tables are auto-scoped by the runtime, so a `global` cache over one would serve tenant A's rows to tenant B. **The boot audit checks this**: a `'global'` scope over a `tenant()`-scoped table is reported by `voltro dev` and refused under `VOLTRO_SERVER_ONLY=strict`. It stays silent for `'global'` on reference data — the case the option exists for — and for a query with no declared `source`, where it has nothing to reason about.

**`scope: 'tenant'` is not a replacement for modelling.** For a rollup, an aggregate with `tenantId` as an indexed column puts the tenant boundary in the *data* rather than in a cache key, which is better. `'tenant'` is for the other case: a query that must be FRESH and is merely expensive, where an aggregate's refresh interval is the wrong instrument.

## How auto-invalidation works

When you opt in, the framework tags the cached snapshot with the **full set of tables the query reads** — the root `source` plus every table reached through eager `.with(...)` relations and joins. A mutation that writes any of those tables drops the entry through the same invalidation bus the low-level `wrap` uses. The writer's own next read recomputes (read-your-writes holds).

- **`CDC=1`** (postgres LISTEN/NOTIFY) → invalidation propagates across instances.
- **`CDC=0`** → single-process invalidation only. Fine for dev; for multi-instance `global` caches you want CDC on.

## When to use it vs. a live subscription

Live `useSubscription` queries already stay fresh by pushing deltas — they don't need this. Query caching earns its keep for the **initial snapshot shared across many subscribers/instances** (cutting redundant DB hits when N tabs/pods open the same query) and for adding an SWR window. If a query is opened once and rarely, the live engine alone is enough; reach for `cache:` on hot, widely-shared read paths.

Inspect hit-rate live in the dashboard's **Cache** panel, or via `voltro cache status` — see [Enabling Redis](/docs/caching/enabling).



---

<!-- source: en/caching/enabling.md -->
## Enabling Redis & tooling

_Switch from the memory default to Redis — at project creation (--cache=redis) or later (voltro add redis) — plus the voltro cache CLI and the dashboard panel._

The `memory` backend is the always-on default — there is nothing to install to start caching. Switching to Redis is two concerns: flip the backend (`app.config` / env) **and** provision the infra to run it (a container in compose, a Deployment in helm, the env vars). The CLI does both for you.

## At project creation

```sh
voltro create-project myApp --baseline=compose --cache=redis
```

`--cache=redis` sets `cache: 'redis'` in the api `app.config.ts` and, because a baseline was chosen, injects a `redis` service into the compose `docker-compose.yml` (or a Deployment + Service + values into the helm chart) plus `CACHE_BACKEND` / `CACHE_REDIS_URL` into `.env.example`.

## Later, on an existing project

```sh
voltro add redis
```

The same idempotent wiring, applied to the project at the cwd:

1. flips every api `app.config.ts` to `cache: 'redis'`
2. injects a `redis` service into `docker-compose.yml` (if the compose baseline is present)
3. injects a redis `values` block + a Deployment/Service into the helm chart (if the helm baseline is present)
4. activates `CACHE_BACKEND` / `CACHE_REDIS_URL` in `.env.example` — uncommenting the baseline's optional-backends block in place, or appending it if absent (and `.env` if it exists)

Every baseline ships that block **commented-out** in `.env.example` from the start — the cache *and* the durable-KV Redis vars — so a fresh project already shows what it can turn on. `voltro add redis` just uncomments the cache half; the durable-KV vars stay commented on purpose (durable KV wants a persistent Redis, not the ephemeral cache one this command provisions — see [Key-value backends](/docs/caching/kv-backends)).

It only touches infra files that exist — a `bare`-baseline project just gets the `app.config` + `.env` changes. Re-running is safe (each step checks for an existing marker and skips). Caching is a backend + infrastructure concern, **not** a runtime plugin, so there is deliberately no `plugins:` entry and no `voltro plugin add` for it.

Then start Redis and reboot:

```sh
docker compose up -d redis
voltro dev .
# boot log: cache backend resolved: redis (driver resp)
```

For the engine matrix (Redis / Valkey / KeyDB / Dragonfly / Upstash) and all env vars, see **[Backends & engines](/docs/caching/backends)**.

## The `voltro cache` CLI

```sh
voltro cache status                 # resolved backend + hit/miss counters
voltro cache flush                  # clear every entry (redis backend)
voltro cache invalidate <tag>       # drop everything carrying <tag>
```

`flush` / `invalidate` operate on the shared store, so against `redis` they affect every instance. Against the `memory` backend they're refused (the CLI can't reach another process's in-memory map) — `status` still reports the config.

## Dashboard

The local DevTools dashboard and the Voltro Cloud dashboard both show a per-app **Cache** panel — resolved backend/engine, driver, and live hit-rate (hits / misses) — backed by `GET /_voltro/inspect/data-cache`. Same surface in both, so what you see locally matches production.



---

<!-- source: en/caching/key-value.md -->
## 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

```ts
// 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:

```ts
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.

```ts
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`:

```ts
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`](/docs/caching/wrap) and [query caching](/docs/caching/query-cache).
- **`ctx.store`** — when the data is relational, queried, joined, or reported on — model it as a table.

## Where to go next

- **[Key-value backends](/docs/caching/kv-backends)** — `database` (durable) vs `redis` vs `memory`, the `KV_BACKEND` selector, the `KvStore` port, custom backends.
- **[Named connections](/docs/caching/connections)** — how the cache, KV, rate limiter, and broadcast share one connection registry, and how to point each concern at its own server.



---

<!-- source: en/caching/kv-backends.md -->
## 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):

```ts
// 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](/docs/caching/connections) — 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`.

```ts
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:

```ts
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.



---

<!-- source: en/caching/connections.md -->
## Named connections

_One connection registry shared by the cache, KV, rate limiter and broadcast — the <NAME>_REDIS_URL → REDIS_URL scheme, configuring each concern's server independently, and why enablement is explicit per concern._

Four subsystems can talk to a RESP server: the **query cache**, the **durable KV** (on its redis backend), the **rate limiter**, and the **broadcast bus**. Rather than each parsing its own env and opening its own socket, they share **one connection registry** — so you point each concern at the same server or a different one purely by configuration.

The shareable thing is the **connection**, not the contract: the cache uses tag sets, the limiter atomic Lua, broadcast pub/sub. The registry deals in connections; each consumer keeps its own semantics on top. (That's also why they stay separate concerns — forcing a rate limiter onto a plain get/set cache would break it.)

## The `<NAME>_REDIS_URL` → `REDIS_URL` scheme

Every named connection resolves its **own** var first, then the **shared default**:

```
REDIS_URL              the shared default for every named connection
CACHE_REDIS_URL        the cache's own server (overrides REDIS_URL for the cache)
KV_REDIS_URL           the KV's own server
RATELIMIT_REDIS_URL    the rate limiter's own server
BROADCAST_REDIS_URL    the broadcast bus's own server
```

So one server for everything is just `REDIS_URL`; splitting a concern onto its own server is one extra var. Each also has a matching `<NAME>_REDIS_DRIVER` (`resp` default, or `http` for Upstash REST) and `<NAME>_REDIS_TOKEN`, falling back to `REDIS_DRIVER` / `REDIS_TOKEN` / `UPSTASH_REDIS_REST_TOKEN`.

> **Why `_REDIS_` when Voltro supports Valkey / KeyDB / Dragonfly / Upstash too?** Because the URL *value* is `redis://…` for all of them — there is no `valkey://` or `resp://` scheme; they all speak the Redis wire protocol. `_REDIS_URL` names the protocol, not the vendor, and `REDIS_URL` is the de-facto env var every PaaS injects. The `<NAME>_REDIS_URL` var is the connection for a concern's **redis backend specifically** — the KV on `database` uses your SQL connection, and broadcast on NATS uses `BROADCAST_URL` / `NATS_URL`.

## Enablement is explicit per concern — a shared `REDIS_URL` is NOT a master switch

Setting `REDIS_URL` does **not** silently turn every concern on. Each concern has its own on/off selector; the URL is only the connection detail, read once you've opted in:

| Concern | Enablement selector | Reads a redis url only when |
|---|---|---|
| Cache | `CACHE_BACKEND=memory\|redis` (default `memory`) | `= redis` |
| KV | `KV_BACKEND=database\|redis\|memory` (default `database`) | `= redis` |
| Rate limiter | `store: 'memory' \| 'postgres' \| 'redis'` (plugin option) | `redis` |
| Broadcast | opt-in via `BROADCAST_URL` / `BROADCAST_REDIS_URL` / `BROADCAST_PROVIDER` / the `connection` option | explicitly opted in |

So `REDIS_URL` set for the cache leaves the KV on `database` and broadcast on its in-process memory bus. You enable each deliberately. (Broadcast is the one that used to infer "on" from any url; it no longer does — see [broadcast](/docs/plugins/broadcast).)

## The registry API

Inside an Effect program, `RedisConnections` hands out pooled clients by name:

```ts
import { RedisConnections, layerConnections } from '@voltro/kv/connection'
import { Effect } from 'effect'

const program = Effect.gen(function* () {
  const connections = yield* RedisConnections
  const client = yield* connections.client('cache') // pooled RESP command client
  // …get / set / eval / zrange / scan / del …
})

program.pipe(Effect.provide(layerConnections))
```

- `client(name)` opens (once, pooled — one socket per name for the layer's lifetime) and returns the RESP command client. Concurrent first-callers collapse to a single connect; the socket is closed on shutdown.
- `config(name)` resolves just the `RedisConnectionConfig` (`url` / `driver` / `token`) **without** opening it — for consumers that build their own client, e.g. a dedicated pub/sub connection over raw ioredis (which the REST driver can't do anyway).

A one-off resolve without the service is `connectionConfig(name)`:

```ts
import { connectionConfig } from '@voltro/kv/connection'

const cfg = connectionConfig('broadcast') // Effect<RedisConnectionConfig, ConfigError>
```

## Adding a new named connection

There is **no registry code to change** for a new connection — the scheme is convention-based. A new consumer just picks a name and calls `connections.client('<name>')`; it automatically reads `<NAME>_REDIS_URL` → `REDIS_URL`. You only write code when the concern needs its own *contract* (atomic Lua, pub/sub) rather than plain get/set — then resolve `config(name)` and build the client shape you need.

## Package

The registry ships in **`@voltro/kv`**, importable narrowly as `@voltro/kv/connection`. Exports: `RedisConnections` (the service), `layerConnections` (the pooled registry layer), `connectionConfig` (env resolution), `connect` (build a client from a config), `commandRetry` (the shared bounded retry), and the `RespClient` / `RedisConnectionConfig` types. It consolidates the RESP client that used to be duplicated across `@voltro/cache` and `@voltro/plugin-ratelimit`.
