MainSDK
Search⌘K
Interfaces

Data

Key-value store

kv

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

Building for 0.3ships as a beta APIPhase 1 · Core, public betaSpec kvFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
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.

At a glance

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

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?)

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

5 drivers. Select one to see its line in main.yaml.

main.yamlLocal (memory or one file) (default)
kv:
  driver: local

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-KV-0001RuntimeError responses and logsincr 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-0002RuntimeError responses and logsttl is not a positive duration (400)
MAIN-KV-0003RuntimeError responses and logsA key is empty or longer than 512 characters (400)

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
GetPhase 10.3kv
Counters with a fixed windowPhase 10.3kv
Cache wrap and tag invalidation (the cache interface)Phase 10.4http-cache#wrap

Quick start

main.yaml
kv:
  driver: local

app/api/hit/route.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 });
}

Configuration

main.yaml
kv:
  driver: redis                  # local (default) | redis
  redis: { url: secret:REDIS_URL, prefix: myapp }
KeyTypeWhat it does
driverstringlocal or redis; any other name fails at startup with MAIN-CONFIG-0003
filepathlocal only: keep values in this file so they survive a restart; without it they live in memory. No default
redis.urlsecret referenceredis:// or rediss:// URL. Defaults to secret:REDIS_URL
redis.prefixstringAdded 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.

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.

Examples

Keep a draft for a day

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

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, which builds on the same stores.

Delete a key

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.

NameWhat 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

CodeWhenWhat to do
MAIN-KV-0001incr 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-0002ttl is zero, negative or not a duration such as 500ms, 30s, 5m, 1h, 7dPass milliseconds or a duration with one unit
MAIN-KV-0003A key is empty or longer than 512 charactersUse a shorter, non-empty key

Outside a tenant a call fails with MAIN-TENANT-0001; see Tenants. kv adds no check rules.

Swapping drivers

main.yaml
kv:
  driver: redis   # was local

Your calls stay the same. Values are not carried over between drivers. See Swap a driver.

Testing

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

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.

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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet