Key-value store
plax.kv — get, set and expire keys in your app's own database. Nothing to add, nothing to paste.
Every app collects small shared state — a last-run timestamp, a cached sidebar, a rate-limit counter, a flag you flip without a deploy. plax.kv keeps it in one table in your app's own database, created the first time you touch it. No service to add, no key to paste.
Set and get
import { createPlaxKv } from "@camplax/plax";
import { db } from "./db.ts";
const kv = createPlaxKv(db);
await kv.set("digest:last", { sentAt: Date.now() });
const last = await kv.get<{ sentAt: number }>("digest:last"); // { sentAt: … } | null
get answers null when the key is not there; values are any JSON — objects, arrays, strings, numbers. getMany reads a batch in one round trip and delete says whether a row existed:
await kv.getMany(["digest:last", "digest:count"]); // { "digest:last": { sentAt: … } }
await kv.delete("digest:last"); // true — and false the second time
Expiry
ttlSeconds on set gives a key a lifetime — once it lapses the key reads as absent, and the expired row is deleted the next time it is touched, so stale keys clean themselves:
await kv.set("lock:digest", runId, { ttlSeconds: 300 });
Expiry is measured on the database clock, not your app's. A set without ttlSeconds never expires, and setting the same key again replaces the whole row — TTL included.
Listing and counting
await kv.list({ prefix: "lock:" }); // key names, unexpired only
await kv.increment("digest:count"); // atomic — adds 1 by default
await kv.increment("digest:bytes", bytes); // or any amount
list takes { prefix?, limit? } — limit defaults to 1,000 and caps there. increment is one statement (INSERT … ON CONFLICT DO UPDATE … RETURNING): an absent or expired key starts at the amount, a live number adds to it, and anything else throws not_a_number — counters and rate limits with no read-modify-write race. One edge to know: an expired key that increment revives restarts without its old TTL — set the TTL again with set if it should still expire.
The rails
Keys are at most 256 characters and serialised values at most 64 KB. Past either — or a bad key, an unserialisable value, a non-positive TTL — the call throws a PlaxKvError with a code (key_too_long, value_too_large, invalid_key, invalid_value, invalid_ttl, not_a_number) before the database is touched, so a 40 MB blob can never land in the one table the whole app shares.
What it is — and is not
This is your app's own Postgres, and it behaves like it: reads see committed writes immediately, and handed a transaction client it joins your transactions — exactly what you want for correctness-sensitive state like idempotency records, "did we send this" flags, or a lock with an expiry. The bound is your database pool.
It is not a distributed cache: there is no sub-millisecond edge read, no memory-tier eviction, and no point filling it with megabytes of churn. If you need that, you need a real cache — this does not pretend to be one.
Under the hood: one plax_kv table — key text primary key, value jsonb, expires_at timestamptz, updated_at timestamptz — created lazily on first call. There is no migration to write and nothing on Camplax's side to provision.