diff --git a/src/cache/cache.types.ts b/src/cache/cache.types.ts index 4dfa20b..c4f0d7b 100644 --- a/src/cache/cache.types.ts +++ b/src/cache/cache.types.ts @@ -3,6 +3,8 @@ export interface CacheAdapter { set(key: string, value: T, ttl?: number): Promise; delete(key: string): Promise; clear(): Promise; + /** Delete all entries whose key starts with the given prefix. */ + deleteByPrefix?(prefix: string): Promise; } interface CacheEntry { @@ -51,4 +53,12 @@ export class InMemoryCacheAdapter implements CacheAdapter { async clear(): Promise { this.store.clear(); } + + async deleteByPrefix(prefix: string): Promise { + for (const key of this.store.keys()) { + if (key.startsWith(prefix)) { + this.store.delete(key); + } + } + } } diff --git a/src/client/GuildPassClient.ts b/src/client/GuildPassClient.ts index 278936f..aa8eb62 100644 --- a/src/client/GuildPassClient.ts +++ b/src/client/GuildPassClient.ts @@ -130,7 +130,13 @@ export class GuildPassClient { `guilds:getGuild:${guildId}`, `guilds:getGuildConfig:${guildId}`, ]; - await Promise.all(prefixes.map((k) => this.cache!.delete(k))); + // Use deleteByPrefix if the adapter supports it; otherwise fall back to + // exact-key deletion (legacy behaviour that may miss nested entries). + if (this.cache.deleteByPrefix) { + await Promise.all(prefixes.map((p) => this.cache!.deleteByPrefix!(p))); + } else { + await Promise.all(prefixes.map((k) => this.cache!.delete(k))); + } } /** @@ -138,12 +144,16 @@ export class GuildPassClient { * * Useful when a wallet's on-chain state has changed (e.g., token transfer). */ - public async invalidateWalletCache(_walletAddress: string): Promise { + public async invalidateWalletCache(walletAddress: string): Promise { if (!this.cache) return; - // We clear the whole cache since per-address key enumeration requires - // knowing all guilds. Use a custom adapter with key-scanning support if - // finer granularity is needed. - await this.cache.clear(); + // Use deleteByPrefix to remove only wallet-scoped entries instead of + // clearing the entire cache. Falls back to full clear for adapters + // that don't support prefix deletion. + if (this.cache.deleteByPrefix) { + await this.cache.deleteByPrefix(`wallet:${walletAddress}:`); + } else { + await this.cache.clear(); + } } /** Clears the entire cache. */ diff --git a/tests/cache.test.ts b/tests/cache.test.ts index 1c08bfa..2fe4c55 100644 --- a/tests/cache.test.ts +++ b/tests/cache.test.ts @@ -67,6 +67,32 @@ describe('InMemoryCacheAdapter', () => { const adapter = new InMemoryCacheAdapter(); await expect(adapter.delete('ghost')).resolves.toBeUndefined(); }); + + it('deleteByPrefix removes all keys starting with the prefix', async () => { + const adapter = new InMemoryCacheAdapter(); + await adapter.set('access:checkAccess:g1:res1:0xabc', { hasAccess: true }); + await adapter.set('access:checkAccess:g1:res2:0xdef', { hasAccess: false }); + await adapter.set('access:checkAccess:g2:res1:0xabc', { hasAccess: true }); + await adapter.set('unrelated:key', 'keep'); + + await adapter.deleteByPrefix('access:checkAccess:g1:'); + + expect(await adapter.get('access:checkAccess:g1:res1:0xabc')).toBeNull(); + expect(await adapter.get('access:checkAccess:g1:res2:0xdef')).toBeNull(); + // Different guild should NOT be deleted + expect(await adapter.get('access:checkAccess:g2:res1:0xabc')).toEqual({ hasAccess: true }); + // Unrelated key should NOT be deleted + expect(await adapter.get('unrelated:key')).toBe('keep'); + }); + + it('deleteByPrefix with no matching keys is a no-op', async () => { + const adapter = new InMemoryCacheAdapter(); + await adapter.set('a', 1); + await adapter.set('b', 2); + await adapter.deleteByPrefix('nonexistent:'); + expect(await adapter.get('a')).toBe(1); + expect(await adapter.get('b')).toBe(2); + }); }); // --------------------------------------------------------------------------- @@ -195,6 +221,60 @@ describe('GuildPassClient – cache integration', () => { expect(await adapter.get('roles:getRoles:prime-guild')).toBeNull(); }); + it('invalidateGuildCache removes composite-key entries via deleteByPrefix', async () => { + const adapter = new InMemoryCacheAdapter(); + // Composite keys with wallet/resource suffixes (the actual bug) + await adapter.set('access:checkAccess:prime-guild:premium-docs:0xabc', { hasAccess: true }); + await adapter.set('access:checkAccess:prime-guild:basic-docs:0xdef', { hasAccess: false }); + await adapter.set('access:checkAccess:other-guild:premium-docs:0xabc', { hasAccess: true }); + await adapter.set('membership:getMembership:prime-guild:0xabc', { member: true }); + await adapter.set('unrelated:key', 'keep'); + + const client = new GuildPassClient({ ...BASE_CONFIG, cache: adapter }); + await client.invalidateGuildCache('prime-guild'); + + // All prime-guild entries with nested keys should be removed + expect(await adapter.get('access:checkAccess:prime-guild:premium-docs:0xabc')).toBeNull(); + expect(await adapter.get('access:checkAccess:prime-guild:basic-docs:0xdef')).toBeNull(); + expect(await adapter.get('membership:getMembership:prime-guild:0xabc')).toBeNull(); + // Other guild should NOT be removed + expect(await adapter.get('access:checkAccess:other-guild:premium-docs:0xabc')).toEqual({ hasAccess: true }); + // Unrelated key should NOT be removed + expect(await adapter.get('unrelated:key')).toBe('keep'); + }); + + it('invalidateWalletCache uses deleteByPrefix when adapter supports it', async () => { + const adapter = new InMemoryCacheAdapter(); + await adapter.set('wallet:0xabc:balance', 100); + await adapter.set('wallet:0xabc:nonce', 5); + await adapter.set('wallet:0xdef:balance', 200); + await adapter.set('guilds:getGuild:g1', { id: 'g1' }); + + const client = new GuildPassClient({ ...BASE_CONFIG, cache: adapter }); + await client.invalidateWalletCache('0xabc'); + + // 0xabc wallet entries removed + expect(await adapter.get('wallet:0xabc:balance')).toBeNull(); + expect(await adapter.get('wallet:0xabc:nonce')).toBeNull(); + // 0xdef wallet entries preserved + expect(await adapter.get('wallet:0xdef:balance')).toBe(200); + // Guild cache preserved + expect(await adapter.get('guilds:getGuild:g1')).toEqual({ id: 'g1' }); + }); + + it('invalidateGuildCache falls back to exact delete for adapters without deleteByPrefix', async () => { + const adapter = buildMockAdapter(); + // Only set exact-prefix keys (legacy behavior) + await adapter.set('guilds:getGuild:prime-guild', mockGuild); + await adapter.set('roles:getRoles:prime-guild', []); + + const client = new GuildPassClient({ ...BASE_CONFIG, cache: adapter }); + await client.invalidateGuildCache('prime-guild'); + + expect(await adapter.get('guilds:getGuild:prime-guild')).toBeNull(); + expect(await adapter.get('roles:getRoles:prime-guild')).toBeNull(); + }); + it('invalidateGuildCache is a no-op when no adapter is configured', async () => { const client = new GuildPassClient(BASE_CONFIG); await expect(client.invalidateGuildCache('any')).resolves.toBeUndefined();