Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/cache/cache.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ export interface CacheAdapter {
set<T>(key: string, value: T, ttl?: number): Promise<void>;
delete(key: string): Promise<void>;
clear(): Promise<void>;
/** Delete all entries whose key starts with the given prefix. */
deleteByPrefix?(prefix: string): Promise<void>;
}

interface CacheEntry<T> {
Expand Down Expand Up @@ -51,4 +53,12 @@ export class InMemoryCacheAdapter implements CacheAdapter {
async clear(): Promise<void> {
this.store.clear();
}

async deleteByPrefix(prefix: string): Promise<void> {
for (const key of this.store.keys()) {
if (key.startsWith(prefix)) {
this.store.delete(key);
}
}
}
}
22 changes: 16 additions & 6 deletions src/client/GuildPassClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,20 +130,30 @@ 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)));
}
}

/**
* Removes all cache entries scoped to a specific wallet address.
*
* Useful when a wallet's on-chain state has changed (e.g., token transfer).
*/
public async invalidateWalletCache(_walletAddress: string): Promise<void> {
public async invalidateWalletCache(walletAddress: string): Promise<void> {
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. */
Expand Down
80 changes: 80 additions & 0 deletions tests/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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();
Expand Down
Loading