KV
Namespaced key-value storage with duration TTL — sessions, caches, and short-lived locks via fx.store.
The KV facet is a typed namespace for sessions, cache entries, OTPs, and other short-lived keys.
Declare with store.kv(name), then read and write through fx.store(decl) inside Flows.
For developers caching or locking on okengine — one get/set surface; Redis in Docker, memory in tests.
The one rule
Pass the declaration into fx.store(sessions). There is no fx.store.kv
namespace. TTL is a duration string ("15m"), not { ttl: "15m" }.
Smallest Example
Declare a namespace
import { store } from "okengine";
export const sessions = store.kv("sessions");Set and get in a Flow
import { on, flow, http } from "okengine";
import { z } from "zod";
import { sessions } from "@/core";
export const create = on(
http.post({
in: z.object({ token: z.string(), userId: z.string() }),
}),
flow({
do: async ({ token, userId }, fx) => {
await fx.store(sessions).set(`session:${token}`, userId, "1h");
const cached = await fx.store(sessions).get(`session:${token}`);
return { userId: cached };
},
}),
);Call the endpoint
curl -X POST http://localhost:6530/sessions \
-H "content-type: application/json" \
-d '{"token":"abc","userId":"u1"}'Response:
{
"data": { "userId": "u1" },
"error": null
}Import the declaration
store.kv(…) must run when the app loads — export it from a module Flows import (starters:
@/core). A Flow that only types the name as a string never opens a namespace; the handle needs
the decl object.
Progressive Patterns
From a plain set to TTL, list/delete, and durable SQL-backed namespaces:
Values may be strings, numbers, or JSON-serializable objects:
import { on, flow, http } from "okengine";
import { z } from "zod";
import { sessions } from "@/core";
export const get = on(
http.get({
in: z.object({ userId: z.string() }),
}),
flow({
do: async ({ userId }, fx) => {
const prefs = await fx.store(sessions).get(`user:${userId}:prefs`);
return { prefs: prefs ?? null };
},
}),
);Missing keys return undefined / driver nullish — treat as a cache miss.
Method Reference
fx.store(kvDecl):
| Method | Signature | Purpose | Side effect |
|---|---|---|---|
get | get(key) | Read value (or miss) | Read |
set | set(key, value, ttl?) | Write; optional duration string | Write |
delete | delete(key) | Remove; returns whether deleted | Write |
list | list(prefix?) | List keys (optional prefix) | Read |
ttlMs | ttlMs(key) | Remaining ms, or null | Read |
Handle also exposes ref (kv:name) and driverId
(memory · redis · postgres · pglite when durable).
There is no public incr, setNx, del alias, or eval on the fx handle.
Gate rate strategies use Redis eval internally — not application Flows.
Namespaces
Every store.kv(name) registers a Manifest resource kv:name. The string you
pass is the namespace id — keys inside it are relative to that namespace.
Cache namespace (default) — opens on drivers.store.kv (redis / memory):
export const sessions = store.kv("sessions");
// ref → "kv:sessions"Durable namespace — opens on the shared SQL connection (oke_kv table):
export const drafts = store.kv("drafts", {
durable: true,
description: "Compose drafts that survive Redis recreate",
});Global under tenancy — skip the {tenantId}: prefix when gate.auth.tenant
is on:
export const sharedFlags = store.kv("shared-flags", {
tenantScoped: false,
});Methods
Each verb binds through fx.store(decl) inside flow({ do }):
Read a key. Misses return undefined (or driver nullish):
import { on, flow, http } from "okengine";
import { z } from "zod";
import { sessions } from "@/core";
export const get = on(
http.get({
in: z.object({ token: z.string() }),
errors: { NotFound: z.object({ token: z.string() }) },
}),
flow({
do: async ({ token }, fx) => {
const userId = await fx.store(sessions).get(`session:${token}`);
if (userId === undefined || userId === null) {
return fx.fail("NotFound", { token });
}
return { userId };
},
}),
);Declare Options
Second argument to store.kv(name, options):
| Option | Type | Default | Meaning |
|---|---|---|---|
description | string | omitted | Console / Manifest label |
durable | true | omitted | Persist in SQL oke_kv (not Redis) |
tenantScoped | boolean | true when tenancy is on | Prefix keys with {tenantId}:; set false for global namespaces |
Durable Namespaces
Detailed section
If you only need a Redis/memory cache, skip this section. durable: true is a different backend —
SQL table oke_kv on the shared store.sql connection — not a Redis persistence mode.
Use durable KV when values must survive Redis flushes or Compose recreate
(feature flags, compose drafts, webhook registrations). Cache namespaces stay
on redis / memory.
import { store } from "okengine";
export const drafts = store.kv("drafts", {
durable: true,
description: "Compose drafts",
});import { on, flow, http } from "okengine";
import { z } from "zod";
import { drafts } from "@/core";
export const save = on(
http.put("/drafts/:id", {
in: z.object({
id: z.string().min(1),
title: z.string().min(1),
body: z.string().optional(),
}),
}),
flow({
do: async ({ id, title, body }, fx) => {
await fx.store(drafts).set(id, { title, body: body ?? "" }, "7d");
return { id };
},
}),
);Rows live in engine-owned table oke_kv (namespace, key, value JSONB,
expires_at, updated_at). Schema is ensured at open — not part of
oke db push domain migrations.
| Fact | Detail |
|---|---|
| Connection | Shared primary SQL URL (DATABASE_URL / project SQL) |
driverId on the handle | postgres or pglite (the SQL driver), not redis |
| TTL | expires_at filtered on get / list / ttlMs; expired rows purged on write paths |
| Distinct from | Flow durable: true (step journaling) |
Durable namespaces reject Lua/eval. Gate rate buckets and any Redis-only
atomic scripts stay on a cache (redis / memory) namespace.
oke store: durable store.kv does not support evalWithout a SQL driver configured:
oke store: durable store.kv needs a configured sql driver| Need | Prefer |
|---|---|
| Sessions, OTP, short locks, Gate rates | redis / memory cache namespace |
| Flags / drafts that must outlive Redis recreate | durable: true |
| Relational queries / joins | SQL, not KV list |
TTL Physics
Detailed section
TTL is best-effort and driver-dependent. The same set(key, value, "30m") call does not mean
identical expiry semantics on every backend.
TTL — same call, different physics
set(key, value, "30m")setredisTTL honored
`SET … EX` — the key vanishes when the bar hits zero. No sweeper Flow required.
memoryTTL ignored
Local default — the key stays until `delete` or process exit. Test expiry on redis.
| Driver | TTL on set(…, "30m") |
|---|---|
redis | Applied (SET … EX); key expires |
memory | ttlMs may report remaining time; get does not expire the key |
Durable SQL (oke_kv) | Applied on read (expires_at filter) |
Valid units match ^(\d+)(ms|s|m|h|d)$. Invalid strings parse to 0 (no
useful expiry). Prefer explicit units: "30s", "15m", "1h", "1d".
Redis converts the duration to whole seconds (EX); sub-second "ms" values
ceil to at least 1 second when TTL is set.
Tenant Scoping
When gate.auth.tenant is on, KV namespaces
default to tenant-prefixed keys. Application code still uses logical keys —
the runtime rewrites:
| Op | Physical key (when scoped) |
|---|---|
get / set / delete / ttlMs | {tenantId}:{key} |
list(prefix?) | scans {tenantId}:{prefix…}; strips the prefix on return |
// With tenancy on and fx.tenant.id = "acme":
await fx.store(sessions).set("session:abc", "u1", "1h");
// Physical redis key: oke:kv:sessions:acme:session:abcOpt out for genuinely global namespaces:
export const catalog = store.kv("catalog", { tenantScoped: false });Missing tenant on a scoped op throws OKE1810 (TENANT_REQUIRED):
This operation needs a tenant, but none is resolved for this request.Fix: switch tenant (fx.auth.switchTenant), send a signed tid claim, or pass
the tenant header — see Tenancy.
Key Prefixes & Effects
Two prefix layers sit under the logical key you pass to fx.store:
| Layer | Shape | Who sees it |
|---|---|---|
| Tenant (when scoped) | {tenantId}: | Stripped from list results |
| Driver | Redis oke:kv:{ns}: · memory {ns}: · durable SQL namespace column | Console / Redis tools |
Compiler effects stamp kv:name on reads (get · list · ttlMs) and
writes (set · delete). Declare the same refs when you hand-write
effects on a Flow.
Drivers
| Driver | Runs as | Best for |
|---|---|---|
redis | Docker Redis / Valkey-compatible | Dev + prod default |
memory | Process map | Test default |
Durable (postgres / pglite via oke_kv) | Shared SQL URL | Namespaces that must outlive Redis |
Defaults: redis / memory / redis (dev / test / prod). Pin overrides in
oke.config.ts; image pins stay under images.store.kv — the driver id stays
redis for Valkey / Dragonfly / Upstash wire-compatible servers.
Gate rate strategies inherit drivers.store.kv (no separate drivers.gate).
Troubleshooting
Boot needs drivers.store.kv (or DRIVER_DEFAULTS). Check REDIS_URL when the id is redis.
durable: true opens oke_kv on the SQL connection. Configure drivers.store.sql and
DATABASE_URL (or the project SQL URL).
Durable namespaces reject Lua/eval. Keep rate-limit buckets on a Redis namespace; use durable KV
for flags and similar durable maps.
Cause: Unknown kv ref: …. The declaration was never imported. Export store.kv(…) from a module
the app loads before Flows run (starters: @/core).
Expected on the memory driver — see TTL Physics. Use redis in Docker when
expiry must be enforced on read.
Third argument is a bare string: set(key, value, "15m"). There is no options bag on set.
Cause: This operation needs a tenant, but none is resolved for this request. Tenancy is on and
the namespace is tenant-scoped. Resolve fx.tenant.id, or set tenantScoped: false for global
keys.
Browse refused rather than KEYS *. Use a Redis client that supports SCAN (Bun.RedisClient
does). Prefer prefix filters in admin Flows.
Those helpers are not on the public handle. Use get / set / delete / list / ttlMs via
fx.store(decl). Gate rates use Redis Lua internally.
Learn more
- Store — four facets; driver defaults
- SQL — durable KV shares the SQL driver
- Gate · Tenancy —
tenantScopedandfx.tenant.id - Gate — rate buckets use Redis internally
- fx —
fx.store(decl) - Configuration —
drivers.store.kv - Errors — OKE1810