experimental-vercel-kv2
v0.0.1
Published
KV2 - A type-safe KV store backed by Vercel private blobs with regional caching
Readme
@vercel/kv2
A type-safe key-value store backed by Vercel Blob with edge caching and copy-on-write branch isolation.
Installation
npm install @vercel/kv2
# or
pnpm add @vercel/kv2Features
- Edge caching - Vercel Runtime Cache for low-latency reads
- Copy-on-write - Preview branches inherit from production, writes stay local
- Typed stores - Type-safe sub-stores with automatic key prefixing
- Concurrent iteration -
entries()andgetMany()with bounded concurrency - Pagination - Cursor-based pagination for HTTP APIs
- Schema & Tree - Hierarchical data with batched tree loading
- Streaming - Large values streamed without buffering
Quick Start
import { createKV } from "@vercel/kv2";
// Creates KV with automatic upstream fallback to production/main
const kv = createKV({ prefix: "myapp/" });
interface User {
name: string;
email: string;
}
// Type-safe sub-store
const users = kv.getStore<User>("users/");
await users.set("alice", { name: "Alice", email: "[email protected]" });
const result = await users.get("alice");
if (result.exists) {
console.log((await result.value).name); // "Alice"
}Iterating Entries
Fetch multiple values concurrently:
// Iterate with concurrent fetching (default: 20 concurrent)
for await (const [key, entry] of users.entries()) {
console.log(key, await entry.value);
}
// With prefix filter
for await (const [key, entry] of users.entries("active/")) {
console.log(key, entry.metadata);
}
// Fetch specific keys concurrently
const results = await users.getMany(["alice", "bob", "charlie"]);
for (const [key, entry] of results) {
console.log(key, await entry.value);
}Pagination
For HTTP APIs, use cursor-based pagination:
// GET /api/users?cursor=xxx
export async function GET(req: Request) {
const url = new URL(req.url);
const cursor = url.searchParams.get("cursor") ?? undefined;
// Get a page of keys with cursor
const { keys, cursor: nextCursor } = await users.keys().page(20, cursor);
// Fetch values for those keys
const entries = await users.getMany(keys);
return Response.json({
users: keys.map((k) => entries.get(k)?.value),
nextCursor,
});
}Both keys() and entries() support pagination:
// Paginate keys
const { keys, cursor } = await kv.keys("users/").page(10, cursor);
// Paginate entries (fetches values concurrently)
const { entries, cursor } = await kv.entries("users/").page(10, cursor);
for (const [key, entry] of entries) {
console.log(key, await entry.value);
}Typed Stores
Create nested, type-safe stores:
interface Post {
title: string;
content: string;
}
const posts = kv.getStore<Post>("posts/");
await posts.set("hello-world", { title: "Hello", content: "World" });
// Keys are relative to the store
for await (const key of posts.keys()) {
console.log(key); // "hello-world" (not "posts/hello-world")
}
// Nested stores accumulate prefixes
const drafts = posts.getStore<Post>("drafts/");
await drafts.set("my-draft", { title: "Draft", content: "..." });
// Actual key: "posts/drafts/my-draft"Copy-on-Write Branches
Preview deployments automatically inherit from production:
// On preview branch "feature-x":
const kv = createKV({ prefix: "app/" });
// Read falls back to production/main if not found locally
const user = await kv.get("users/alice"); // Found in production
// Write only affects this branch
await kv.set("users/alice", { name: "Alice Updated" });
// Delete creates tombstone (won't fall back to production)
await kv.delete("users/bob");Configure upstream explicitly:
const kv = createKV({
prefix: "app/",
upstream: { env: "production", branch: "main" }, // default
// upstream: null, // disable fallback
});Optimistic Locking
Safely update values with conflict detection using versions (etags):
const entry = await users.get("alice");
if (entry.exists) {
const user = await entry.value;
user.name = "Alice Updated";
// Update using the version from when we read the entry
// Throws KVVersionConflictError if another process modified it
await entry.update(user);
}Manual version control with expectedVersion:
const { version } = await kv.set("counter", { count: 0 }, metadata);
// Later, conditionally update
await kv.set("counter", { count: 1 }, metadata, { expectedVersion: version });Create-only (fails if key exists):
await kv.set("user/new-id", userData, metadata, { override: false });Retry pattern for concurrent updates:
import { KVVersionConflictError } from "@vercel/kv2";
async function incrementCounter(key: string): Promise<number> {
for (let attempt = 0; attempt < 3; attempt++) {
const entry = await kv.get<{ count: number }>(key);
if (!entry.exists) throw new Error("Key not found");
const current = await entry.value;
try {
await entry.update({ count: current.count + 1 });
return current.count + 1;
} catch (error) {
if (error instanceof KVVersionConflictError) continue;
throw error;
}
}
throw new Error("Max retries exceeded");
}With Metadata
Track metadata per entry:
interface Metadata {
updatedAt: number;
version: number;
}
const kv = createKV<Metadata>({ prefix: "app/" });
const users = kv.getStore<User>("users/");
// Metadata required on set
await users.set("alice", { name: "Alice" }, { updatedAt: Date.now(), version: 1 });
const result = await users.get("alice");
if (result.exists) {
console.log(result.metadata.version); // 1
}Schema and Tree
For hierarchical data, define a schema and fetch trees with batched concurrent loading:
import { defineSchema, createSchemaKV, createKV } from "@vercel/kv2";
interface Board { name: string }
interface Column { name: string; order: number }
interface Task { title: string; done: boolean }
const schema = defineSchema("kanban/", {
boards: {
pattern: "*",
value: {} as Board,
children: {
columns: {
pattern: "columns/*",
value: {} as Column,
children: {
tasks: { pattern: "tasks/*", value: {} as Task }
}
}
}
}
});
const kv = createKV({ prefix: "kanban/" });
const kanban = createSchemaKV(schema, kv);
// Type-safe key builders
kanban.key.boards("board-1"); // "board-1"
kanban.key.columns("board-1", "col-1"); // "board-1/columns/col-1"
kanban.key.tasks("board-1", "col-1", "task-1"); // "board-1/columns/col-1/tasks/task-1"
// Fetch entire tree with batched concurrent loading
const board = await kanban.tree("boards", "board-1");
console.log(board.value.name);
// Lazy async iteration over children
for await (const column of board.columns) {
console.log(column.value.name);
for await (const task of column.tasks) {
console.log(task.value.title);
}
}The tree() method makes a single keys() call to discover all descendants, then uses getMany() to fetch values with bounded concurrency.
API
createKV<M>(options)
Creates a KV store with automatic environment detection and upstream fallback.
| Option | Default | Description |
|--------|---------|-------------|
| prefix | "" | Key prefix (must end with /) |
| upstream | { env: "production", branch: "main" } | Fallback config or null |
| env | VERCEL_ENV | Environment name |
| branch | VERCEL_GIT_COMMIT_REF | Branch name |
KVLike<M> Interface
All stores implement this interface:
interface KVLike<M> {
get<V>(key: string): Promise<KVGetResult<V, M>>;
set<V>(key: string, value: V, metadata?: M, options?: SetOptions): Promise<KVSetResult>;
delete(key: string): Promise<void>;
keys(prefix?: string): KeysIterable;
entries<V>(prefix?: string): EntriesIterable<V, M>;
getMany<V>(keys: string[], concurrency?: number): Promise<Map<string, KVEntry<V, M>>>;
}
interface SetOptions {
expectedVersion?: string; // Only succeed if current version matches
override?: boolean; // Allow overwriting (default: true)
}
interface KVSetResult {
version: string; // etag of the written blob
}
interface KeysIterable extends AsyncIterable<string> {
page(limit: number, cursor?: string): Promise<{ keys: string[]; cursor?: string }>;
}
interface EntriesIterable<V, M> extends AsyncIterable<[string, KVEntry<V, M>]> {
page(limit: number, cursor?: string): Promise<{ entries: [string, KVEntry<V, M>][]; cursor?: string }>;
}Get Result
const result = await store.get("key");
if (result.exists) {
result.metadata; // M - immediate
result.version; // string - etag for optimistic locking
await result.value; // V - lazy
await result.stream; // ReadableStream<Uint8Array>
// Conditionally update using captured version
await result.update(newValue); // throws KVVersionConflictError on conflict
}Environment Variables
BLOB_READ_WRITE_TOKEN=vercel_blob_...Testing
pnpm test # Unit tests (fake blob store)
pnpm test:integration # Integration tests (real Vercel Blob)