fast state

Key-Value

Configuration, feature flags, hot keys — millisecond reads.

What it is

Key-Value on RedDB.

Two complementary surfaces under one umbrella. User KV collections are SQL-queryable (key, value) collections — same engine, same WAL, same backup story as your tables. Read with SELECT, write with INSERT / UPDATE — all the SQL surface applies.

Engine config + vault is a separate namespace for runtime knobs (SET CONFIG acme.pagination.pageSize = 100) and encrypted secrets (SET CONFIG red.secret.openai.api_key = 'sk-...'). Reference both inline from any SQL statement via $config.<path> and $secret.<path> — the substitution is a typed Value, never a textual splice, so injection is structurally impossible.

For ephemeral data with TTL eviction (sessions, render fragments), see the dedicated Cache primitive — different shape, different durability story.

Code

Write it. Read it. Same engine.

Insert

SET CONFIG acme.risk_threshold = 'high';

Query

SELECT * FROM users WHERE risk = $config.acme.risk_threshold;

Use cases

Where key-value earn their place.

  • Feature flags

    Per-environment toggles read by every request, pushed by deploys.

  • Idempotency keys

    Webhook event ids, payment retries — durable dedup the engine handles in one round trip.

  • Counters + locks

    INSERT ... ON CONFLICT for idempotent writes and counters. Atomic INCR / CAS roadmap (reddb-io/reddb#238).

  • App config

    Tunables you change without a redeploy, observable from your admin panel like any other table.

Build it

End-to-end walkthrough.

Four steps from empty database to a real key-value workload — each step shows the exact code and explains what the engine is doing.

  1. Step 1

    1. Engine config — SET CONFIG, read inline via $config

    SET CONFIG <path> = <value> is the engine-config surface. Values are addressable from any SQL statement via $config.<path> as typed substitutions. Operations team adjusts at runtime — application code does not refetch.

    -- Set runtime knobs for your app.
    SET CONFIG acme.pagination.pageSize = 100;
    SET CONFIG acme.search.maxResults  = 50;
    SET CONFIG feature.new_dashboard   = 'on';
    
    -- Reference inline from any SQL statement.
    SELECT id, name FROM users
    ORDER BY id LIMIT $config.acme.pagination.pageSize;
    
    -- $config.<path> is resolved as a typed Value at parse time —
    -- not a textual splice — so SQL injection through config writes
    -- is structurally impossible.
  2. Step 2

    2. Vault secrets — same shape, sealed reads

    red.secret.* keys are auto-encrypted with the vault AES key. $secret.<path> resolves the typed Value when policy allows; everyone else sees ***. Provider keys for AUTO EMBED, OAuth, payments — none of them ever land in your repo.

    -- Write under the red.secret.* namespace.
    SET CONFIG red.secret.openai.api_key   = 'sk-...';
    SET CONFIG red.secret.stripe.api_key   = 'sk_live_...';
    
    -- Reference inline; the engine pulls the typed Value from vault.
    INSERT INTO notes (body) VALUES ($1)
      WITH AUTO EMBED (body) USING openai;
    -- The OpenAI provider reads $red.secret.openai.api_key internally —
    -- never appears in your application code, logs, or query plans.
    
    -- Sealed read protects the value from non-privileged principals.
    SELECT $secret.red.secret.stripe.api_key;
    -- → '***' unless the policy allows the principal to UNSEAL.
  3. Step 3

    3. User KV collection — SQL-queryable (key, value) rows

    User KV is shaped as KV collections — (key, value) rows you query with full SQL. WITH TTL evicts on the engine sweep; ON CONFLICT gives you idempotent writes without a separate CAS verb. Same WAL, same backup, same SELECT surface as any other table.

    -- Create a KV collection with a TTL on every entry.
    CREATE TABLE sessions KV (
      key      TEXT PRIMARY KEY,
      value    JSONB NOT NULL,
      created_at TIMESTAMPTZ NOT NULL DEFAULT now()
    );
    
    -- It is a regular collection — the full SQL surface applies.
    INSERT INTO sessions DOCUMENT (key, value)
    VALUES ('abc-123', {"user_id": 42, "scope": "admin"})
    WITH TTL 3600000;
    
    SELECT value FROM sessions WHERE key = 'abc-123';
    
    -- ON CONFLICT DO NOTHING gives you idempotent inserts.
    INSERT INTO idempotency (key, value) VALUES ($1, '1')
    ON CONFLICT (key) DO NOTHING
    RETURNING key;
    -- 0 rows back means the key already existed (replay).
  4. Step 4

    4. Combine — config-driven query against a KV collection

    Config + vault references are typed-Value substitutions, so they compose with the rest of the SQL surface — they're available everywhere a literal would be. Tune at runtime, rotate secrets without redeploys, never touch the values in application code.

    -- Page-size knob in engine config.
    SET CONFIG acme.pagination.pageSize = 25;
    
    -- Iterate a KV namespace using the config knob.
    SELECT key, value
    FROM sessions
    WHERE key LIKE 'org:42:%'
    ORDER BY created_at DESC
    LIMIT $config.acme.pagination.pageSize;
    
    -- Combine with $secret for ops that need a vault value.
    INSERT INTO webhook_events (id, payload, signature_check)
    VALUES ($1, $2, hmac_sha256($2, $secret.red.secret.stripe.webhook_secret));

Engine features

What RedDB adds on top of key-value.

The engine knobs you would otherwise wire up yourself — TTL, encryption, indexes, atomic ops, eventual consistency — applied to this data model.

  • TTL per key

    User KV collections use INSERT INTO <kv_table> (key, value) VALUES (...) WITH TTL <duration> for time-bounded writes that evict on the engine sweep. Atomic increment for sliding-window rate limits is roadmap (reddb-io/reddb#238).

  • Atomic ops (INCR / DECR / CAS) — roadmap

    Planned in reddb-io/reddb#238 (Redis-flavor KV DSL). Today, use INSERT ... ON CONFLICT DO UPDATE SET count = count + 1 for atomic counters and INSERT ... ON CONFLICT DO NOTHING for idempotent writes.

  • WATCH push streams — roadmap

    Planned in reddb-io/reddb#238. Today, fan-out to services on write via the queue primitive or by polling the KV collection with a SELECT WHERE updated_at > $last_seen.

  • Configs pattern

    KV is the natural home for runtime config and feature flags. Read by every request, observable from your admin panel like any other table, audited via the same WAL.

  • Secrets — Value::Secret + red.secret.* namespace

    Sensitive values (red.secret.stripe_api_key) are auto-encrypted with the vault AES key on SET CONFIG, sealed reads return ***. The Value::Secret shape applies to KV the same way it applies to columns.

  • Prefix scan

    SCAN config.* returns matching keys with pagination. Sub-linear in total key count — the namespace tree is indexed.

  • Eventual consistency reducers

    For high-write counters (visit counts, abuse rates), an EC reducer (Sum, Max) gives you append-throughput without a write lock. Reads return the consolidated value; consolidation runs in background.

  • Policy-gated namespaces

    Policies scope by key prefix. A dev role can read config.feature.* but not red.secret.*; a secrets_admin role gets the latter only via UNSEAL. Same KV, two visibility planes.

  • Audit log on secret reads

    Every UNSEAL of a red.secret.* value writes an audit row — actor, secret name, timestamp. Fits SOC 2 / ISO 27001 access-review controls without a sidecar.

  • $config / $secret references inline in SQL

    KV values are addressable directly from any SQL statement: LIMIT $config.acme.pagination.pageSize reads a typed value from the KV store at parse time. The substitution is typed (not string interpolation) so SQL injection is structurally impossible. Same shape for $secret.<path> from the vault.

Showcases

What key-value can do once you bring the rest of the engine in.

Cross-model correlation, algorithm flex, multi-model patterns — things that take a stack of services elsewhere.

Config-driven SQL (no app-layer fetch)

Reference $config.acme.pagination.pageSize directly inside a SELECT. The engine resolves it as a typed value at parse time.

A pagination knob lives in the KV namespace. Every query that paginates reads it inline — operations team adjusts the page size at runtime, no deploy, no client change. The substitution is structural (typed Value through an AST function call), so SQL injection is impossible regardless of who can write the key.

sql

-- 1. Set the config (admin panel, deploy script, runbook).
SET CONFIG acme.pagination.pageSize = 100;

-- 2. Use it directly in any query.
SELECT id, name, created_at
FROM users
WHERE org_id = $1
ORDER BY id DESC
LIMIT $config.acme.pagination.pageSize;

-- 3. Tune at runtime — no app deploy.
SET CONFIG acme.pagination.pageSize = 250;
-- Subsequent queries pick up the new value on next execution.

-- Same pattern for vault secrets used inside SQL functions.
INSERT INTO notes (body) VALUES ($1)
  WITH AUTO EMBED (body) USING openai;
-- The engine reads $red.secret.openai.api_key from vault internally;
-- it never appears in your application code or query string.

Configs + secrets in one namespace

Runtime config, feature flags, and encrypted secrets share one KV — different shapes, same admin panel.

Plain config (config.risk_threshold = high) sits next to encrypted secrets (red.secret.stripe_api_key = ...). The engine encrypts the secret namespace with the vault AES key, regular reads return ***, only authorised principals can decrypt. Both surfaces in your admin panel like any other KV row.

sql

-- Plain config — readable, durable, audited via WAL.
SET CONFIG config.risk_threshold = 'high';
SET CONFIG feature.new_dashboard = 'on';

-- Sensitive — auto-encrypted with vault AES key.
SET CONFIG red.secret.stripe_api_key = 'sk_live_...';
SET CONFIG red.secret.openai_api_key = 'sk-...';

-- Sealed read — $secret.<path> returns '***' unless the caller is authorized.
SELECT $secret.red.secret.stripe_api_key;
-- → '***'

-- Authorised principal sees the plaintext.
SET ROLE secrets_admin;
SELECT $secret.red.secret.stripe_api_key;
-- → 'sk_live_...'

Distributed coordination via INSERT ... ON CONFLICT

Exactly-one-runs semantics — no Zookeeper, no etcd, no Redis.

A multi-worker deploy pipeline needs exactly-one-runs semantics. INSERT ... ON CONFLICT DO NOTHING gives one worker the lock row; the loser gets rowCount 0 and backs off. Atomic CAS with TTL is planned in reddb-io/reddb#238.

typescript

async function withDeployLock<T>(workerId: string, fn: () => Promise<T>) {
  const acquired = await db.query(
    `INSERT INTO deploy_locks (name, holder, acquired_at)
     VALUES ('deploy', $1, NOW())
     ON CONFLICT (name) DO NOTHING`,
    [workerId],
  )
  if (acquired.rowCount === 0) throw new Error('lock taken')
  try {
    return await fn()
  } finally {
    await db.query(
      "DELETE FROM deploy_locks WHERE name = 'deploy' AND holder = $1",
      [workerId],
    )
  }
}

Migrate from

Bring key-value over from your current tool.

From

Redis (durable cases only)

Use redis-cli to dump the keys you treat as durable state — feature flags, idempotency keys, counters — and replay as RedDB SET CONFIG writes.

redis-cli --scan --pattern 'config.*' | while read k; do
  v=$(redis-cli GET "$k")
  red sql -d "reds://admin@.../my-db:5050" \
    "SET CONFIG $k = '$v'"
done

From

etcd

etcdctl get with a prefix → JSON lines → red import.

etcdctl --prefix get /service/ -w json \
  | jq -r '.kvs[] | "SET CONFIG \(.key | @base64d) = \u0027\(.value | @base64d)\u0027"' \
  | red sql -d "reds://admin@.../my-db:5050"

Recipes

Common patterns, real driver code.

sql

Per-IP rate limit

INSERT ... ON CONFLICT to count hits per window — one round trip per request.

INSERT INTO rate_limits (ip, window_start, count)
VALUES ($1, date_trunc('minute', NOW()), 1)
ON CONFLICT (ip, window_start)
DO UPDATE SET count = rate_limits.count + 1
RETURNING count;
-- if count > 100, reject the request.

typescript

Idempotent webhook intake

Dedupe replays by upstream event id with INSERT ... ON CONFLICT DO NOTHING.

const inserted = await db.query(
  `INSERT INTO idempotency_keys (key, created_at)
   VALUES ($1, NOW())
   ON CONFLICT (key) DO NOTHING`,
  [`stripe.${event.id}`]
)
if (inserted.rowCount === 0) return new Response('replay', { status: 200 })
// fresh event — process it

typescript

Live feature flag fan-out

Poll the config key on a short interval, or subscribe via the WATCH stream (roadmap: reddb-io/reddb#238).

// Today: poll the config key.
const { rows } = await db.query(
  "SELECT value FROM app_config WHERE key = 'feature.new_dashboard'"
)
featureFlags.set('new_dashboard', rows[0]?.value === 'on')

Limits

What it does. What it costs.

Operational caps and durability semantics, no hand-waving.

Max value size
1 MB (use Cache or Documents for larger)
Key namespace
Dotted (`a.b.c`); SCAN by prefix is sub-linear
TTL granularity
Per-key, second precision
Atomic writes
INSERT ... ON CONFLICT for idempotent ops; INCR / CAS roadmap (reddb-io/reddb#238)
Durability
fsync + WAL on every commit, same as tables

When NOT to use

Don't pick key-value for these.

Honest constraints — when another model fits better.

  • Don't use KV for ephemeral cached data

    KV writes hit the WAL. For TTL-evicted blobs that you can lose, the Cache primitive (L1 + L2) is faster and operationally cheaper.

  • Don't stuff JSON blobs > 100 KB

    KV is shaped for small, high-frequency reads. Larger structured data belongs in document collections — they are queryable by path, KV is not.

  • Avoid synthesising lists with `key.001`, `key.002`

    If you need an ordered collection, use a regular table with an autoincrement id. SCAN by prefix isn't a substitute for ORDER BY.

vs

How RedDB key-value compare.

Redis (KV side)

Durable by default. Lives in the same .rdb file as your relational data — one backup story.

etcd

Same KV semantics for app state without standing up a separate cluster for a side-line workload.

FAQ

Questions teams ask before they pick key-value.

Is KV durable like a regular table?

Yes. INSERT / UPDATE / DELETE go through the WAL and fsync exactly like row writes. Restarts and crashes preserve every committed change.

How is this different from the Cache primitive?

KV is for state that should never silently vanish — config, feature flags, counters, idempotency keys. Cache is for ephemeral data with TTL eviction. Different durability + eviction semantics, different shape.

Can I list keys by prefix?

SCAN config.* returns matching keys with pagination. The engine indexes the namespace tree so prefix scans are sub-linear in total key count.

What sizes are reasonable for values?

KV is shaped for small payloads — typically up to a few KB. Larger blobs belong in Cache (with TTL) or in document storage (queryable). The engine accepts up to 1 MB but you should not be stuffing files in KV.

Try key-value on RedDB.

Free nano database, no credit card. AGPL self-host or managed Cloud — same engine, same file format.