Key-value store
A cache-shaped API on ctx.kv and app.kv, backed by an adapter you choose. In-memory with no configuration at all, Redis or Cloudflare KV with one config line, and the same five calls either way.
KV holds derived data. A cached API response, a rate-limit bucket, a one-time code that should vanish in five minutes. The durable copy of anything lives in a collection, so a KV entry can be dropped and recomputed without losing a record.
The default
There is no setup step. Leave kv out of your config and KVService builds a
MemoryKVAdapter for itself, a plain Map in the current process with TTL
bookkeeping. The app context is spread into every handler, so you destructure
kv and use it.
import { route } from "questpie/services";
export default route()
.get()
.handler(async ({ kv, collections }) => {
const cached = await kv.get<{ total: number }>("stats:posts");
if (cached) return cached;
const total = await collections.posts.count();
await kv.set("stats:posts", { total }, 60); // ttl in SECONDS
return { total };
});The default store is one process wide
MemoryKVAdapter is a Map. It is wiped on restart and not shared with your
other instances or your worker, so two processes reading one key get two
answers. Fine for dev. Configure Redis before you run more than one process.
The five calls
ctx.kv and app.kv are the same KVService, and it has exactly five methods.
This is the surface every adapter is normalized to, and swapping does not move it.
const user = await ctx.kv.get<User>("user:42"); // null on miss or expiry
await ctx.kv.set("user:42", user); // no ttl, so defaultTtl or none
await ctx.kv.set("otp:42", "839201", 300); // 300 seconds
await ctx.kv.has("otp:42"); // false once expired
await ctx.kv.delete("otp:42");
await ctx.kv.clear(); // everything this adapter ownsTTL is always in seconds. When set omits one it falls back to defaultTtl
from config, and with that unset too the value never expires. Under
observability, get, set, delete and has open a
kv.<op> span carrying the operation and the key length, never the key itself.
Tags are the sixth thing, and they live on the adapter rather than the service. See Tag invalidation.
The adapters
Three ship with QUESTPIE, each in its own entry point so you pull in only the client you actually use.
| Adapter | Import | Needs | Pick it when |
|---|---|---|---|
MemoryKVAdapter | questpie/adapters/memory-kv | nothing | Dev, tests, one process that can lose its cache. |
redisKVAdapter | questpie/adapters/redis-kv | A connected node-redis-shaped client | More than one instance, or a cache that outlives a deploy. |
cloudflareKVAdapter | questpie/adapters/cloudflare-kv | A Cloudflare KV namespace binding | You deploy to Cloudflare Workers. |
What actually changes when you swap
The five calls are identical. These are not.
| Behavior | Memory | Redis | Cloudflare KV |
|---|---|---|---|
| Shared | no, one process | yes | yes, globally |
| Survives restart | no | yes | yes |
| Read after write | immediate | immediate | eventually consistent |
| Expiry | lazy, on next touch | server-side | server-side |
| Values | stored by reference | JSON.stringify | JSON.stringify |
clear() covers | the whole Map | keyPrefix, else all | keyPrefix, else all |
Both serializing adapters throw a TypeError on a value JSON cannot take, a
function or a BigInt or a cycle, and read back best-effort, so a stored string
that is not valid JSON returns as that raw string. Keep values to plain data.
Configuring
KV lives under the kv key of questpie.config.ts. The shape is KVConfig.
| Option | Type | Default | Notes |
|---|---|---|---|
adapter | KVAdapter | new MemoryKVAdapter() | The backend. Omit it for in-memory. |
defaultTtl | number | none | Seconds. Used by set only when the call omits its own ttl. |
Swapping to Redis
One config entry, and nothing in the route above moves.
import { runtimeConfig } from "questpie/app";
import { redisKVAdapter } from "questpie/adapters/redis-kv";
import { createClient } from "redis";
async function getRedis() {
const client = createClient({ url: process.env.REDIS_URL });
await client.connect();
return client;
}
export default runtimeConfig({
kv: {
adapter: redisKVAdapter({ client: getRedis, keyPrefix: "myapp:" }),
defaultTtl: 3600,
},
});client takes a connected client or a provider returning one, sync or async,
resolved once on first use. That provider form is what create-questpie writes,
because it keeps the connection out of module load.
| Option | Type | Default | Notes |
|---|---|---|---|
keyPrefix | string | "" | Prepended to every key. Share one database between apps. |
tagIndexPrefix | string | "__questpie_tag:" | Prefix for the tag-index sets, applied after keyPrefix. |
scanCount | number | 100 | Keys per SCAN page during clear() and tag cleanup. |
allowFlushDb | boolean | false | See below. |
TTL maps to the EX option on SET. delete() and clear() walk the keyspace
with SCAN rather than KEYS, so neither blocks the server.
Without a `keyPrefix`, `clear()` still empties the database
FLUSHDB needs all three of no keyPrefix, allowFlushDb: true, and a
client exposing the optional flushDb. Short of that, clear() falls back to
SCAN plus DEL, and with no keyPrefix the pattern is *, so it deletes
every key anyway. Only a keyPrefix actually scopes it.
Swapping to Cloudflare KV
Pass the namespace binding as namespace, or a provider returning it for
runtimes that resolve bindings per request.
import { cloudflareKVAdapter } from "questpie/adapters/cloudflare-kv";
// env is the Worker bindings object, MY_KV comes from your wrangler config
const adapter = cloudflareKVAdapter({ namespace: () => env.MY_KV });keyPrefix and tagIndexPrefix mean what they do on Redis, and listLimit
defaults to 1000, the page size for the list() calls behind clear(). TTL
maps to Cloudflare's expirationTtl. The adapter is re-exported from
questpie/adapters/cloudflare if you prefer one import for the whole runtime.
On Cloudflare it is also not optional. It carries runtime: "cloudflare", which
the fetch, queue and scheduled handler factories all assert on, so an unset
kv.adapter throws CloudflareCompatibilityError before the first request. The
in-memory default is rejected, and a custom adapter needs that same field.
Cloudflare KV is eventually consistent
A read can lag a write, so a value you just set may not be visible to the
next get at another edge location. That is the platform, not the adapter.
Use it for caches and config that tolerate staleness, not one-time tokens you
verify milliseconds later.
Writing your own
The store is an interface, so Memcached, DynamoDB or a SQLite table are one
class away. Import KVAdapter from questpie/kv, implement it, and wire the
instance under kv.adapter exactly like a built-in.
interface KVAdapter {
get<T = unknown>(key: string): Promise<T | null>;
set(key: string, value: unknown, ttl?: number): Promise<void>;
delete(key: string): Promise<void>;
has(key: string): Promise<boolean>;
clear(): Promise<void>;
}Those five are required, and ttl is always in seconds. Three optional tag
methods complete it, covered on Tag invalidation.
questpie/kv exports KVAdapter and KVConfig, while option and client types
such as RedisKVClient and CloudflareKVNamespace come from each adapter entry.
Related
- Tag invalidation, grouping keys and dropping a group in one call.
- Configuration, where
runtimeConfigputskv. - Services, how
kvarrives on every context.