@yaebal/cache
ttl memoization for api calls and arbitrary data: ctx.cache.get/set/wrap, plus
stale-while-revalidate, sliding expiry, negative caching, prefix invalidation, batching,
namespaces, and an optional typed key catalog. built on @yaebal/sklad, so it takes the same storage
adapters (memory, redis, sqlite, cloudflare kv, json-file) as session/scenes. concurrent misses for the same key share one in-flight call, so a burst of updates hitting getChat/getChatMember at once never turns into a burst of duplicate
requests.
install
pnpm add @yaebal/cacheusage
install cache() with bot.install(). it adds ctx.cache to
every handler's context. wrap is read-through: a hit returns the cached value; a
miss calls the function, caches its result, and returns it. a rejected call is never cached,
unless you opt into negative caching.
import { createBot } from "yaebal";
import { cache } from "@yaebal/cache";
const bot = createBot(process.env.BOT_TOKEN!)
.install(cache({ ttl: 60_000 }));
let calls = 0;
bot.command("weather", async (ctx) => {
// stands in for a slow api call (e.g. ctx.getChat()) — wrap caches its result
const forecast = await ctx.cache.wrap("weather:london", async () => {
calls++;
return "sunny, 21°C";
});
await ctx.reply(`${forecast} (fetched ${calls} time(s) so far)`);
});
bot.start();bot.command("whoami", async (ctx) => {
const chat = await ctx.cache.wrap(`chat:${ctx.chat.id}`, () => ctx.getChat());
await ctx.reply(chat.title ?? "private chat");
});options
ttl is enforced by @yaebal/cache itself — each entry carries its own expiry — not
by the storage adapter, so per-call ttl overrides behave the same on every adapter,
including ones whose native ttl is fixed at construction time (redisStorage/sqliteStorage). a non-finite or non-positive ttl throws a RangeError — it never
means "expires now" or "never expires" by accident.
import { cache } from "@yaebal/cache";
import { redisStorage } from "@yaebal/sklad";
bot.install(
cache({
storage: redisStorage(client), // defaults to a bounded MemoryStorage — see "memory" below
ttl: 60_000, // default ttl in ms; omit for "never expires"
scope: "bot-1", // key namespace — set when several bots share one persistent storage
sliding: false, // default for `sliding` on set/wrap calls that don't pass their own
max: 1000, // LRU cap on the default in-memory store; ignored when `storage` is given
sweepIntervalMs: 60_000, // active ttl sweep for the default store; `false` disables it
onEvent: (event) => metrics.count(event.type), // hit / miss / store / stale / dedupe / …
}),
);standalone use
cache() returns the installable plugin itself, with the underlying client on .handle — read or pre-warm it outside a handler (bot.onStart, a
webhook route, …). pass an already-built Cache to cache() to install
that exact instance instead of building a new one.
import { cache, createCache } from "@yaebal/cache";
const apiCache = cache({ ttl: 60_000 });
const bot = createBot(token).install(apiCache);
// outside a handler — bot.onStart, a webhook route, ...
await apiCache.handle.set("feature-flags", flags, 5 * 60_000);
// or build the client first and install that exact instance
const client = createCache({ ttl: 60_000 });
bot.install(cache(client));dedup
wrap tracks in-flight calls per key. if two updates call wrap("chat:1", fetchChat) before either finishes, the second one awaits the
first's promise instead of calling fetchChat again.
// two updates racing the same cold key — fetchChat only runs once
const [a, b] = await Promise.all([
ctx.cache.wrap("chat:1", fetchChat),
ctx.cache.wrap("chat:1", fetchChat),
]);stale-while-revalidate
staleTtl keeps serving a value after ttl elapses, while one background
call to fn refreshes it — every caller in the stale window gets the old value
immediately, with no fetch latency. only the first caller in the window triggers the refresh; a
failed background refresh never surfaces to the caller (it already has a value) — it's reported
via onEvent's "error" event, and the stale entry is left in place for
the next call to retry.
// serves the stale forecast immediately once ttl elapses, refreshing it in the
// background — callers never pay the fetch latency, only the first "expiry" tick does
const forecast = await ctx.cache.wrap("weather:london", fetchWeather, {
ttl: 60_000,
staleTtl: 300_000, // stay servable-stale for up to 5 more minutes while it refreshes
});sliding expiry
sliding: true refreshes an entry's ttl on every hit instead of a fixed absolute
expiry. works with set and wrap, per-call or as CacheOptions.sliding's default. requires a resolvable ttl (per-call or CacheOptions.ttl) — sliding a "never expires" entry throws a RangeError instead of silently doing nothing.
// "expires 5 minutes after the *last* read", not 5 minutes after the write
await ctx.cache.set(`session:${userId}`, data, { ttl: 300_000, sliding: true });negative caching
errorTtl remembers a rejection and re-throws it for that long instead of calling fn again. the tombstone is invisible to get/peek/has — they report a miss, not the error; onEvent reports "negative-hit" when a call hits it. omit errorTtl (the default) for the pre-1.0 behavior: a
rejection is never cached.
// a burst of updates hitting a dead upstream all get the remembered rejection
// instead of retrying it themselves
const chat = await ctx.cache.wrap("chat:1", fetchChat, { errorTtl: 10_000 });invalidation
both need the underlying storage adapter to enumerate its keys — MemoryStorage, sqliteStorage, and fileStorage always can; redisStorage/kvStorage can when their client exposes KEYS/list(). an adapter that can't throws a clear error instead of
silently no-opping.
await ctx.cache.invalidatePrefix("chat:"); // drop every "chat:*" key
await ctx.cache.clear(); // drop everything under this cache's scopebatch
await ctx.cache.setMany([
{ key: "a", value: 1 },
{ key: "b", value: 2, ttl: 10_000 },
]);
const values = await ctx.cache.getMany(["a", "b", "missing"]); // Map<string, T> — only live keysnamespaces
namespace(prefix) returns a view over the same cache with every key prefixed —
reads, writes, and clear()/invalidatePrefix() all stay within it. forChat(chatId) is sugar for the dominant real-world key pattern. namespaces nest
and compose with CacheOptions.scope.
const chatCache = ctx.cache.forChat(ctx.chat.id); // sugar for namespace(`chat:${id}:`)
await chatCache.set("info", chat); // physically "chat:<id>:info"
await chatCache.clear(); // drops only this chat's entriestyped key catalog
pass a schema to createCache/cache for get/set/wrap/peek/getMany inferring the value type from the
key — template-literal key patterns included. entirely a compile-time contract: there's no
catalog object to pass at runtime, unlike @yaebal/feature-flags' flags.
interface Schema {
flags: { newUi: boolean };
[key: `chat:${number}`]: ChatFullInfo;
}
const c = createCache<Schema>();
await c.get("chat:1"); // Promise<ChatFullInfo | undefined>
await c.set("chat:1", "nope"); // ✗ type error — value must be ChatFullInfo
await c.get("anything"); // Promise<unknown | undefined> — keys outside the catalog stay free-form
// or typed straight on the bot:
bot.install(cache<Schema>({ ttl: 60_000 }));events
onEvent observes every operation. a throwing observer is caught and logged; it never
fails the cache call that triggered it. every event carries both key (the logical
key you passed in) and scopedKey (key prefixed by CacheOptions.scope, if any).
| event | when |
|---|---|
hit | get/peek/has/wrap found a fresh value |
miss | nothing live was cached for the key |
stale | a wrap call served a stale value |
revalidate | a background stale refresh wrote a fresh value |
dedupe | a call caught a fetch or revalidation already in flight |
negative-hit | a call hit an errorTtl tombstone |
store | a value was written (includes the effective ttl) |
expire | an expired entry was dropped (lazily, or by the active sweep) |
delete | an entry was removed, via delete/clear/invalidatePrefix |
error | a background stale revalidation's fn rejected |
memory
the default MemoryStorage is LRU-capped at max entries (1000 by default) and actively swept every sweepIntervalMs (60_000 by
default) — both close the leak you'd otherwise get from an unbounded key pattern like chat:{id} across a large audience, where most keys are written once and
never read again to trigger lazy eviction. pass your own storage and you own its
lifecycle — max is ignored, and the sweep is off unless you set sweepIntervalMs explicitly. call .dispose() on the cache to stop an
active sweep, e.g. in a test or a serverless handler that shouldn't keep the process alive.
api
| export | signature | description |
|---|---|---|
cache | <S>(source?: CacheOptions | Cache<S>) => CachePlugin<S> | installs ctx.cache; the returned function also carries .handle |
createCache | <S>(options?: CacheOptions) => Cache<S> | standalone client, independent of any bot or ctx; S is the optional typed key catalog |
CacheControl interface (ctx.cache and the standalone client)
| method | returns | description |
|---|---|---|
get<T>(key) | Promise<T | undefined> | cached value, or undefined on a miss / expired entry |
peek<T>(key) | Promise<{ value: T } | undefined> | like get, but distinguishes a cached undefined from a miss |
set<T>(key, value, ttl?) | Promise<void> | write a value; ttl is a number or { ttl, sliding } |
delete(key) | Promise<void> | drop one entry |
has(key) | Promise<boolean> | whether a live entry exists, without reading it |
wrap<T>(key, fn, options?) | Promise<T> | cached value, or call fn; options is a ttl number or { ttl, staleTtl, errorTtl, sliding } — dedupes concurrent misses |
getMany(keys) | Promise<Map<string, T>> | batched get — only live keys are in the result |
setMany(entries) | Promise<void> | batched set, run concurrently |
clear() | Promise<void> | drop every entry under this cache's scope |
invalidatePrefix(prefix) | Promise<void> | drop every key starting with prefix |
namespace(prefix) | CacheControl | a view over this cache with every key prefixed |
forChat(chatId) | CacheControl | sugar for namespace(chat:<id>:) |
CacheOptions
| field | type | default | description |
|---|---|---|---|
storage | StorageAdapter<unknown> | bounded MemoryStorage | where entries live — any @yaebal/sklad adapter |
ttl | number | — | default ttl in ms for entries that don't pass their own; omit for "never expires" |
scope | string | — | key namespace — set when several bots share one persistent storage |
sliding | boolean | false | default for sliding on calls that don't pass their own |
max | number | 1000 | LRU cap on the default in-memory store; ignored when storage is given |
sweepIntervalMs | number | false | 60_000 | active ttl sweep for the default store; false disables it |
now | () => number | Date.now | clock override, mainly for tests |
onEvent | (event: CacheEvent) => unknown | — | observe hits / misses / stores / stale / dedupe / etc. |
testing
@yaebal/cache doesn't touch ctx beyond the decorated object, so drive
it with @yaebal/test as usual and assert on how
many times your wrapped function actually ran.
import { createTestEnv } from "@yaebal/test";
import { cache } from "@yaebal/cache";
let calls = 0;
const bot = new Composer<Context>()
.install(cache())
.command("info", async (ctx) => {
const info = await ctx.cache.wrap("chat-info", async () => {
calls++;
return "info";
});
return ctx.reply(info);
});
const env = createTestEnv(bot);
await env.createUser().sendCommand("info");
await env.createUser().sendCommand("info");
// calls === 1 — the second update hit the cacheMemoryStorage for redisStorage/sqliteStorage to share
a cache across processes or survive restarts — @yaebal/cache layers ttl, dedup,
stale-while-revalidate, and invalidation on top, so the same options work no matter which
adapter is behind it.