---
title: "Key-value store"
description: "Keep small values and counters under a key with an optional expiry, scoped to the tenant, on a local file or Redis."
section: "interfaces"
status: "Building for 0.3 · ships as a beta API"
spec: "kv"
canonical: "https://mainsdk.dev/docs/interfaces/kv"
updated: "2026-10-28"
---

# Key-value store

> Keep small values and counters under a key with an optional expiry, scoped to the tenant, on a local file or Redis.

Status: Building for 0.3 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/kv · Updated: 2026-10-28

`kv` keeps small JSON values and counters under a key, with an optional expiry, scoped to the tenant. It runs on a file or memory in development and on Redis in production.

> **Note:** `cache.wrap` and `cache.invalidate` (reuse a slow call's result, drop it by tag) are planned and not part of `kv` yet. Upstash, Vercel Runtime Cache and Cloudflare KV drivers are planned too; Upstash works today through the `redis` driver and a `rediss://` URL.

## At a glance

- Status: Building for 0.3 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/kv.md
- Category: Data

Calls:

- `kv.get(key)`
- `kv.set(key, value, { ttl })`
- `kv.delete(key)`
- `kv.incr(key, { by, ttl })`
- `new RespClient(opts)`
- `new RespError(message)`
- `new RespSubscriber(opts, backoff)`
- `redisKv(o)`
- `localKv(o?)`

In main.yaml:

```yaml
kv:
  driver: redis
  redis: { url: secret:REDIS_URL, prefix: myapp }
```

Guarantees, whatever the driver:

- Keys scoped per tenant
- A ttl is a positive duration, or the write fails
- Redis connections pass the network allow list first


## Drivers

Pick one in main.yaml with `kv.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Local (memory or one file) | `local` | yes |  |
| Redis | `redis` |  |  |
| Upstash Redis | `upstash` |  |  |
| Vercel Runtime Cache | `vercel-runtime-cache` |  |  |
| Cloudflare KV | `cloudflare-kv` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-KV-0001` | runtime | `incr` was given a step that is not a whole number (400), or the key holds a value that is not a whole number (409) |
| `MAIN-KV-0002` | runtime | `ttl` is not a positive duration (400) |
| `MAIN-KV-0003` | runtime | A key is empty or longer than 512 characters (400) |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Get | 1 | 0.3 | `kv` |
| Counters with a fixed window | 1 | 0.3 | `kv` |
| Cache wrap and tag invalidation (the cache interface) | 1 | 0.4 | `http-cache#wrap` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
kv:
  driver: local
```

TypeScript:

```ts
import { kv } from "mainsdk";

export async function POST() {
  const hits = await kv.incr("hits:home", { ttl: "1m" });   // a fixed one-minute window
  return Response.json({ hits });
}
```

Rust:

```rust
use mainsdk::prelude::*;
let app = App::test();
Context::new().tenant("tnt_a").user("u1").scope(async {
app.kv().set("cart", 3).await?;
assert_eq!(app.kv().get("cart").await?, Some(3.into()));
Ok::<_, MainError>(())
}).await?;
```


## Configuration

```yaml title="main.yaml"
kv:
  driver: redis                  # local (default) | redis
  redis: { url: secret:REDIS_URL, prefix: myapp }
```

| Key | Type | What it does |
| --- | --- | --- |
| `driver` | string | `local` or `redis`; any other name fails at startup with `MAIN-CONFIG-0003` |
| `file` | path | `local` only: keep values in this file so they survive a restart; without it they live in memory. No default |
| `redis.url` | secret reference | `redis://` or `rediss://` URL. Defaults to `secret:REDIS_URL` |
| `redis.prefix` | string | Added before every key, so one Redis can serve several apps. Defaults to `mainsdk:<project>` |

The Redis host is added to the app's network allow list as a driver host, and every connection is checked before it opens. See [Network permissions](https://mainsdk.dev/docs/interfaces/network).

## What you get

Keys are scoped to the tenant, so one customer never reads or counts another's. The same calls run on a file or memory in development and on Redis in production, and `kv` is free. Reach for it for drafts, short-lived codes, feature counters and fixed-window rate counts. Records you must keep belong in [`db`](https://mainsdk.dev/docs/interfaces/db).

## Examples

### Keep a draft for a day

```ts title="app/onboarding/actions.ts"
"use server";
import { auth, kv } from "mainsdk";

export async function saveDraftStep(req: Request, step: number) {
  const session = await auth.require(req);
  await kv.set(`onboarding:${session.user}`, { step }, { ttl: "1d" });
}

export async function draftStep(req: Request) {
  const session = await auth.require(req);
  return kv.get<{ step: number }>(`onboarding:${session.user}`);   // null when missing or expired
}
```

### Count in a fixed window

```ts title="lib/invite-count.ts"
import { kv } from "mainsdk";

export async function invitesThisHour(userId: string) {
  // The one-hour window starts when the key is created and does not move on later calls.
  return kv.incr(`invites:${userId}`, { ttl: "1h" });
}
```

For a refusal with `Retry-After`, use [`limits`](https://mainsdk.dev/docs/interfaces/limits), which builds on the same stores.

### Delete a key

```ts title="app/api/reset/route.ts"
import { kv } from "mainsdk";

export async function POST() {
  const existed = await kv.delete("hits:home");   // true when the key was there
  return Response.json({ existed });
}
```

## Reference

The SDK runs these itself; you call them only when you build a store by hand or write a driver.

| Name | What it is |
| --- | --- |
| `localKv(o?)` | In-process driver. Pass `{ file }` to save after every change, and `{ now }` to control the clock. Without a file the data lives and dies with the process. |
| `redisKv(o)` | Redis driver. Takes `url` (`redis://` or `rediss://`), `prefix` (put before every key, so one Redis can serve several apps), `timers` and `connect`, which opens each connection through `net.connect`. |
| `new RespClient(opts)` | The SDK's own Redis client. It connects on first use and again after any drop. Options: `url`, `timers`, `connect` and `timeoutMs` (2000 by default). |
| `new RespSubscriber(opts, backoff)` | A dedicated subscribe connection. `subscribe(channel, { onMessage, onUp?, onDown? })` returns a function that stops it. It reconnects with the `backoff(failures)` delay and subscribes again each time. |
| `new RespError(message)` | An error reply from the Redis server, such as `-ERR unknown command`. It is `MAIN-SDK-0007` with status 502. |

## Errors and checks

| Code | When | What to do |
| --- | --- | --- |
| `MAIN-KV-0001` | `incr` was given a step that is not a whole number (400), or the key holds something that is not a whole number (409) | Pass an integer `by`; use `incr` only on keys it created |
| `MAIN-KV-0002` | `ttl` is zero, negative or not a duration such as `500ms`, `30s`, `5m`, `1h`, `7d` | Pass milliseconds or a duration with one unit |
| `MAIN-KV-0003` | A key is empty or longer than 512 characters | Use a shorter, non-empty key |

Outside a tenant a call fails with `MAIN-TENANT-0001`; see [Tenants](https://mainsdk.dev/docs/interfaces/tenant). `kv` adds no check rules.

## Swapping drivers

```yaml title="main.yaml"
kv:
  driver: redis   # was local
```

Your calls stay the same. Values are not carried over between drivers. See [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver).

## Testing

`createTestApp` runs `kv` in memory with a fake clock, so `clock.advance()` passes a ttl without waiting.

```ts title="lib/draft.test.ts"
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";

describe("draft", () => {
  it("expires after its ttl", async () => {
    const app = createTestApp({ main: "./main.yaml" });
    await app.kv.set("draft:u1", { step: 2 }, { ttl: "1d" });
    app.clock.advance("23h");
    assert.deepEqual(await app.kv.get("draft:u1"), { step: 2 });
    app.clock.advance("2h");
    assert.equal(await app.kv.get("draft:u1"), null);
  });
});
```

## Security and privacy

- Every key is prefixed with the tenant, so tenants never see each other's values, and `delete` and `incr` stay inside the tenant.
- The Redis host must be declared, and the connection fails before it opens when the network layer refuses it.
- Values are stored as plain JSON. Don't keep personal data in `kv`; labelled fields belong in [`db`](https://mainsdk.dev/docs/interfaces/db).

## FAQ

**Can I set a value that never expires?** Yes: leave `ttl` out and the key stays until you delete it. A `ttl` that is zero or malformed fails instead of being ignored.

**Does `incr` move the expiry on each call?** No. The expiry is set when the call creates the key, so the window is fixed; the next call after it expires starts a new one.

## Next

- [Data and records](https://mainsdk.dev/docs/interfaces/db)
- [Rate limits and quotas](https://mainsdk.dev/docs/interfaces/limits)
- [Network permissions](https://mainsdk.dev/docs/interfaces/network)
