ElementsStore

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

src/core.ts
import { store } from "okengine";

export const sessions = store.kv("sessions");

Set and get in a Flow

src/flows/sessions/create.ts
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:

src/flows/prefs/[userId]/get.ts
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):

MethodSignaturePurposeSide effect
getget(key)Read value (or miss)Read
setset(key, value, ttl?)Write; optional duration stringWrite
deletedelete(key)Remove; returns whether deletedWrite
listlist(prefix?)List keys (optional prefix)Read
ttlMsttlMs(key)Remaining ms, or nullRead

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):

src/flows/sessions/[token]/get.ts
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):

OptionTypeDefaultMeaning
descriptionstringomittedConsole / Manifest label
durabletrueomittedPersist in SQL oke_kv (not Redis)
tenantScopedbooleantrue when tenancy is onPrefix 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.

src/core.ts
import { store } from "okengine";

export const drafts = store.kv("drafts", {
  durable: true,
  description: "Compose drafts",
});
src/flows/drafts/[id]/save.ts
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 };
    },
  }),
);

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")
set
shared beat across both drivers
  • redis

    TTL honored

    `SET … EX` — the key vanishes when the bar hits zero. No sweeper Flow required.

  • memory

    TTL ignored

    Local default — the key stays until `delete` or process exit. Test expiry on redis.

DriverTTL on set(…, "30m")
redisApplied (SET … EX); key expires
memoryttlMs 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:

OpPhysical 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:abc

Opt 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:

LayerShapeWho sees it
Tenant (when scoped){tenantId}:Stripped from list results
DriverRedis oke:kv:{ns}: · memory {ns}: · durable SQL namespace columnConsole / 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

DriverRuns asBest for
redisDocker Redis / Valkey-compatibleDev + prod default
memoryProcess mapTest default
Durable (postgres / pglite via oke_kv)Shared SQL URLNamespaces 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

Learn more

  • Store — four facets; driver defaults
  • SQL — durable KV shares the SQL driver
  • Gate · TenancytenantScoped and fx.tenant.id
  • Gate — rate buckets use Redis internally
  • fxfx.store(decl)
  • Configurationdrivers.store.kv
  • Errors — OKE1810

Next

On this page