Cache plugin
Introduction
Section titled “Introduction”@warden/cache adds a caching layer to your Warden bot with a clean, injectable API and swappable storage backends. It ships with three built-in adapters — Redis, in-memory, and database — so you can pick the right backend for your environment without changing any application code.
Caching is invaluable for any bot with repeated data access. Guild settings that rarely change, user warning counts that get checked on every message, case lookups that moderators run repeatedly — all of these benefit enormously from a fast cache layer instead of hitting the database on every request.
Installation
Section titled “Installation”pnpm add @warden/cacheIf you’re using the Redis adapter (the default), you’ll also need a Redis server running. The scaffolder’s docker-compose.yml includes one, or you can use any Redis-compatible service.
The peer dependencies are:
| Package | Description |
|---|---|
@warden/core | Warden framework (you already have this) |
The Redis client is bundled with the package, so there’s nothing extra to install for the default adapter.
Register CacheServiceProvider in your bootstrap file:
import {Bot} from '@warden/core';import {CacheServiceProvider} from '@warden/cache';
new Bot() .discover(d => d .interactions('./src/{commands,buttons,modals,menus}/**/*.ts') .events('./src/events/**/*.ts')) .intents(i => i.defaults()) .partials(p => p.defaults()) .plugins([CacheServiceProvider]) .start();With no config/cache.ts, the plugin defaults to a lazy Redis adapter reading REDIS_URL from the environment:
REDIS_URL=redis://127.0.0.1:6379To customise anything — pick a different adapter, pass an explicit URL — write a config/cache.ts:
import {Config, env} from '@warden/core';import {CacheServiceProvider, LazyRedisAdapter} from '@warden/cache';
export default Config.define(CacheServiceProvider, { adapter: new LazyRedisAdapter({url: env.required('REDIS_URL')}),});The plugin connects to Redis lazily (on first use) and disconnects during shutdown().
Swapping adapters
Section titled “Swapping adapters”Pick a different adapter in config/cache.ts to swap storage backends without changing any application code:
The default. Production-grade, persistent across restarts, shared across instances.
import {Config, env} from '@warden/core';import {CacheServiceProvider, LazyRedisAdapter} from '@warden/cache';
export default Config.define(CacheServiceProvider, { adapter: new LazyRedisAdapter({url: env.required('REDIS_URL')}),});Zero external dependencies. Ideal for development, testing, and single-instance bots that don’t need persistence.
import {Config} from '@warden/core';import {CacheServiceProvider, MemoryAdapter} from '@warden/cache';
export default Config.define(CacheServiceProvider, { adapter: new MemoryAdapter(),});Reuses your drizzle connection from @warden/drizzle. No extra services to run — if you already have a database, you have a cache. Slower than Redis for high-frequency reads, but perfectly fine for cooldowns and occasional remember() calls.
Database storage is enabled via database: true on the top-level config (not via a hand-constructed adapter). When set, CacheServiceProvider.boot() resolves the drizzle dialect, loads the matching schema (MySQL or SQLite), and wires the database-backed adapter automatically.
import {Config} from '@warden/core';import {CacheServiceProvider, LazyRedisAdapter} from '@warden/cache';
export default Config.define(CacheServiceProvider, { adapter: new LazyRedisAdapter(), // ignored when database: true database: true,});Before flipping database: true, make sure the warden_cache
table exists. warden make:migration auto-includes
@warden/cache’s schema, so one run covers both your app tables
and the cache:
pnpm warden make:migration add_cachepnpm warden db:migrateThe starter ships with an initial migration that already includes the cache table.
| Adapter | Persistent | Shared across instances | External dependency |
|---|---|---|---|
LazyRedisAdapter | Yes | Yes | Redis server |
MemoryAdapter | No | No | None |
database: true (drizzle-backed) | Yes | Yes | @warden/drizzle + applied migration |
Using the CacheService
Section titled “Using the CacheService”Inject CacheService into any command, event handler, or service class. The API is intentionally small and straightforward:
import {CacheService} from '@warden/cache';
// Store a value (TTL in seconds)await cache.set('guild:123:settings', settings, 300);
// Retrieve a valueconst settings = await cache.get('guild:123:settings');
// Check if a key existsconst exists = await cache.has('guild:123:settings');
// Remove a keyawait cache.forget('guild:123:settings');
// Get or set — returns cached value if it exists, otherwise calls the// callback, caches the result, and returns itconst settings = await cache.remember('guild:123:settings', 300, async () => { return await this.settingsService.fetchFromDatabase('123');});Here’s the full method reference:
| Method | Signature | Description |
|---|---|---|
get | get<T>(key: string): Promise<T | null> | Retrieve a cached value, or null if not found |
set | set<T>(key: string, value: T, ttl?: number): Promise<void> | Store a value with an optional TTL in seconds |
has | has(key: string): Promise<boolean> | Check whether a key exists in the cache |
forget | forget(key: string): Promise<void> | Remove a key from the cache |
remember | remember<T>(key: string, ttl: number, cb: () => Promise<T>): Promise<T> | Get the cached value or execute the callback, cache the result, and return it |
The remember() method
Section titled “The remember() method”The remember() method deserves special attention because it’s by far the most useful method for day-to-day work. It implements the “cache-aside” pattern in a single call: check the cache, return if hit, otherwise fetch from the source, cache it, and return.
@command({parent: 'mod', name: 'warn', description: 'Warn a user'})export default class ModWarnCommand extends Subcommand { constructor(private cache: CacheService, private caseService: CaseService) {}
public async execute(interaction: CommandInteraction, params: Params): Promise<void> { const target = interaction.options.getUser('user')!;
// Only hits the database if the count isn't cached const warningCount = await this.cache.remember( `warnings:${interaction.guildId}:${target.id}`, 60, // cache for 1 minute () => this.caseService.getWarningCount(interaction.guildId!, target.id), );
// ... check threshold, issue warning, etc. }}Of course, when you create a new warning, you’ll want to invalidate the cache so the count is fresh:
await this.caseService.createWarning(guildId, targetId, reason, moderatorId);await this.cache.forget(`warnings:${guildId}:${targetId}`);Serialization
Section titled “Serialization”The cache plugin uses V8 serialization (v8.serialize() / v8.deserialize()) to store values. This means you can cache more than just strings and numbers — objects, arrays, dates, Maps, Sets, and other structured data all work out of the box:
// All of these workawait cache.set('simple', 'hello');await cache.set('number', 42);await cache.set('object', {guildId: '123', logChannel: '456', maxWarnings: 3});await cache.set('date', new Date());await cache.set('array', [{id: 1, type: 'warning'}, {id: 2, type: 'mute'}]);V8 serialization is significantly faster than JSON.stringify() / JSON.parse() for complex objects, and it supports types that JSON doesn’t (like Date, Map, Set, RegExp, Buffer, and undefined).
Cooldown persistence
Section titled “Cooldown persistence”One of the nicest features of the cache plugin is that it integrates transparently with Warden’s cooldown system. When @warden/cache is installed and registered, the @cooldown() decorator automatically switches from in-memory storage to Redis-backed storage.
This means:
- Cooldowns survive bot restarts. If a user hit the
/warncooldown and you restart the bot, they’re still on cooldown when it comes back up. - Cooldowns work across instances. If you’re running multiple bot processes behind a gateway, cooldown state is shared through Redis.
You don’t need to change any of your @cooldown() decorators — the framework detects the cache plugin and uses it automatically. Your existing cooldown code works identically with either backend; the only difference is persistence.
// This works with in-memory storage AND Redis — no code changes needed@command({name: 'warn', description: 'Warn a user'})@cooldown({duration: 10, scope: 'user'})export default class WarnCommand implements Command { // ...}Configuration
Section titled “Configuration”The cache plugin reads from the cache config namespace:
import {env} from '@warden/core';
export default { host: env('REDIS_HOST', '127.0.0.1'), port: env.number('REDIS_PORT', 6379), password: env('REDIS_PASSWORD'), prefix: env('CACHE_PREFIX', 'modbot:'), db: env.number('REDIS_DB', 0),};The prefix option is particularly useful if you’re sharing a Redis instance across multiple bots or applications. Every key the cache plugin writes is prefixed with this value, so cache.set('foo', 'bar') actually stores modbot:foo in Redis.
Practical examples
Section titled “Practical examples”Caching guild settings
Section titled “Caching guild settings”Guild settings rarely change but are read on almost every interaction. A perfect candidate for caching:
import {singleton} from 'tsyringe';
@singleton()export class SettingsService { constructor(private cache: CacheService, private db: DrizzleService) {}
async getGuildSettings(guildId: string) { return this.cache.remember(`guild:${guildId}:settings`, 300, async () => { return this.db.query .select() .from(guildSettings) .where(eq(guildSettings.guildId, guildId)) .then(rows => rows[0] ?? this.getDefaults()); }); }
async updateGuildSettings(guildId: string, data: Partial<GuildSettings>) { await this.db.query .update(guildSettings) .set(data) .where(eq(guildSettings.guildId, guildId));
// Invalidate the cache so the next read picks up the changes await this.cache.forget(`guild:${guildId}:settings`); }
private getDefaults(): GuildSettings { return {logChannel: null, muteRole: null, maxWarnings: 3}; }}Caching case lookups
Section titled “Caching case lookups”When a moderator runs /mod history, you can cache the result so repeated lookups are instant:
@command({parent: 'mod', name: 'history', description: 'View moderation history'})export default class ModHistoryCommand extends Subcommand { constructor(private cache: CacheService, private caseService: CaseService) {}
public async execute(interaction: CommandInteraction, params: Params): Promise<void> { const target = interaction.options.getUser('user')!;
const modCases = await this.cache.remember( `cases:${interaction.guildId}:${target.id}`, 120, // 2 minutes () => this.caseService.getHistory(interaction.guildId!, target.id), );
// ... format and display cases }}