@mofax/cfkv
v0.0.0
Published
Typed Cloudflare KV client with a table-style API built on tuple keys.
Readme
cfkv
Typed Cloudflare KV client with a table-style API built on tuple keys.
cfkv wraps the Cloudflare KV REST API and gives you:
- typed reads and writes based on a schema
- automatic namespace lookup and creation
- tuple keys like
["users", "user_1"]instead of manual string concatenation - batch operations for writes and reads
- full-table scans, cursor pagination, and async iteration
What This Library Does
Cloudflare KV is a flat key-value store. This library adds a lightweight structure on top:
- a logical namespace is resolved from
namespaceNameanddbName - the first tuple key segment is treated as a table name
- the remaining tuple key segments form the row key
Example:
namespace("app", "prod")resolves to a Cloudflare namespace titledprod:app["users", "user_1"]is stored as the KV keyprod:users:user_1
This gives you a simple table-like model without adding a database server or query layer.
Installation
This repo currently builds the library from source. To work with it locally:
bun installThe package exports ESM entrypoints and TypeScript declarations. Consumer code imports it as:
import { CfKV } from "cfkv";Requirements
You need:
- a Cloudflare account ID
- a Cloudflare API token with Workers KV permissions
Pass them to the client:
const cfkv = new CfKV({
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
apiToken: process.env.CLOUDFLARE_API_TOKEN!,
});You can also provide:
baseUrlto override the Cloudflare API base URLfetchto inject a custom fetch implementationheadersto merge custom headers into every request
Core Concepts
Schema
The schema maps table names to JSON value types:
type AppSchema = {
users: { name: string; age: number };
sessions: { userId: string; createdAt: string };
};Namespace Handle
Create a typed namespace handle by passing your schema to namespace():
const kv = await cfkv.namespace<AppSchema>("app", "prod");On first use, the client will:
- look for a Cloudflare KV namespace titled
prod:app - create it if it does not exist
- cache the resolved namespace for repeated calls
Tuple Keys
All row operations use tuple keys.
Examples:
["users", "user_1"]["sessions", "session_123"]
Rules:
- the first segment must be a table name from your schema
- key parts may contain only letters, numbers, and underscores
- characters like
-,/,:, and spaces are rejected before any API request is sent
Quick Start
import { CfKV } from "cfkv";
type AppSchema = {
users: { name: string; age: number };
};
const cfkv = new CfKV({
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
apiToken: process.env.CLOUDFLARE_API_TOKEN!,
});
const kv = await cfkv.namespace<AppSchema>("app", "prod");
await kv.put(["users", "user_1"], { name: "Ada", age: 31 });
const user = await kv.get(["users", "user_1"]);
console.log(user);
await kv.delete(["users", "user_1"]);Usage
Write One Value
await kv.put(["users", "user_1"], { name: "Ada", age: 31 });Write Many Values
await kv.putBatch([
{ key: ["users", "user_1"], value: { name: "Ada", age: 31 } },
{ key: ["users", "user_2"], value: { name: "Bob", age: 28 } },
]);Read One Value
get() returns undefined when the key does not exist.
const user = await kv.get(["users", "user_1"]);Read Many Values
getBatch() preserves the order of the requested keys.
const [user1, user2, missing] = await kv.getBatch([
["users", "user_1"],
["users", "user_2"],
["users", "missing_user"],
] as const);Delete One Value
await kv.delete(["users", "user_1"]);Listing and Iteration
Listing is table-based, so these methods use only the first tuple segment:
await kv.fetch(["users"]);That call lists all keys with the prefix prod:users: and returns the decoded JSON values.
Fetch All Rows
fetch(["users"]) is the convenience form. It follows Cloudflare cursors internally and returns every matching row.
const users = await kv.fetch(["users"]);Use this when you want all rows in memory.
Fetch One Page
Use paginate: true to get one page plus the next cursor:
const page1 = await kv.fetch(["users"], {
paginate: true,
limit: 50,
});
console.log(page1.items);
console.log(page1.cursor);Fetch the next page by passing the returned cursor back in:
const page2 = page1.cursor
? await kv.fetch(["users"], {
paginate: true,
limit: 50,
cursor: page1.cursor,
})
: { items: [], cursor: undefined };If cursor is undefined, there are no more pages.
Iterate Rows
Use iterate(["users"]) to stream rows lazily:
for await (const user of kv.iterate(["users"])) {
console.log(user);
}Iterate Pages
Use iterate(..., { paginate: true }) to stream page objects instead of rows:
for await (const page of kv.iterate(["users"], { paginate: true, limit: 50 })) {
console.log(page.items, page.cursor);
}Pagination Behavior
Cloudflare's key-list endpoint requires paginated limit values of at least 10.
This library enforces that before making a network call:
await kv.fetch(["users"], { paginate: true, limit: 10 });These are valid:
fetch(["users"])fetch(["users"], { paginate: true, limit: 10 })iterate(["users"])iterate(["users"], { paginate: true, limit: 50 })
This throws:
await kv.fetch(["users"], { paginate: true, limit: 1 });Error Handling
When Cloudflare returns a failed response or failed envelope, the client throws CfKVError.
import { CfKVError } from "cfkv";
try {
await kv.put(["users", "user_1"], { name: "Ada", age: 31 });
} catch (error) {
if (error instanceof CfKVError) {
console.error(error.status);
console.error(error.message);
console.error(error.errors);
}
}Notes and Constraints
- values are stored as JSON
- row keys are serialized as
dbName:table:keyPart:keyPart... fetch()anditerate()operate on one table prefix at a time- namespace resolution is cached per
dbName:namespaceName - the client uses the global
fetchby default
Development
Verify Types
bun run verifyRun Tests
bun testBuild
bun run buildThis emits publishable ESM output and type declarations into dist/.
End-to-End Test
The repo includes a live Cloudflare API test.
Environment variables:
CLOUDFLARE_ACCOUNT_IDCLOUDFLARE_API_TOKEN- optional
CFKV_E2E_NAMESPACE - optional
CFKV_E2E_DB
Run it with:
bun test:e2e