Key-value store
kvKeep small values and counters under a key with an optional expiry, scoped to the tenant, on a local file or Redis.
kvFree in your app, Apache-2.0Status 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.
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 }import { kv } from "mainsdk";
const hits = await kv.incr(`rate:${ip}`, { ttl: "1m" }); // a fixed one-minute window
await kv.set(`draft:${user.id}`, { step: 2 }, { ttl: "1d" });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);
});
});Drivers
5 drivers. Select one to see its line in main.yaml.
kv:
driver: localChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-KV-0001 | RuntimeError responses and logs | 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 | RuntimeError responses and logs | ttl is not a positive duration (400) |
MAIN-KV-0003 | RuntimeError responses and logs | A 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.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Get | Phase 1 | 0.3 | kv |
| Counters with a fixed window | Phase 1 | 0.3 | kv |
| Cache wrap and tag invalidation (the cache interface) | Phase 1 | 0.4 | http-cache#wrap |
Quick start
kv:
driver: localapp/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 });
}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
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.
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
"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
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
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. kv adds no check rules.
Swapping drivers
kv:
driver: redis # was localYour 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.
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
deleteandincrstay 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 indb.
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?