@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

terminal
pnpm add @yaebal/session

usage

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.

session.ts
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:

redis.ts
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
  }),
}));
custom-storage.ts
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".

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

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

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

control.ts
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");
session-v2.ts
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.

ttl.ts
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 load

migrations

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.

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

exportsignaturedescription
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
keyBychat · user · chatUser · chatThreadready-made getKey strategies
ttl / unwrapTtlttl(value, ms) / unwrapTtl(wrapped)self-expiring session fields (TtlValue<T>)
SessionErrorclass extends Errorevery failure mode of this plugin
MemoryStorage / StorageAdapterre-exported from @yaebal/skladkept here for compatibility

SessionOptions<S, K = "session">

fieldtypedefaultdescription
initial(ctx: Context) => Srequiredfresh state when nothing is stored; receives the context
storageStorageAdapter<S>new MemoryStorage()any sklad adapter
getKey(ctx) => string | SessionKey | undefined (may be async)keyBy.chatstorage partition per update
keyK extends string"session"the context field — distinct names give independent sessions
alwaysSavebooleanfalsepersist even when nothing changed
onMissingKey"throwaway" | "skip" | "error""throwaway"behavior when getKey yields no key
migrationsRecord<number, (data: unknown) => unknown>versioned schema upgrades, gapless from 1

error semantics and concurrency

a throwing handler skips the save — half-applied state is not persisted. the default 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.)

session.test.ts
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.