Skip to content

@puregram/storage

every plugin in the puregram ecosystem that needs persistent state — @puregram/session, @puregram/scenes, @puregram/flow (persistent flows), @puregram/media-cacher, @puregram/rate-limit — talks to its backing store through the same tiny interface defined here. write your adapter once, plug it into any plugin

when to use

you rarely install this package directly. it comes in transitively through whichever satellite you're using. install it explicitly when you want to:

  • share one storage instance across multiple plugins (session + rate-limit against the same redis)
  • write a custom adapter and need the KVStorage<V> / TtlStorage<V> type imports
  • use enhanceStorage for versioned migrations

install

sh
yarn add @puregram/storage
sh
npm i -S @puregram/storage
sh
pnpm add @puregram/storage

core api

KVStorage<V> interface

the contract every adapter has to satisfy — four required async methods, three optional iterators:

ts
interface KVStorage<V> {
  get (key: string): Promise<V | undefined>
  set (key: string, value: V): Promise<void>
  delete (key: string): Promise<void>
  has (key: string): Promise<boolean>

  // optional — skip when iteration is expensive (cloudflare kv, dynamodb, …)
  keys?: () => AsyncIterable<string>
  values?: () => AsyncIterable<V>
  entries?: () => AsyncIterable<readonly [string, V]>
}

a few intentional design choices:

  • all methods return Promise, even on sync backends. consumers never write await maybeAsync(...) ceremony
  • V is generic at the class level, not per-call — once you type it as KVStorage<{ counter: number }>, you can't accidentally set('k', 'a string')
  • iterators are optional — leave them off when your backend can't iterate cheaply. consumers guard with storage.keys?.() ?? []

TtlStorage<V> interface

extends KVStorage<V> with a touch(key) method for backends that support sliding-window expiry (redis EXPIRE, sqlite last_seen columns):

ts
interface TtlStorage<V> extends KVStorage<V> {
  touch (key: string): Promise<void>
}

@puregram/session auto-detects via isTtlStorage and calls touch after each access to roll the backend timer without rewriting the value

isTtlStorage(storage)

runtime typeguard — narrows KVStorage<V> to TtlStorage<V> when touch is present:

ts
import { isTtlStorage, type KVStorage } from '@puregram/storage'

function maybeTouch (storage: KVStorage<unknown>, key: string) {
  if (isTtlStorage(storage)) {
    return storage.touch(key)
  }
}

MemoryStorage<V>

unbounded in-process kv backed by Map. fast, simple, ephemeral — gone on restart. the default for every satellite that doesn't receive an explicit storage option

ts
import { MemoryStorage } from '@puregram/storage'

const storage = new MemoryStorage<number>()

await storage.set('counter', 1)
console.log(await storage.get('counter'))

seed at construction:

ts
const storage = new MemoryStorage<string>([['a', '1'], ['b', '2']])
console.log(storage.size)

LruMemoryStorage<V>

bounded in-process kv with LRU eviction. keeps at most max entries — when max + 1 is set, the least-recently-used entry is evicted. get and set bump recency; has and delete don't

ts
import { LruMemoryStorage } from '@puregram/storage'

const storage = new LruMemoryStorage<string>({ max: 1_000 })

await storage.set('a', 'hello')
console.log(storage.size)

great for media-cacher (cap remembered file_ids), rate-limit (cap tracked users), or any cache that needs a hard ceiling

enhanceStorage(base, opts)

wraps any KVStorage<V> with versioned migrations and optional per-entry expiry. payloads are stored as { __v, __exp?, data } envelopes — old entries upgrade lazily on next read

ts
import { enhanceStorage, MemoryStorage } from '@puregram/storage'

interface SessionV3 {
  counter: number
  role: 'guest' | 'user'
}

const storage = enhanceStorage<SessionV3>(new MemoryStorage(), {
  migrations: {
    1: d => ({ ...d, role: 'guest' }),
    2: d => ({ ...d, counter: (d as { counter?: number }).counter ?? 0 })
  }
})
optiontypedescription
migrationsRecord<number, (data: unknown) => unknown>keyed by target version. migrations[1] upgrades v0 → v1, migrations[2] runs next to upgrade v1 → v2. the latest key wins as the current version stamped on every set
millisecondPrecisionbooleanwhen true, preserves any __exp (unix ms) carried on the envelope across re-writes

semantics:

  • legacy unversioned values are treated as v0 and migrated forward on first read
  • expired entries (__exp < Date.now()) return undefined and are deleted from the base storage
  • migrated envelopes are written back at the current version — subsequent reads skip the upgrade
  • concurrent reads converge (last write wins)

writing your own adapter

implement KVStorage<V> — no base class, no registration step, just the four methods:

ts
import type { KVStorage } from '@puregram/storage'

export class RedisStorage<V> implements KVStorage<V> {
  constructor (private readonly redis: RedisClient, private readonly prefix = 'pg:') {}

  async get (key: string): Promise<V | undefined> {
    const raw = await this.redis.get(this.prefix + key)

    return raw === null ? undefined : JSON.parse(raw) as V
  }

  async set (key: string, value: V) {
    await this.redis.set(this.prefix + key, JSON.stringify(value))
  }

  async delete (key: string) {
    await this.redis.del(this.prefix + key)
  }

  async has (key: string) {
    return (await this.redis.exists(this.prefix + key)) === 1
  }
}

then plug it in:

ts
const telegram = Telegram.fromToken(TOKEN)
  .extend(session({ storage: new RedisStorage(redis) }))

opting into ttl

if your backend supports expiring keys, implement TtlStorage<V> and add touch:

ts
import type { TtlStorage } from '@puregram/storage'

export class RedisTtlStorage<V> implements TtlStorage<V> {
  // ...the four required methods...

  async touch (key: string) {
    await this.redis.pexpire(this.prefix + key, this.ttlMs)
  }
}

session middleware auto-detects touch via isTtlStorage and calls it after every read — you don't need to wire anything up

adding iterators

implement the three optional iterator methods when your backend can iterate cheaply:

ts
async * keys () {
  for (const k of await this.redis.keys(this.prefix + '*')) {
    yield k.slice(this.prefix.length)
  }
}

consumers guard defensively — for await (const k of storage.keys?.() ?? []) ... — so leaving them off is always safe

bundled adapters

two adapters ship as separate packages. install them alongside @puregram/storage when you need a persistent backend out of the box

@puregram/storage-redis

redis-backed TtlStorage via an ioredis-compatible client. values are JSON-serialised. native ttl uses PX / PEXPIRE

sh
yarn add @puregram/storage-redis ioredis
sh
npm i -S @puregram/storage-redis ioredis
sh
pnpm add @puregram/storage-redis ioredis
ts
import Redis from 'ioredis'
import { RedisStorage } from '@puregram/storage-redis'
import { session } from '@puregram/session'

const redis = new Redis()

const telegram = Telegram.fromToken(TOKEN)
  .extend(session({
    storage: new RedisStorage({ client: redis, ttlMs: 86_400_000 })
  }))

RedisStorage options:

optiontypedefaultdescription
clientRedisLikeClientrequiredioredis (or compatible) client instance
prefixstring'puregram:'namespace prepended to every key
ttlMsnumberundefineddefault ttl in ms applied on every set. touch rolls it forward

@puregram/storage-sqlite

sqlite-backed TtlStorage via a better-sqlite3-compatible database handle. values are JSON-serialised. ttl stored as expires_at integer column; get evicts expired rows lazily; an optional sweep timer batches eviction

sh
yarn add @puregram/storage-sqlite better-sqlite3
sh
npm i -S @puregram/storage-sqlite better-sqlite3
sh
pnpm add @puregram/storage-sqlite better-sqlite3
ts
import Database from 'better-sqlite3'
import { SqliteStorage } from '@puregram/storage-sqlite'
import { session } from '@puregram/session'

const db = new Database('bot.db')

const telegram = Telegram.fromToken(TOKEN)
  .extend(session({
    storage: new SqliteStorage({ db, ttlMs: 86_400_000, sweepIntervalMs: 60_000 })
  }))

SqliteStorage options:

optiontypedefaultdescription
dbSqliteLikeDatabaserequiredbetter-sqlite3 (or compatible) database handle
tablestring'puregram_kv'table name. created via CREATE TABLE IF NOT EXISTS on construction
ttlMsnumberundefineddefault ttl in ms. stored in expires_at column
sweepIntervalMsnumberundefinedbackground sweep interval in ms. when set, periodically deletes expired rows. omit to rely on lazy per-read eviction only

call storage.close() when shutting down gracefully to stop the sweep timer

which plugins consume storage

pluginV typenotes
@puregram/sessionunknownuser-shaped payload, cast by the user
@puregram/flow (persistent flows)depends on payload typesee the flow/persistent-flows page
@puregram/media-cacherstringstores file_id strings
@puregram/rate-limitinternal entry type

exported types

ts
import type {
  KVStorage,
  TtlStorage,
  LruMemoryStorageOptions,
  EnhanceStorageOptions
} from '@puregram/storage'

see also