@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
pnpm add @yaebal/skladusage
construct an adapter, hand it to any plugin that takes storage. prefixes keep
several plugins (or several bots) apart in one shared backend.
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
| adapter | backend | ttl | notes |
|---|---|---|---|
new MemoryStorage(opts?) | in-process map | lazy, per entry | the default everywhere. clone isolation, max lru cap |
redisStorage(client, opts?) | ioredis / node-redis v4+ (RedisLike) | native EXPIRE, sliding via touch | prefix, custom serializer |
sqliteStorage(db, opts?) | node:sqlite / better-sqlite3 (SqliteLike) | lazy, per row | creates 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 entry | atomic tmp+rename writes; one instance owns one path |
memory: ttl, lru, clone
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
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
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
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.
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}`,
};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.