@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

terminal
pnpm add @yaebal/cache

usage

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.

bot.ts
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.ts
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.

bot.ts
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.

standalone.ts
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.

dedup.ts
// 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.

bot.ts
// 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.

bot.ts
// "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.

bot.ts
// 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.

bot.ts
await ctx.cache.invalidatePrefix("chat:"); // drop every "chat:*" key
await ctx.cache.clear(); // drop everything under this cache's scope

batch

bot.ts
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 keys

namespaces

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.

bot.ts
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 entries

typed 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.

bot.ts
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).

eventwhen
hitget/peek/has/wrap found a fresh value
missnothing live was cached for the key
stalea wrap call served a stale value
revalidatea background stale refresh wrote a fresh value
dedupea call caught a fetch or revalidation already in flight
negative-hita call hit an errorTtl tombstone
storea value was written (includes the effective ttl)
expirean expired entry was dropped (lazily, or by the active sweep)
deletean entry was removed, via delete/clear/invalidatePrefix
errora 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

exportsignaturedescription
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)

methodreturnsdescription
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)CacheControla view over this cache with every key prefixed
forChat(chatId)CacheControlsugar for namespace(chat:<id>:)

CacheOptions

fieldtypedefaultdescription
storageStorageAdapter<unknown>bounded MemoryStoragewhere entries live — any @yaebal/sklad adapter
ttlnumberdefault ttl in ms for entries that don't pass their own; omit for "never expires"
scopestringkey namespace — set when several bots share one persistent storage
slidingbooleanfalsedefault for sliding on calls that don't pass their own
maxnumber1000LRU cap on the default in-memory store; ignored when storage is given
sweepIntervalMsnumber | false60_000active ttl sweep for the default store; false disables it
now() => numberDate.nowclock override, mainly for tests
onEvent(event: CacheEvent) => unknownobserve 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.

cache.test.ts
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 cache
pairs with sklad. swap MemoryStorage 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.