@yaebal/session
typed session state: loaded before your handlers, persisted after — but only when it actually changed. per-chat by default, per-anything via key strategies, with lazy loading, multiple independent sessions, schema migrations and self-expiring fields.
install
pnpm add @yaebal/sessionusage
session() is a plugin — pass it to .install() on a bot or composer. initial is required, so ctx.session is always S, never S | undefined; the context type is augmented automatically, no declaration merging.
import { createBot, session } from "yaebal";
type Session = { count: number };
const bot = createBot(process.env.BOT_TOKEN!)
.install(session<Session>({ initial: () => ({ count: 0 }) }));
bot.command("count", async (ctx) => {
ctx.session.count += 1;
await ctx.reply(`count: ${ctx.session.count}`);
});
bot.hears("reset", async (ctx) => {
ctx.session.count = 0;
await ctx.reply("count reset");
});
bot.start();dirty-checked saves
a save is skipped when the state is byte-identical to what was loaded. deep mutations count —
dirtiness is a serialized-snapshot comparison, not a proxy, so there is no class of "mutation
the proxy didn't see" (Map/Set/Date tricks aside: keep
sessions plain json data). untouched fresh sessions are never written at all — lurkers in big
groups don't fill your storage with initial() records. opt out with alwaysSave: true.
when the adapter advertises touch (redis/sqlite/file/memory with ttl),
an unchanged read refreshes the ttl instead — sessions live as long as the chat is active.
storage
defaults to a cloning in-memory store. bring any adapter from @yaebal/sklad — redis, sqlite, cloudflare kv, a json file — or implement the interface yourself:
import { redisStorage } from "@yaebal/sklad";
import { session } from "@yaebal/session";
bot.install(session({
initial: () => ({ count: 0 }),
storage: redisStorage(redis, {
prefix: "session:",
ttl: 24 * 60 * 60_000, // sliding: refreshed on every write and touch
}),
}));import type { StorageAdapter } from "@yaebal/sklad";
// three required methods; `has`/`touch` are optional capabilities
class MyStorage<T> implements StorageAdapter<T> {
async get(key: string): Promise<T | undefined> { /* … */ }
async set(key: string, value: T): Promise<void> { /* … */ }
async delete(key: string): Promise<void> { /* … */ }
}key strategies
getKey decides the storage partition. it may be async, return a plain string, a
composite descriptor, or undefined for "no session on this update".
import { keyBy, session } from "@yaebal/session";
session({ initial, getKey: keyBy.chat }); // default: one session per chat
session({ initial, getKey: keyBy.user }); // per user — also covers inline queries
session({ initial, getKey: keyBy.chatUser }); // per user per chat
session({ initial, getKey: keyBy.chatThread }); // per forum topic
// custom: async, and composite descriptors normalize to stable keys —
// { chat: 42, user: 7 } → "user:7:chat:42"
session({
initial,
getKey: (ctx) => ({ chat: ctx.chat?.id, key: "quiz" }),
});updates that yield no key (a poll update; an inline query under the per-chat
default) get a working throwaway session that is silently dropped. pick a different
behavior with onMissingKey: "skip" leaves the field off the context, "error" throws a SessionError so the gap can't hide.
multiple sessions
give installs distinct key names — each gets its own field, storage and partition,
and the types flow for both. two installs sharing one field fail loud at runtime. (annotate initial's return type instead of passing explicit generics — then both the state
type and the field name infer.)
bot
.install(session({ key: "chatState", initial: () => ({ topic: "" }) }))
.install(session({
key: "userState",
getKey: keyBy.user,
initial: () => ({ visits: 0 }),
}));
bot.on("message", (ctx) => {
ctx.chatState.topic = "pricing"; // typed { topic: string }
ctx.userState.visits++; // typed { visits: number }
});lazy sessions
lazySession defers the storage read until the first await ctx.session — handlers that never touch the session cost zero storage
round-trips, and the final flush is skipped entirely if the session was never loaded.
import { lazySession } from "@yaebal/session";
bot.install(lazySession({ initial: () => ({ count: 0 }) }));
bot.on("message", async (ctx) => {
const session = await ctx.session; // ← the only storage read, and only if you get here
session.count++; // tracked exactly like the eager variant
});clearing and flushing
import { clearSession, saveSession } from "@yaebal/session";
bot.command("reset", async (ctx) => {
await clearSession(ctx); // delete from storage + fresh initial()
});
bot.command("checkout", async (ctx) => {
ctx.session.step = "paying";
await saveSession(ctx); // flush now, before the risky long call
await startPayment(ctx);
});
// multi-session setups pass the field name:
await clearSession(ctx, "userState");import { clearSession, createBot, keyBy, session, ttl, unwrapTtl, type TtlValue } from "yaebal";
type Profile = { visits: number; otp?: TtlValue<string> };
const bot = createBot(process.env.BOT_TOKEN!)
// one session per *user* (covers groups and inline queries alike)
.install(session({
getKey: keyBy.user,
initial: (): Profile => ({ visits: 0 }),
}));
bot.command("me", async (ctx) => {
ctx.session.visits += 1; // unchanged sessions are never written — this one is
await ctx.reply(`visit #${ctx.session.visits}`);
});
bot.command("otp", async (ctx) => {
ctx.session.otp = ttl("1234", 60_000); // self-expiring field
await ctx.reply(`code ${unwrapTtl(ctx.session.otp)} — valid for a minute`);
});
bot.command("reset", async (ctx) => {
await clearSession(ctx); // delete from storage + fresh initial()
await ctx.reply("state wiped");
});
bot.start();self-expiring fields
ttl() wraps a value with its own expiry; expired fields are deleted on the next
load. explicit by design — the envelope is visible in your session type, no proxy magic.
import { ttl, unwrapTtl, type TtlValue } from "@yaebal/session";
interface MySession {
otp?: TtlValue<string>;
}
ctx.session.otp = ttl("1234", 60_000); // valid for a minute
const code = unwrapTtl(ctx.session.otp); // string | undefined
// expired fields are also deleted from storage on the next loadmigrations
change the session shape without wiping old data. migrated records are re-persisted immediately (wrapped in a small version envelope), so each record upgrades exactly once.
session<{ fullName: string; visits: number }>({
initial: () => ({ fullName: "", visits: 0 }),
migrations: {
// versions start at 1 and must be gapless; pre-migration records count as 0
1: (old) => ({ fullName: (old as { name: string }).name }),
2: (v1) => ({ ...(v1 as { fullName: string }), visits: 0 }),
},
});api
| export | signature | description |
|---|---|---|
session | (options: SessionOptions<S, K>) => Plugin<Context, Record<K, S>> | eager session plugin (default field "session") |
lazySession | (options: SessionOptions<S, K>) => Plugin<Context, Record<K, Promise<S>>> | reads storage only on first await ctx.session |
clearSession | (ctx, key?) => Promise<void> | delete from storage + reset to initial() |
saveSession | (ctx, key?) => Promise<void> | flush to storage immediately |
keyBy | chat · user · chatUser · chatThread | ready-made getKey strategies |
ttl / unwrapTtl | ttl(value, ms) / unwrapTtl(wrapped) | self-expiring session fields (TtlValue<T>) |
SessionError | class extends Error | every failure mode of this plugin |
MemoryStorage / StorageAdapter | re-exported from @yaebal/sklad | kept here for compatibility |
SessionOptions<S, K = "session">
| field | type | default | description |
|---|---|---|---|
initial | (ctx: Context) => S | required | fresh state when nothing is stored; receives the context |
storage | StorageAdapter<S> | new MemoryStorage() | any sklad adapter |
getKey | (ctx) => string | SessionKey | undefined (may be async) | keyBy.chat | storage partition per update |
key | K extends string | "session" | the context field — distinct names give independent sessions |
alwaysSave | boolean | false | persist even when nothing changed |
onMissingKey | "throwaway" | "skip" | "error" | "throwaway" | behavior when getKey yields no key |
migrations | Record<number, (data: unknown) => unknown> | — | versioned schema upgrades, gapless from 1 |
error semantics and concurrency
MemoryStorage clones values, so this guarantee holds in dev exactly like
it does with redis (a clone: false store can't provide it). races. long polling processes updates sequentially, and @yaebal/runner sequentializes per chat — matching the default per-chat key. webhook deliveries can run concurrently: updates of one chat may then read-modify-write race, like in every session middleware in every framework — keep handlers short or serialize per key upstream.
testing
pass an explicit MemoryStorage and assert on its contents — storage is the
observable behavior. (core and session themselves test with a hand-built context; everything
downstream can use @yaebal/test actors.)
import { Composer, type Context } from "@yaebal/core";
import { MemoryStorage, session } from "@yaebal/session";
import { createTestEnv } from "@yaebal/test";
const storage = new MemoryStorage<{ count: number }>();
const bot = new Composer<Context>()
.install(session({ initial: () => ({ count: 0 }), storage }))
.command("count", (ctx) => ctx.reply(`#${++ctx.session.count}`));
const env = createTestEnv(bot);
const user = env.createUser();
await user.sendCommand("count");
// assert on storage contents — the observable behavior
assert.equal(storage.get(String(user.chat.id))?.count, 1);related
@yaebal/sklad — the storage adapters behind this plugin · @yaebal/scenes — durable wizards · @yaebal/conversation — linear async dialogs · examples/session — a runnable bot showing every feature on this page.