Skip to main content

@monlite/kv — cache, locks, sorted sets & pub/sub

Redis's local role in a file: a synchronous, persisted key/value store with TTLs, an atomic set-if-absent (the lock/nonce primitive), Redis-style sorted sets, and cross-process pub/sub. Values are any JSON-serializable data; TTLs are in milliseconds.

npm install @monlite/kv
import { createDb } from "@monlite/core";
import { kv } from "@monlite/kv";

const db = createDb("app.db");
const cache = kv(db);

cache.set("session:42", { user: "ali" }, { ttl: 60_000 }); // expires in 60s
cache.get("session:42"); // { user: "ali" } — synchronous, no await
cache.incr("hits"); // 1
cache.ttl("session:42"); // ~60000 (ms remaining)

// SET NX — the lock / nonce primitive
if (cache.setNX("lock:sweeper", 1, { ttl: 5_000 })) {
// acquired the single-flight lock
}

On the SQLite engine (kv(db)) every method is synchronous — no await. On the Postgres engine you use pgKv(db) instead, which has the identical surface but returns Promises. Calling kv() on a Postgres database throws with a pointer to pgKv.

Strings, counters & keys

cache.set("name", "ada"); // store any JSON value
cache.set("token", "abc", { ttl: 30_000 }); // with a TTL (ms)
cache.get<string>("name"); // "ada" (or undefined if missing/expired)
cache.has("name"); // true
cache.delete("name"); // true if a key was removed

cache.incr("views"); // 1 — atomically add 1 (default)
cache.incr("views", 10); // 11
cache.decr("stock", 3); // atomically subtract

cache.mget(["a", "b", "c"]); // [valA, undefined, valC] — bulk get
cache.keys(); // every live key in the namespace
cache.keys("session:"); // keys with this prefix (expired ones excluded)
cache.size(); // count of live keys
cache.flush(); // delete every key in this namespace
MethodDescription
get<T>(key)the value, or undefined if missing/expired
set(key, value, { ttl? })store any JSON value; optional TTL in ms
setNX(key, value, { ttl? })atomic set-if-absent → true if acquired, false if a live key exists
has(key)true if a live key exists
delete(key)true if a key was removed
incr(key, by?) / decr(key, by?)atomically add/subtract (by default 1); returns the new value
mget<T>(keys)values in order, undefined for absent/expired
keys(prefix?)live keys in the namespace, optionally by prefix
expire(key, ttl)set/refresh a key's TTL (ms); false if the key is absent
ttl(key)remaining TTL in ms; -1 if no expiry, -2 if absent (Redis convention)
size()number of live keys in the namespace
flush()delete every key in the namespace

incr/decr throw if the stored value isn't a number. ttl follows the Redis convention exactly: a positive number of ms remaining, -1 for a key with no expiry, -2 for a key that doesn't exist (or has expired).

Atomic locks with setNX

setNX (SET … NX) is the building block for single-instance schedulers, nonces, distributed mutexes, and once-only work. It returns true only if the key wasn't already present (and live), false otherwise:

// Only one process/worker may run the nightly sweep
if (cache.setNX("lock:sweep", process.pid, { ttl: 30_000 })) {
try {
await runSweep();
} finally {
cache.delete("lock:sweep"); // release early; the TTL is the safety net
}
}

setNX and incr/decr run under BEGIN IMMEDIATE, taking the write lock up front, so they're cross-process safe: multiple processes (or workers) sharing the same .db can race the same key and exactly one acquires the lock — the losers cleanly get false rather than deadlocking or erroring.

Use a separate :memory: db for a purely ephemeral cache, or { namespace } to isolate multiple caches in one file.

Sorted sets (ZSET)

Redis-style sorted sets — leaderboards, rate-limiters, priority indexes, time series. Each member has a numeric score; members are ordered by score, ties broken lexicographically by member.

cache.zadd("board", 100, "alice");
cache.zadd("board", 60, "bob");
cache.zincrby("board", 5, "bob"); // → 65 (new score)

cache.zscore("board", "alice"); // 100
cache.zcard("board"); // 2 (number of members)
cache.zrank("board", "alice", { rev: true }); // 0 — top by descending score

cache.zrange("board", 0, 2, { rev: true }); // top 3 by score: ["alice", "bob"]
cache.zrange("board", 0, -1, { withScores: true }); // [{ member, score }, …]
cache.zrangeByScore("board", 50, 100); // members with 50 ≤ score ≤ 100

cache.zrem("board", "bob"); // true if removed
MethodDescription
zadd(key, score, member)add or update member with score (ZADD)
zincrby(key, delta, member)atomically bump member's score by delta; returns the new score (ZINCRBY)
zscore(key, member)the member's score, or undefined (ZSCORE)
zrem(key, member)remove a member; true if it existed (ZREM)
zcard(key)number of members (ZCARD)
zrank(key, member, { rev? })0-based rank by ascending (or rev descending) score; undefined if absent
zrange(key, start, stop, { rev?, withScores? })members by rank range [start, stop] inclusive (negative counts from the end), ascending or rev descending
zrangeByScore(key, min, max, { withScores? })members with min ≤ score ≤ max, ascending (ZRANGEBYSCORE)

zincrby runs under an immediate transaction, so a leaderboard or sliding-window rate-limiter stays correct across processes. With withScores, zrange / zrangeByScore return Array<{ member, score }> instead of string[].

A leaderboard top-N and a member's own rank:

cache.zincrby("scores", 1, userId); // record a point
const top10 = cache.zrange("scores", 0, 9, { rev: true, withScores: true });
const myRank = cache.zrank("scores", userId, { rev: true }); // 0 = first place

Pub/sub

publish / subscribe (Redis PUBLISH / SUBSCRIBE) fan out messages to every listener on a channel — including listeners in other processes sharing the same .db.

const stop = cache.subscribe("jobs", (msg) => console.log("got", msg));
const n = cache.publish("jobs", { id: 7 }); // delivered to every "jobs" subscriber
// n = number of subscribers on THIS instance that received it
stop(); // unsubscribe
MethodDescription
publish(channel, message)publish to a channel; returns the count of local listeners that received it
subscribe(channel, cb)subscribe; cb fires for every message published after this call. Returns an unsubscribe fn

Same-process delivery is immediate; cross-process listeners are picked up by a short poll (pubsubPollMs, default 200 ms) that starts on the first subscribe() and stops when the last one unsubscribes. Messages are ephemeral — not replayed to late subscribers, and old messages are pruned automatically (a late subscriber only sees messages published after it subscribed).

Options & lifecycle

const cache = kv(db, {
namespace: "ratelimit", // isolate multiple caches in one file (default "default")
sweepIntervalMs: 60_000, // periodically purge expired keys (default: lazy-only)
pubsubPollMs: 200, // cross-process pub/sub poll interval (ms)
});

cache.stop(); // stop the sweep + pub/sub timers (if any)
OptionDescription
namespacelogical namespace so multiple caches share one database. Default "default"
sweepIntervalMsif set, a timer periodically purges expired keys. Default: lazy-only (keys are also purged on read)
pubsubPollMshow often a subscribe() listener polls for cross-process messages. Default 200

Expired keys are reclaimed lazily on access regardless of sweepIntervalMs; the sweep timer just bounds how long dead rows linger on disk.

Postgres engine: pgKv

On the Postgres engine the same model — namespaced keys, TTLs, sorted sets, table-backed cross-process pub/sub — runs over a networked, multi-writer database via pgKv(db). The surface is identical to kv, but every method is async:

import { createDb } from "@monlite/postgres";
import { pgKv } from "@monlite/kv";

const db = createDb("postgres://user@host/db");
const cache = pgKv(db);

await cache.set("session:42", { user: "ali" }, { ttl: 60_000 });
await cache.get("session:42"); // { user: "ali" }

if (await cache.setNX("lock:sweep", 1, { ttl: 5_000 })) {
// acquired across all processes — atomic via a Postgres transaction
}

await cache.zincrby("board", 1, "alice");
await cache.zrange("board", 0, 9, { rev: true, withScores: true });

const stop = cache.subscribe("jobs", (m) => console.log(m)); // subscribe stays sync
await cache.publish("jobs", { id: 7 });

Every method that touches the database (get, set, setNX, incr, zadd, …, publish) returns a Promise — await it. subscribe is synchronous (it returns the unsubscribe fn immediately) and stop is synchronous, exactly as on SQLite. setNX, incr, and zincrby use Postgres transactions for the same cross-process atomicity. Calling kv() on a Postgres database (or pgKv() on a SQLite database) throws with a clear pointer to the right factory.