You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This is a companion to the Gateway RFC (#54), split out on its own because the caching design deserves independent review: wolfstar-project/stars-components#51 specifically asks for a package with "local and Redis" storage support and "compression, be it with an algorithm or Redis", and that's a large enough surface to deserve its own discussion.
The Gateway RFC's GatewayClient doesn't hand raw API JSON to event listeners — it hands Structure instances (Message, User, Guild, …), built by per-entity Managers via createStructure, following the pattern discordjs/discord.js#10983 is prototyping upstream. That split raises the exact question that PR's reviewers debated and left unresolved: should the cache hold raw API data, or fully-built Structure instances? This RFC takes an explicit position: the Cache holds only raw, JSON-serializable data. Structure construction happens above the cache, in the Managers. Reasoning:
Structures wrap raw data behind symbols (kData, kPatch, …) and aren't trivially JSON.stringify-able in a way that round-trips through Redis.
A Redis-backed store needs to serialize/deserialize/compress values regardless — that's a much simpler contract against plain objects than against live class instances.
Managers already need a createStructure(raw) step to build the initial instance from a Gateway payload; reusing that same step to build it from a cache hit costs nothing extra and keeps the cache genuinely storage-agnostic.
Interface-wise this still closely follows discordjs/discord.js#11426 and the storage swap already prototyped in suneettipirneni's add-cache-package branch (@discordjs/cache), which nails the "swap the store, keep the call sites" ergonomics stars-components#51 is asking for:
import{GatewayDispatchEvents}from'discord-api-types/v10';import{Client}from'@discordjs/core';import{WebSocketShardEvents}from'@discordjs/ws';importtype{Cache}from'@discordjs/cache';import{createInMemoryCache}from'@discordjs/cache/inmemory';constclient=newClient({ rest, gateway });constcache: Cache=createInMemoryCache({mappers: {// Only store a subset of fields we care about. Omit to store the full API object.guild: ({ raw })=>({id: raw.id,name: 'name'inraw ? raw.name : raw.id}),},});gateway.on(WebSocketShardEvents.Dispatch,(payload,shardId)=>{cache.handleGatewayDispatch(payload,shardId);});client.on(GatewayDispatchEvents.InteractionCreate,async({ api, data })=>{if(!data.guild_id)return;// Type auto-inferred to `{ id: string, name: string }`constguild=cache.guilds.get(data.guild_id);awaitapi.interactions.reply(data.id,data.token,{content: `Cached guild name: ${guild?.name??'unknown'}`,});});
The proposal here is @wolfstar/plugin-cache: the same per-entity, swappable-store shape, but plugged into @wolfstar/plugin-gateway's CachedManager/createStructure pattern so what comes back out is always a Structure, not a narrowed plain object.
Design principles
Async-first. Every method returns Awaitable<T> (T | Promise<T>), so an in-memory Map-backed store and an ioredis-backed store implement the exact same interface without either one faking synchronicity it doesn't have.
Raw in, Structures out. The Cache only ever stores and returns raw API shapes. Turning that into a User/Guild/Message is always the calling Manager's job via createStructure, never the cache's.
Map-like, not bespoke.get/set/has/delete/clear mirror the built-in Map API on purpose, so the learning curve is close to zero.
Per-entity sub-caches instead of ad-hoc mappers.@discordjs/cache's mappers option is a clever way to narrow what's stored, but it fights the Manager/createStructure split above — the equivalent narrowing now belongs on each Structure's DataTemplate/optimizeData() (from @discordjs/structures), not on the cache. The Cache interface itself stays a plain, typed, per-entity key-value store.
Storage-agnostic, compression as a store concern. Compression only makes sense for a store that pays a serialization/network cost (Redis), not for an in-memory Map. It's a Redis-store option, not a property of the shared interface.
GatewayClient (see the Gateway RFC) is the only thing that calls into Cache directly — on every dispatch it writes the raw payload into the matching EntityCache, then hands that same raw payload to the Manager's createStructure before emitting the built Structure on its event bus. Reads go the other direction: UserManager#get(id) reads cache.users.get(id) and, on a hit, wraps it with createStructure before returning it — the cache itself never sees a Structure.
Problems
Same open question as the original discord.js RFC: do we need keys() / values() / entries() for iteration on EntityCache, and if so do they return AsyncIterable unconditionally (even for the in-memory store, for interface consistency) or Iterable | AsyncIterable depending on the backing store?
Per-entity TTL: Discord doesn't tell us when a guild/user is "stale," so is expiry purely LRU/size-bound (in-memory) or does the Redis store get a configurable TTL per entity type? Needed for compression to actually save memory rather than just deferring the problem.
Deletes: GUILD_DELETE/CHANNEL_DELETE/etc. need to reach every relevant EntityCache (e.g. a guild delete should probably also drop that guild's channels from the channel cache). Does that cascade live in GatewayClient's dispatch handling, or does Cache itself need entity-relationship awareness? Leaning towards the former to keep Cache genuinely dumb, but flagging it as a real design question.
DataTemplate/optimizeData() narrowing (point 4 above) is per-Structure, decided at compile time by whoever writes the Structure subclass — unlike @discordjs/cache's runtime mappers option, a consumer can't narrow further without subclassing. Is that an acceptable trade-off for the simpler Cache contract, or do we need a lighter, per-GatewayClient-instance override too?
Store backends
exportinterfaceInMemoryCacheOptions{maxSize?: number;}exportfunctioncreateInMemoryCache(options?: InMemoryCacheOptions): Cache;exportinterfaceRedisCacheOptions{/** An existing ioredis-compatible client, or connection options to create one. */redis: Redis|RedisOptions;/** Compress values before writing to Redis and decompress on read. Off by default. */compression?: 'gzip'|'none';/** Per-entity TTL, in seconds. */ttl?: Partial<Record<'guilds'|'users'|'channels'|'messages',number>>;}exportfunctioncreateRedisCache(options: RedisCacheOptions): Cache;
createInMemoryCache mirrors @discordjs/cache's implementation directly, minus the mappers option (superseded by Structure-level DataTemplate/optimizeData(), per the design principles above). createRedisCache is the new piece stars-components#51 is actually asking for: same Cache contract, backed by ioredis, with compression: 'gzip' as an opt-in for larger cached payloads (guild/member objects) where the network/memory savings are worth the CPU cost — left off by default.
message and guild above are Structure instances built by GatewayClient's Managers; client.guilds.get() never returns raw Redis bytes directly. Swapping createRedisCache for createInMemoryCache changes nothing else in this snippet, which is the entire point of the shared Cache interface.
Alternatives considered
Keep @discordjs/cache's mappers option, cache narrowed plain objects directly. This is what an earlier revision of this RFC proposed. Rejected in favor of the Cache-stores-raw / Structure-narrows-via-DataTemplate split once the Gateway RFC settled on emitting Structure instances from events — keeping both a "narrowed plain object" shape and a Structure shape alive at once would mean two competing representations of the same cached entity.
Cache Structure instances directly, matching what event listeners receive. Rejected: Structures carry non-serializable internals (symbol-keyed data, patch/clone methods) that don't map cleanly onto a Redis value, and doing so would make the in-memory and Redis stores behave subtly differently (one holding live references, the other needing constant serialize/deserialize) — exactly the ambiguity discord.js#10983's reviewers flagged and didn't resolve. Keeping Cache raw-only sidesteps it.
Depend on @discordjs/cache directly instead of building @wolfstar/plugin-cache. Simpler short-term, but ties @wolfstar/plugin-gateway to a package that's still an active RFC upstream (RFC: Async Caching Layer & Simpler Manager Pattern for @discordjs/next discordjs/discord.js#11426 isn't merged) and gives us no room to add the Redis store or the Structure-aware Manager integration this proposal needs.
No abstraction, Redis-only. Rejected — stars-components#51 explicitly asks for both local and Redis storage, and a lot of self-hosted wolfstar deployments won't want to run Redis just to cache a handful of guild objects.
Addendum: verified contributor-branch starting point (September 2026)
The add-cache-package branch contains a concrete packages/cache implementation. Its README demonstrates memory and Redis factories, per-entity stores, custom mappers and handleGatewayDispatch. types.ts defines separate synchronous CacheStore/Cache and asynchronous AsyncCacheStore/AsyncCache interfaces: the branch does not yet implement this RFC's single Awaitable interface.
Reusable pieces include typed per-entity stores, composite key helpers, dispatch-operation mapping, mapper context (event, existing, raw and shardId), and memory/Redis tests. Adapt these while retaining raw storage and Manager-owned Structure construction, consistent with discord.js#11426 review. Reconsider the claim above that Structure DataTemplate fully replaces runtime mappers: the branch allows field selection per cache instance, whereas a DataTemplate is defined by the Structure author.
Gaps before borrowing the Redis path: RedisStore stores JSON and a separate set of keys for enumeration, but its set call has no TTL or compression. Clear and prefix deletion enumerate entries; TTL would also require preventing stale index growth. attachCacheToGateway discards the async result with void; #54 needs awaited, ordered dispatch and observable errors. Compression, bounded memory/TTL and a no-op backend remain wolfstar work items.
Revised decisions
Keep get/set/has/delete as the minimal Awaitable storage operations. Be map-like without claiming to implement Map: upstream found return-type conflicts. Make clear, getSize and iteration optional capabilities or document their potentially expensive Redis behavior. The interface above remains illustrative pending this decision.
Define Manager-owned partial merge and construction so update events preserve fields and expose independent old/new Structures. Do not promise live reference mutation across memory and Redis. Upstream identity question.
Distinguish cache miss from cache failure. A miss returns undefined; Redis outage, parse and decompression errors must be observable. Choose a documented Gateway and Manager policy, such as configurable uncached fallback versus hard error. Upstream error-handling discussion.
Specify composite keys, nested references, deletion cascades and reconnect reconciliation for members, voice states, reactions and message authors. See upstream discussion and stale guild report.
Benchmark small and large values before choosing gzip or TTL defaults; specify serialization version, Redis namespace and malformed-value handling.
Acceptance checks
Equivalent Manager behavior with no-op, memory and Redis stores; REST-capable fetch works after a miss.
Tests for partial update, composite keys, old/new snapshots, deletion, nested relationships and reconnect.
TTL does not leave an ever-growing Redis key index; clear/size behavior and compression trade-offs are documented.
discord.js#11556 separately discusses iteration, partial events and backpressure; it is a proposal, not an accepted resolution of #11426.
Status and roadmap (updated 2026-09-24, after #104)
Implemented in #92 as @wolfstar/plugin-cache; #93 (#54) builds on it. The RFC text above is kept as written; this section records the decisions made while building it and what is still open. Remaining phases are meant to land as small or medium PRs, numbered like #54's roadmap so both issues stay in step.
Interface:EntityCache<Raw> has get / set / has / delete / clear / getSize plus keys / values / entries, every method Awaitable. The last three return array snapshots rather than AsyncIterable: simpler to implement for both stores, but a large Redis entity cache gets fully materialised. This goes further than the addendum (optional clear / getSize / iteration), so it is up for revision in phase 4.
Cache: 23 entity caches, the ones @discordjs/cache has, minus interactions, which are never cached.
Cascades stay outside the cache.applyGatewayDispatch(cache, payload) (on top of createCacheOperations, adapted from the add-cache-package branch) turns a dispatch into upserts, merges and deletes. Any custom Cache gets the cascades for free: CHANNEL_DELETE drops its messages, GUILD_DELETE drops everything of the guild, an unavailable guild delete keeps the data.
Reactions and poll votes are counted into the cached message by a new update operation (read, transform, write; nothing is written for an uncached message). Their dispatches only carry the voter's ID, so createCacheOperations / applyGatewayDispatch take an optional { clientUserId } context for the me and me_voted flags (feat: count reactions and poll votes in the cache and emit their gateway events #104).
Composite keys: helpers such as memberKey, messageKey, roleKey, voiceStateKey and threadMemberKey are exported.
Memory store:createInMemoryCache({ maxSize }) is an LRU, bounded globally or per entity. No TTL.
Redis store:createRedisCache({ redis, prefix, compression, compressionThreshold, ttl }) takes a client instance (RedisClientLike, which ioredisRedis and Cluster satisfy, checked by a type test), not RedisOptions, so ioredis is not a dependency. Values are JSON, optionally gzip or brotli above a size threshold. Compressed values carry a tag, so switching compression on or off never breaks reading older values. A sorted set per entity cache indexes keys with their expiry as score.
[~] Value and index are written and deleted in one MULTI, and with a ttl every write prunes expired index entries, so the index stays bounded without reads (feat: make plugin-cache Redis writes atomic and reads fail loudly #94). Still no benchmarks behind the 1024 byte compression threshold, and no serialization version tag.
Per-instance field selection (the branch's runtime mappers) is not there. Structures now extend @discordjs/structures' Structure, so DataTemplate narrowing is available per structure class, but not per cache instance.
Introduction
This is a companion to the Gateway RFC (#54), split out on its own because the caching design deserves independent review: wolfstar-project/stars-components#51 specifically asks for a package with "local and Redis" storage support and "compression, be it with an algorithm or Redis", and that's a large enough surface to deserve its own discussion.
The Gateway RFC's
GatewayClientdoesn't hand raw API JSON to event listeners — it handsStructureinstances (Message,User,Guild, …), built by per-entityManagers viacreateStructure, following the pattern discordjs/discord.js#10983 is prototyping upstream. That split raises the exact question that PR's reviewers debated and left unresolved: should the cache hold raw API data, or fully-builtStructureinstances? This RFC takes an explicit position: theCacheholds only raw, JSON-serializable data.Structureconstruction happens above the cache, in the Managers. Reasoning:kData,kPatch, …) and aren't triviallyJSON.stringify-able in a way that round-trips through Redis.createStructure(raw)step to build the initial instance from a Gateway payload; reusing that same step to build it from a cache hit costs nothing extra and keeps the cache genuinely storage-agnostic.Interface-wise this still closely follows discordjs/discord.js#11426 and the storage swap already prototyped in suneettipirneni's
add-cache-packagebranch (@discordjs/cache), which nails the "swap the store, keep the call sites" ergonomics stars-components#51 is asking for:The proposal here is
@wolfstar/plugin-cache: the same per-entity, swappable-store shape, but plugged into@wolfstar/plugin-gateway'sCachedManager/createStructurepattern so what comes back out is always aStructure, not a narrowed plain object.Design principles
Awaitable<T>(T | Promise<T>), so an in-memoryMap-backed store and anioredis-backed store implement the exact same interface without either one faking synchronicity it doesn't have.Cacheonly ever stores and returns raw API shapes. Turning that into aUser/Guild/Messageis always the calling Manager's job viacreateStructure, never the cache's.get/set/has/delete/clearmirror the built-inMapAPI on purpose, so the learning curve is close to zero.@discordjs/cache'smappersoption is a clever way to narrow what's stored, but it fights the Manager/createStructuresplit above — the equivalent narrowing now belongs on eachStructure'sDataTemplate/optimizeData()(from@discordjs/structures), not on the cache. TheCacheinterface itself stays a plain, typed, per-entity key-value store.Map. It's a Redis-store option, not a property of the shared interface.The Cache interface
GatewayClient(see the Gateway RFC) is the only thing that calls intoCachedirectly — on every dispatch it writes the raw payload into the matchingEntityCache, then hands that same raw payload to the Manager'screateStructurebefore emitting the builtStructureon its event bus. Reads go the other direction:UserManager#get(id)readscache.users.get(id)and, on a hit, wraps it withcreateStructurebefore returning it — the cache itself never sees aStructure.Problems
keys()/values()/entries()for iteration onEntityCache, and if so do they returnAsyncIterableunconditionally (even for the in-memory store, for interface consistency) orIterable | AsyncIterabledepending on the backing store?GUILD_DELETE/CHANNEL_DELETE/etc. need to reach every relevantEntityCache(e.g. a guild delete should probably also drop that guild's channels from the channel cache). Does that cascade live inGatewayClient's dispatch handling, or doesCacheitself need entity-relationship awareness? Leaning towards the former to keepCachegenuinely dumb, but flagging it as a real design question.DataTemplate/optimizeData()narrowing (point 4 above) is per-Structure, decided at compile time by whoever writes theStructuresubclass — unlike@discordjs/cache's runtimemappersoption, a consumer can't narrow further without subclassing. Is that an acceptable trade-off for the simplerCachecontract, or do we need a lighter, per-GatewayClient-instance override too?Store backends
createInMemoryCachemirrors@discordjs/cache's implementation directly, minus themappersoption (superseded byStructure-levelDataTemplate/optimizeData(), per the design principles above).createRedisCacheis the new piece stars-components#51 is actually asking for: sameCachecontract, backed byioredis, withcompression: 'gzip'as an opt-in for larger cached payloads (guild/member objects) where the network/memory savings are worth the CPU cost — left off by default.Usage example
messageandguildabove areStructureinstances built byGatewayClient's Managers;client.guilds.get()never returns raw Redis bytes directly. SwappingcreateRedisCacheforcreateInMemoryCachechanges nothing else in this snippet, which is the entire point of the sharedCacheinterface.Alternatives considered
@discordjs/cache'smappersoption, cache narrowed plain objects directly. This is what an earlier revision of this RFC proposed. Rejected in favor of theCache-stores-raw /Structure-narrows-via-DataTemplatesplit once the Gateway RFC settled on emittingStructureinstances from events — keeping both a "narrowed plain object" shape and aStructureshape alive at once would mean two competing representations of the same cached entity.Structureinstances directly, matching what event listeners receive. Rejected:Structures carry non-serializable internals (symbol-keyed data, patch/clone methods) that don't map cleanly onto a Redis value, and doing so would make the in-memory and Redis stores behave subtly differently (one holding live references, the other needing constant serialize/deserialize) — exactly the ambiguity discord.js#10983's reviewers flagged and didn't resolve. KeepingCacheraw-only sidesteps it.@discordjs/cachedirectly instead of building@wolfstar/plugin-cache. Simpler short-term, but ties@wolfstar/plugin-gatewayto a package that's still an active RFC upstream (RFC: Async Caching Layer & Simpler Manager Pattern for@discordjs/nextdiscordjs/discord.js#11426 isn't merged) and gives us no room to add the Redis store or theStructure-aware Manager integration this proposal needs.Footnotes
@discordjs/nextadd-cache-packagebranch (@discordjs/cache)GatewayClient,CachedManager,createStructure): RFC:GatewayClient— Gateway (WebSocket) support for@wolfstar/http-framework#54Addendum: verified contributor-branch starting point (September 2026)
The add-cache-package branch contains a concrete packages/cache implementation. Its README demonstrates memory and Redis factories, per-entity stores, custom mappers and handleGatewayDispatch. types.ts defines separate synchronous CacheStore/Cache and asynchronous AsyncCacheStore/AsyncCache interfaces: the branch does not yet implement this RFC's single Awaitable interface.
Reusable pieces include typed per-entity stores, composite key helpers, dispatch-operation mapping, mapper context (event, existing, raw and shardId), and memory/Redis tests. Adapt these while retaining raw storage and Manager-owned Structure construction, consistent with discord.js#11426 review. Reconsider the claim above that Structure DataTemplate fully replaces runtime mappers: the branch allows field selection per cache instance, whereas a DataTemplate is defined by the Structure author.
Gaps before borrowing the Redis path: RedisStore stores JSON and a separate set of keys for enumeration, but its set call has no TTL or compression. Clear and prefix deletion enumerate entries; TTL would also require preventing stale index growth. attachCacheToGateway discards the async result with void; #54 needs awaited, ordered dispatch and observable errors. Compression, bounded memory/TTL and a no-op backend remain wolfstar work items.
Revised decisions
Acceptance checks
GatewayClient— Gateway (WebSocket) support for@wolfstar/http-framework#54.discord.js#11556 separately discusses iteration, partial events and backpressure; it is a proposal, not an accepted resolution of #11426.
Status and roadmap (updated 2026-09-24, after #104)
Implemented in #92 as
@wolfstar/plugin-cache; #93 (#54) builds on it. The RFC text above is kept as written; this section records the decisions made while building it and what is still open. Remaining phases are meant to land as small or medium PRs, numbered like #54's roadmap so both issues stay in step.Decisions taken
GatewayClient— Gateway (WebSocket) support for@wolfstar/http-framework#54 build the structures.EntityCache<Raw>hasget/set/has/delete/clear/getSizepluskeys/values/entries, every methodAwaitable. The last three return array snapshots rather thanAsyncIterable: simpler to implement for both stores, but a large Redis entity cache gets fully materialised. This goes further than the addendum (optionalclear/getSize/ iteration), so it is up for revision in phase 4.Cache: 23 entity caches, the ones@discordjs/cachehas, minus interactions, which are never cached.applyGatewayDispatch(cache, payload)(on top ofcreateCacheOperations, adapted from theadd-cache-packagebranch) turns a dispatch into upserts, merges and deletes. Any customCachegets the cascades for free:CHANNEL_DELETEdrops its messages,GUILD_DELETEdrops everything of the guild, anunavailableguild delete keeps the data.updateoperation (read, transform, write; nothing is written for an uncached message). Their dispatches only carry the voter's ID, socreateCacheOperations/applyGatewayDispatchtake an optional{ clientUserId }context for themeandme_votedflags (feat: count reactions and poll votes in the cache and emit their gateway events #104).memberKey,messageKey,roleKey,voiceStateKeyandthreadMemberKeyare exported.createInMemoryCache({ maxSize })is an LRU, bounded globally or per entity. No TTL.createRedisCache({ redis, prefix, compression, compressionThreshold, ttl })takes a client instance (RedisClientLike, whichioredisRedisandClustersatisfy, checked by a type test), notRedisOptions, so ioredis is not a dependency. Values are JSON, optionally gzip or brotli above a size threshold. Compressed values carry a tag, so switching compression on or off never breaks reading older values. A sorted set per entity cache indexes keys with their expiry as score.undefined. Redis errors, invalid JSON and bad compressed bytes throw, and RFC:GatewayClient— Gateway (WebSocket) support for@wolfstar/http-framework#54 surfaces them as anerrorevent.Addendum criteria: where we are
CacheValueErrornaming the key; connection errors propagate unwrapped (feat: make plugin-cache Redis writes atomic and reads fail loudly #94). Slow stores are reported by RFC:GatewayClient— Gateway (WebSocket) support for@wolfstar/http-framework#54'sdispatchTimeout, and order is per guild (feat: partition gateway dispatches per guild and harden the pipeline #95).MULTI, and with attlevery write prunes expired index entries, so the index stays bounded without reads (feat: make plugin-cache Redis writes atomic and reads fail loudly #94). Still no benchmarks behind the1024byte compression threshold, and no serialization version tag.@discordjs/structures'Structure, soDataTemplatenarrowing is available per structure class, but not per cache instance.Phases
Cache/EntityCache, dispatch operations with cascades, memory and Redis stores (feat: add @wolfstar/plugin-cache, a storage-agnostic entity cache #92).GatewayClient— Gateway (WebSocket) support for@wolfstar/http-framework#54's phase 4):MULTI, prune on write,CacheValueError, outage/corrupt-value tests, manager-level tests against Redis are in feat: make plugin-cache Redis writes atomic and reads fail loudly #94. Left: a serialization version tag, and deciding whichEntityCachemethods stay mandatory.updateoperation,clientUserIdcontext (feat: count reactions and poll votes in the cache and emit their gateway events #104).AsyncIterableiteration for large stores, per-instance mappers, TTL for the memory store, compression benchmarks to pick defaults.