A cache over HTTP and in the clients
The space's cache verbs as HTTP routes — get, set with an expiry, incr, conditional set, keys by prefix, a lock and its release — and the same calls in all five clients.
A space already is a cache, a counter and a lock: the
statements are on Expiry, counters and locks.
This page is the same thing without writing the statement — ten HTTP routes, and a
cache handle in every client — for node 0.15.0-beta and later.
Over HTTP
Every route runs one space statement through your own session, so a grant, a tenancy and a refusal are exactly what a script would meet. The key is the rest of the path, percent-encoded; the three names before it are plain names.
curl -X PUT 'http://127.0.0.1:8000/kv/app/main/cache/key/session%3Aabc?expire=30m' \
--data-binary "{ user: 'ada', at: datetime '2026-09-30T10:00:00Z' }"
# {"written":true}
curl 'http://127.0.0.1:8000/kv/app/main/cache/key/session%3Aabc'
# {"value":{"at":"2026-09-30T10:00:00Z","user":"ada"},"ttl":"29m59s985ms"}
curl -X POST 'http://127.0.0.1:8000/kv/app/main/cache/incr/hits?by=3'
# {"value":3}
curl 'http://127.0.0.1:8000/kv/app/main/cache?prefix=session'
# {"keys":["session:abc"]}
| method | path | query | answers |
|---|---|---|---|
GET | /kv/{ns}/{db}/{space}/key/{key} | — | {"value": …, "ttl": "…" | null} — null is never expires; 404 when there is no key |
PUT | /kv/{ns}/{db}/{space}/key/{key} | expire, if=absent|present | {"written": true|false} |
DELETE | /kv/{ns}/{db}/{space}/key/{key} | — | {"deleted": true|false} |
POST | /kv/{ns}/{db}/{space}/swap/{key} | expire | {"written": …} — the body is { expect: old, value: new } |
POST | /kv/{ns}/{db}/{space}/incr/{key} | by | {"value": n} |
POST | /kv/{ns}/{db}/{space}/expire/{key} | expire | {"found": …} |
POST | /kv/{ns}/{db}/{space}/persist/{key} | — | {"found": …} |
POST | /kv/{ns}/{db}/{space}/lock/{key} | holder, expire | {"held": …} |
POST | /kv/{ns}/{db}/{space}/unlock/{key} | holder | {"released": …} |
GET | /kv/{ns}/{db}/{space} | prefix, after, limit | {"keys": […]} |
Four things the routes promise:
- A condition that does not hold is
false, not an error.written: falsefromif=absentmeans somebody got there first; a refusal is a4xx. - The body is one TessariQL value, so a datetime, a decimal or a record reference arrives as itself — not a JSON string you have to reinterpret.
expiremust be positive. A zero or negative one is a400, because the statement underneath would remove the key.- Not a space is
404, and so is an operation that does not exist; the right operation with the wrong method is405.
A client that sends a password with every request pays a deliberately slow hash
every time: exchange it once for a token at POST /session and send that.
A lock over HTTP
curl -X POST 'http://127.0.0.1:8000/kv/app/main/locks/lock/report?holder=worker-7&expire=30s'
# {"held":true} — taken, or extended if worker-7 already held it
curl -X POST 'http://127.0.0.1:8000/kv/app/main/locks/lock/report?holder=worker-9&expire=30s'
# {"held":false}
curl -X POST 'http://127.0.0.1:8000/kv/app/main/locks/unlock/report?holder=worker-7'
# {"released":true}
A lock is a lease, not a mutex: past expire another holder may take it and
neither is told. unlock never deletes: it writes an expiring value only while
the key still names you, so a worker whose lease already lapsed cannot remove the
next holder's lock — and the key cannot become permanent, which a hand-back
without an expiry would do.
In the clients
Each client has a cache handle over a connection you already hold. It rides the
wire, so every value keeps its type, and it sends its own USE with every call.
The calls and their statements are the same in all five — the protocol
repository's spec/cache-v1.md:
| call | what it answers |
|---|---|
get · set(ttl?) · delete | the value or nothing · — · whether there was a key |
setIfAbsent · setIfPresent · compareAndSet | whether it wrote |
incr(by) | the new value |
ttl · expire · persist | expires / never / no key · whether there was a key |
keys(prefix, after, limit) | keys in order, up to 1000 |
getOrSet(key, ttl, loader) | the stored value — the loader runs only on a miss |
lock(key, ttl, holder?) | a lease, with extend and release, or nothing |
const cache = new Cache(connection, { namespace: 'app', database: 'main', space: 'cache' });
const page = await cache.getOrSet('page:/', 60_000, () => render());
const lease = await cache.lock('nightly-report', 30_000);
if (lease) { /* … */ await lease.release(); }
The same in Rust, Python, Go, Kotlin and TypeScript.
Two rules every one of them is built around, because both fail silently:
- A plain
setclears an expiry the key had. Pass the ttl again on every write that must keep one. getOrSetdoes not coordinate racing callers. Each that misses runs its loader; the first to store wins and the others answer the winner's value.