QUESTPIE
Infrastructure

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.

View markdown

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.

src/questpie/server/routes/stats.get.ts
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 owns

TTL 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.

AdapterImportNeedsPick it when
MemoryKVAdapterquestpie/adapters/memory-kvnothingDev, tests, one process that can lose its cache.
redisKVAdapterquestpie/adapters/redis-kvA connected node-redis-shaped clientMore than one instance, or a cache that outlives a deploy.
cloudflareKVAdapterquestpie/adapters/cloudflare-kvA Cloudflare KV namespace bindingYou deploy to Cloudflare Workers.

What actually changes when you swap

The five calls are identical. These are not.

BehaviorMemoryRedisCloudflare KV
Sharedno, one processyesyes, globally
Survives restartnoyesyes
Read after writeimmediateimmediateeventually consistent
Expirylazy, on next touchserver-sideserver-side
Valuesstored by referenceJSON.stringifyJSON.stringify
clear() coversthe whole MapkeyPrefix, else allkeyPrefix, 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.

OptionTypeDefaultNotes
adapterKVAdapternew MemoryKVAdapter()The backend. Omit it for in-memory.
defaultTtlnumbernoneSeconds. Used by set only when the call omits its own ttl.

Swapping to Redis

One config entry, and nothing in the route above moves.

src/questpie/server/questpie.config.ts
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.

OptionTypeDefaultNotes
keyPrefixstring""Prepended to every key. Share one database between apps.
tagIndexPrefixstring"__questpie_tag:"Prefix for the tag-index sets, applied after keyPrefix.
scanCountnumber100Keys per SCAN page during clear() and tag cleanup.
allowFlushDbbooleanfalseSee 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.

On this page