Cache (Valkey)
AnvilBase includes Valkey — the Apache-2.0 community fork of Redis — as a
shared cache. Each project gets its own namespace: every key is transparently
prefixed with <project_id>:, so projects never see each other’s keys. The
platform also uses Valkey for per-project rate limiting.
Use it for ephemeral, fast-access data: session blobs, computed results, rate counters, feature flags, short-lived locks.
Two ways to reach the cache
| Surface | Auth | Use it for |
|---|---|---|
Cache data-plane API (/v1/cache/{id}/…) | a project service_role key | your app/backend (the recommended path — same as the SDK client.cache) |
Cache management API (/api/v1/projects/{id}/cache/…) | a platform PAT / admin token | the console + operational scripts (browse/flush/stats) |
Both apply (and strip) the <project_id>: namespace for you, so you always work
in bare keys. The data-plane is the surface an app uses with only a project
key — see below.
Per-project isolation (enforced, not just by convention)
Every project has a dedicated Valkey ACL user (proj_<project_id>) that is
physically confined to its <project_id>:* keyspace. The cache data-plane
connects to Valkey as that user, so a project cannot read or write another
project’s keys, and cannot run cluster-wide commands (FLUSHALL, FLUSHDB,
KEYS, CONFIG, …) — Valkey returns NOPERM. The ACL user’s password is
derived from the deployment’s CONTROL_PLANE_SECRET (HMAC-SHA256, never stored
in plaintext) and the user is created automatically at project provisioning
(and lazily back-filled for older projects on first use).
This means namespace isolation is an enforced security boundary, not just a key-prefix convention.
The cache data-plane API (use this from your app)
Authenticate with a project service_role key and call /v1/cache/{id}/…. Keys
are bare; the namespace is applied + enforced for you.
| Operation | Endpoint | Body / result |
|---|---|---|
| Get | GET /v1/cache/{id}/get/{key} | → { value } (404 if absent) |
| Set | POST /v1/cache/{id}/set | { key, value, ex?, px?, nx?, xx? } → { ok } |
| Delete | POST /v1/cache/{id}/del | { keys: [...] } → { deleted } |
| Incr / Decr | POST /v1/cache/{id}/incr (or /decr) | { key, by? } → { value } |
| Expire | POST /v1/cache/{id}/expire | { key, seconds } → { applied } |
| TTL | GET /v1/cache/{id}/ttl/{key} | → { ttl } (-1 no expiry, -2 absent) |
| Exists | GET /v1/cache/{id}/exists/{key} | → { exists } |
| MGet | POST /v1/cache/{id}/mget | { keys: [...] } → { values: [...] } |
| MSet | POST /v1/cache/{id}/mset | { pairs: { k: v } } → { ok } |
| Scan | GET /v1/cache/{id}/scan?cursor=&match=&count= | → { cursor, keys } (bare keys) |
The cache data-plane is service_role-only — an anon or authenticated key
gets 403. The SDK alias shape /v1/{id}/cache/v1/… is equivalent.
# set with a 1-hour TTL (service_role key)curl -X POST http://localhost:39001/v1/cache/<id>/set \ -H "Authorization: Bearer $SERVICE_ROLE_KEY" -H "Content-Type: application/json" \ -d '{"key":"session:abc","value":"{\"user\":\"3f2b\"}","ex":3600}'
# read it backcurl http://localhost:39001/v1/cache/<id>/get/session:abc \ -H "Authorization: Bearer $SERVICE_ROLE_KEY" # → {"value":"..."}
scanonly ever returns your keys. Note: ascanwithmatch=*is scoped to your namespace by the API; the per-key ACL is the hard floor for values (another project’s values are alwaysNOPERM).
The cache management API
The control plane exposes a project’s keyspace over the management API. You pass the
bare key — the <project_id>: prefix is added and stripped for you.
| Operation | Endpoint |
|---|---|
| List keys | GET /api/v1/projects/{id}/cache/keys?pattern=*&limit=50 |
| Get a key | GET /api/v1/projects/{id}/cache/keys/{key} |
| Set a string key | POST /api/v1/projects/{id}/cache/keys |
| Delete a key | DELETE /api/v1/projects/{id}/cache/keys/{key} |
| Flush the project | POST /api/v1/projects/{id}/cache/flush |
| Stats (key count) | GET /api/v1/projects/{id}/cache/stats |
Set a value with a TTL
curl -X POST http://localhost:39001/api/v1/projects/<id>/cache/keys \ -H "Authorization: Bearer $ANVILBASE_TOKEN" -H "Content-Type: application/json" \ -d '{"key":"session:abc","value":"{\"user\":\"3f2b\"}","ttl":3600}'ttl is in seconds; omit it for no expiry. Returns 201.
Read it back
curl http://localhost:39001/api/v1/projects/<id>/cache/keys/session:abc \ -H "Authorization: Bearer $ANVILBASE_TOKEN"# → value plus type (string/hash/list/set/zset), TTL, sizeList and flush
# list keys (pattern default *, limit capped at 200)curl "http://localhost:39001/api/v1/projects/<id>/cache/keys?pattern=session:*&limit=100" \ -H "Authorization: Bearer $ANVILBASE_TOKEN"
# delete all of THIS project's keyscurl -X POST http://localhost:39001/api/v1/projects/<id>/cache/flush \ -H "Authorization: Bearer $ANVILBASE_TOKEN"# → { "deleted": <count> }The list/get endpoints can read all value types (string, hash, list, set, sorted set); the set endpoint writes string keys.
The console surfaces this management API as a Cache inspector page (sidebar → Cache): pick a project, browse keys (with a pattern filter), view a key’s value and TTL, set or delete keys, see a key-count / memory stats strip, and flush the whole project’s keyspace (confirm-guarded).
Using the cache from your app (SDK)
Every AnvilBase SDK exposes client.cache over the data-plane API above. Keys are
bare; the namespace is applied + enforced server-side. It is a server-side
surface — initialise the client with a service_role key from trusted backend
code (an anon/authenticated key gets 403).
import { createClient } from '@anvilbase/client'
const client = createClient('https://your-host', SERVICE_ROLE_KEY, { projectId })
await client.cache.set('user:42:profile', JSON.stringify(profile), { ex: 300 })const { data } = await client.cache.get('user:42:profile')The same cache surface exists in the Python, Go, Rust, Elixir, Kotlin, and
Swift SDKs (client.cache.set(...) / cache.get(...)), and as MCP tools
(anvilbase_cache_get / _set / _del / _incr / _expire / _ttl /
_scan).
Caching patterns
Read-through cache
async function getProfile(userId: string) { const { data: hit } = await client.cache.get(`profile:${userId}`) if (hit) return JSON.parse(hit)
const { data } = await db.from('profiles').select('*').eq('id', userId).single() await client.cache.set(`profile:${userId}`, JSON.stringify(data), { ex: 300 }) return data}Rate counter
const { data: n } = await client.cache.incr(`rl:${ip}`)if (n === 1) await client.cache.expire(`rl:${ip}`, 60)if (n > 100) throw new Error('rate limited')Short-lived lock
const { data: ok } = await client.cache.set(`lock:job:${id}`, '1', { nx: true, ex: 30 })if (!ok) return // someone else holds itDeprecated: raw Redis with the shared VALKEY_PASSWORD
Deprecated — do not use. Earlier guidance suggested connecting to Valkey directly with the shared
VALKEY_PASSWORDand prefixing keys by hand. That password belongs to the Valkey admin (default) user, which can read and write every project’s keys and runFLUSHALL— there is no isolation. A leaked or sharedVALKEY_PASSWORDis a cross-tenant compromise.Use the cache data-plane API /
client.cacheinstead (above): it connects as your project’s namespace-isolated ACL user, so a credential leak is bounded to your own namespace. The adminVALKEY_PASSWORDis never exposed to projects and should be treated as an internal platform secret.
Notes
- Valkey is ephemeral — treat it as a cache, not a database. Anything you can’t recompute belongs in Postgres.
- Per-project quotas can bound namespace size; flush stale keys and set TTLs.
- The platform’s rate limiter shares this Valkey instance (as the admin user) — it is unaffected by the per-project ACL users and stays scoped by its own keys.
Next: Cron & Scheduling.