@yaebal/sklad

the storage contract shared by the yaebal ecosystem — @yaebal/session, @yaebal/scenes and friends all persist through one StorageAdapter<T> interface — plus zero-dependency adapters for the usual suspects. every adapter takes an already-constructed client and types it structurally, so sklad depends on nothing and never dictates a driver version.

install

terminal
pnpm add @yaebal/sklad

usage

construct an adapter, hand it to any plugin that takes storage. prefixes keep several plugins (or several bots) apart in one shared backend.

bot.ts
import { session } from "@yaebal/session";
import { scenes } from "@yaebal/scenes";
import { redisStorage } from "@yaebal/sklad";
import Redis from "ioredis";

const redis = new Redis();

bot
  .install(session({
    initial: () => ({ count: 0 }),
    storage: redisStorage(redis, { prefix: "session:" }),
  }))
  .install(scenes(defs, {
    storage: redisStorage(redis, { prefix: "scenes:", ttl: 30 * 60_000 }),
  }));

adapters

adapterbackendttlnotes
new MemoryStorage(opts?)in-process maplazy, per entrythe default everywhere. clone isolation, max lru cap
redisStorage(client, opts?)ioredis / node-redis v4+ (RedisLike)native EXPIRE, sliding via touchprefix, custom serializer
sqliteStorage(db, opts?)node:sqlite / better-sqlite3 (SqliteLike)lazy, per rowcreates its table on first use; synchronous, no event-loop hops
kvStorage(kv, opts?)cloudflare workers kv (KVNamespaceLike)native expirationTtl (60s minimum)per-write expiry — kv has no cheap refresh, so no touch
fileStorage(path, opts?)one json document (@yaebal/sklad/file subpath)lazy, per entryatomic tmp+rename writes; one instance owns one path

memory: ttl, lru, clone

memory.ts
import { MemoryStorage } from "@yaebal/sklad";

// the default everywhere — now with knobs
const cache = new MemoryStorage<Profile>({
  ttl: 10 * 60_000, // expire entries 10 min after the last write/touch
  max: 5_000,       // lru-cap the map
  clone: true,      // default: values are structured-cloned, like a real serializer would
});

sqlite

sqlite.ts
import { DatabaseSync } from "node:sqlite"; // or better-sqlite3
import { sqliteStorage } from "@yaebal/sklad";

const db = new DatabaseSync("bot.db");

bot.install(session({
  initial: () => ({ count: 0 }),
  storage: sqliteStorage(db, { table: "sessions" }),
}));

json file

file.ts
import { fileStorage } from "@yaebal/sklad/file";

// zero-infrastructure persistence for small bots: one json document on disk,
// atomic writes (tmp + rename), one instance per path
bot.install(session({
  initial: () => ({ count: 0 }),
  storage: fileStorage("./data/sessions.json"),
}));

cloudflare kv

worker.ts
import { kvStorage } from "@yaebal/sklad";

// cloudflare workers: pass the kv binding from your env
export default {
  fetch(request, env) {
    const storage = kvStorage(env.BOT_KV, { prefix: "session:" });
    // … webhookCallback(bot) with session({ storage })
  },
};

the contract

get / set / delete are required; has and touch are optional capabilities — touch refreshes a key's ttl without rewriting the value, which callers use for sliding expiry when the adapter advertises it. values round-trip through a Serializer (default JSON), so keep them plain data.

custom-adapter.ts
import type { StorageAdapter } from "@yaebal/sklad";

// anything with get/set/delete is an adapter — has/touch are optional capabilities
const postgres: StorageAdapter<Session> = {
  get: (key) => sql`SELECT value FROM kv WHERE key = ${key}`.then(rowToValue),
  set: (key, value) => sql`INSERT … ON CONFLICT …`,
  delete: (key) => sql`DELETE FROM kv WHERE key = ${key}`,
};
ttl is milliseconds everywhere — adapters convert to their backend's unit (redis seconds, kv expirationTtl) and round up. cloudflare kv enforces a 60-second minimum.

structural clients. RedisLike / SqliteLike / KVNamespaceLike describe only the handful of methods the adapters call, so any compatible client works and sklad ships zero dependencies.

related

@yaebal/session — per-chat state on top of these adapters · @yaebal/scenes — durable wizards whose snapshots live here · deploy targets — which adapter fits which runtime.