diff --git a/src/cache/cache.types.ts b/src/cache/cache.types.ts index 8075835..e5edc0f 100644 --- a/src/cache/cache.types.ts +++ b/src/cache/cache.types.ts @@ -61,6 +61,14 @@ export interface CacheAdapter { deleteByPrefix?(prefix: string): Promise; } +export type CacheInvalidationRequest = + | string + | { key: string } + | { prefix: string } + | { pattern: string }; + +export type CacheInvalidationKind = 'key' | 'prefix'; + interface CacheEntry { value: T; expiresAt: number | null; diff --git a/src/client/GuildPassClient.ts b/src/client/GuildPassClient.ts index b079913..a7b360a 100644 --- a/src/client/GuildPassClient.ts +++ b/src/client/GuildPassClient.ts @@ -16,7 +16,7 @@ import { MembershipService } from '../membership/membership.service'; import { SDK_VERSION } from '../config/version'; // GuildPass SDK: Import external module dependencies. import { RolesService } from '../roles/roles.service'; -import { CacheAdapter } from '../cache/cache.types'; +import { CacheAdapter, CacheInvalidationKind, CacheInvalidationRequest } from '../cache/cache.types'; import { normaliseAddress } from '../utils/address'; import { validateAddress } from '../utils/validation'; import { encodePathSegment } from '../utils/formatting'; @@ -33,6 +33,8 @@ import type { BatchItemResult } from '../contracts/contract.types'; import { DiagnosticsModule } from '../diagnostics/DiagnosticsModule'; import type { RequestOptions } from '../types/common'; import type { ResponseMetadata } from '../http/http.types'; +import { GuildPassConfigError } from '../errors/errorTypes'; +import { GuildPassErrorCode } from '../errors/errorCodes'; /** * The main GuildPass SDK this. @@ -93,7 +95,11 @@ export class GuildPassClient { private readonly http: HttpClient; // GuildPass SDK: Class member structure property or constructor. private readonly config: GuildPassClientConfig; - private readonly cache: CacheAdapter | undefined; + private readonly cacheAdapter: CacheAdapter | undefined; + public readonly cache: { + invalidate: (request: CacheInvalidationRequest) => Promise; + clear: () => Promise; + }; private readonly cacheTtl: number | undefined; private readonly deduplication: boolean; private readonly inFlightRequests = new Map>(); @@ -110,7 +116,11 @@ export class GuildPassClient { emitSecurityConfigWarnings(this.config); - this.cache = this.config.cache; + this.cacheAdapter = this.config.cache; + this.cache = { + invalidate: (request: CacheInvalidationRequest) => this.invalidateCache(request), + clear: () => this.clearCache(), + }; this.cacheTtl = this.config.cacheTtl; this.deduplication = this.config.deduplication ?? true; @@ -212,8 +222,73 @@ export class GuildPassClient { * Call this after any mutation that may affect guild data, membership, roles, * or access decisions for that guild. */ + + private normalizeInvalidationRequest( + request: CacheInvalidationRequest, + ): { kind: CacheInvalidationKind; value: string } { + if (typeof request === 'string') { + const key = request.trim(); + if (!key) { + throw new GuildPassConfigError( + 'cache.invalidate key must not be empty', + GuildPassErrorCode.INVALID_INPUT, + ); + } + return { kind: 'key', value: key }; + } + + if ('key' in request) { + const key = request.key?.trim(); + if (!key) { + throw new GuildPassConfigError( + 'cache.invalidate key must not be empty', + GuildPassErrorCode.INVALID_INPUT, + ); + } + return { kind: 'key', value: key }; + } + + const rawPrefix = 'prefix' in request ? request.prefix : request.pattern; + const prefix = rawPrefix?.trim(); + if (!prefix) { + throw new GuildPassConfigError( + 'cache.invalidate prefix/pattern must not be empty', + GuildPassErrorCode.INVALID_INPUT, + ); + } + + return { kind: 'prefix', value: prefix }; + } + + private async invalidateCache(request: CacheInvalidationRequest): Promise { + if (!this.cacheAdapter) return; + + const normalized = this.normalizeInvalidationRequest(request); + + try { + if (normalized.kind === 'key') { + await this.cacheAdapter.delete(normalized.value); + return; + } + + if (this.cacheAdapter.deleteByPrefix) { + await this.cacheAdapter.deleteByPrefix(normalized.value); + return; + } + + // Fallback for adapters that cannot do prefix invalidation. + await this.cacheAdapter.clear(); + } catch (error: any) { + this.handleCacheError( + normalized.kind === 'key' ? 'delete' : this.cacheAdapter.deleteByPrefix ? 'delete' : 'clear', + error, + normalized.value, + ); + } + } + public async invalidateGuildCache(guildId: string): Promise { - if (!this.cache) return; + if (!this.cacheAdapter) return; const prefixes = [ `${buildCacheKey('access', 'checkAccess', guildId)}:`, `${buildCacheKey('access', 'checkRoleAccess', guildId)}:`, @@ -228,10 +303,10 @@ export class GuildPassClient { try { // 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))); + if (this.cacheAdapter.deleteByPrefix) { + await Promise.all(prefixes.map((p) => this.cacheAdapter!.deleteByPrefix!(p))); } else { - await Promise.all(prefixes.map((k) => this.cache!.delete(k))); + await Promise.all(prefixes.map((k) => this.cacheAdapter!.delete(k))); } } catch (error: any) { this.handleCacheError('delete', error); @@ -245,26 +320,26 @@ export class GuildPassClient { */ public async invalidateWalletCache(walletAddress: string): Promise { validateAddress(walletAddress, { strict: this.config.strictAddressChecksum }); - if (!this.cache) return; + if (!this.cacheAdapter) return; const wallet = normaliseAddress(walletAddress); try { // 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(`${buildCacheKey('wallet', wallet)}:`); + if (this.cacheAdapter.deleteByPrefix) { + await this.cacheAdapter.deleteByPrefix(`${buildCacheKey('wallet', wallet)}:`); } else { - await this.cache.clear(); + await this.cacheAdapter.clear(); } } catch (error: any) { - this.handleCacheError(this.cache.deleteByPrefix ? 'delete' : 'clear', error); + this.handleCacheError(this.cacheAdapter.deleteByPrefix ? 'delete' : 'clear', error); } } /** Clears the entire cache. */ public async clearCache(): Promise { try { - await this.cache?.clear(); + await this.cacheAdapter?.clear(); } catch (error: any) { this.handleCacheError('clear', error); } @@ -353,9 +428,9 @@ export class GuildPassClient { const effectiveTtl = ttlOverride ?? this.cacheTtl; const shouldDeduplicate = deduplicate ?? this.deduplication; - if (this.cache) { + if (this.cacheAdapter) { try { - const cached = await this.cache.get(key); + const cached = await this.cacheAdapter.get(key); if (cached !== null) { this.diagnostics.recordCacheHit(key); return cached; @@ -370,9 +445,9 @@ export class GuildPassClient { const execute = async (): Promise => { const result = await fn(); - if (this.cache) { + if (this.cacheAdapter) { try { - await this.cache.set(key, result, effectiveTtl); + await this.cacheAdapter.set(key, result, effectiveTtl); } catch (error: any) { this.handleCacheError('set', error, key); } diff --git a/src/contracts/watchAndInvalidate.ts b/src/contracts/watchAndInvalidate.ts index e761bf2..15cda53 100644 --- a/src/contracts/watchAndInvalidate.ts +++ b/src/contracts/watchAndInvalidate.ts @@ -1,4 +1,5 @@ import { GuildPassClient } from '../client/GuildPassClient'; +import type { CacheAdapter } from '../cache/cache.types'; export interface WatchOptions { chainId: number; @@ -7,6 +8,18 @@ export interface WatchOptions { confirmations?: number; } +export async function invalidateByPrefixWithFallback( + adapter: CacheAdapter | undefined, + prefix: string, +): Promise { + if (!adapter) return; + if (adapter.deleteByPrefix) { + await adapter.deleteByPrefix(prefix); + return; + } + await adapter.clear(); +} + export class WatchAndInvalidateService { private client: GuildPassClient; private activeSubscriptions: Map = new Map(); diff --git a/tests/cache-manual-invalidation.test.ts b/tests/cache-manual-invalidation.test.ts new file mode 100644 index 0000000..2f5bb8b --- /dev/null +++ b/tests/cache-manual-invalidation.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi } from 'vitest'; +import { GuildPassClient } from '../src/client/GuildPassClient'; +import { InMemoryCacheAdapter } from '../src/cache/cache.types'; + +describe('client.cache.invalidate', () => { + it('invalidates a single known key', async () => { + const adapter = new InMemoryCacheAdapter(); + await adapter.set('guilds:getGuild:alpha', { id: 'alpha' }); + + const client = new GuildPassClient({ + apiUrl: 'https://api.guildpass.xyz', + cache: adapter, + }); + + await client.cache.invalidate({ key: 'guilds:getGuild:alpha' }); + + const value = await adapter.get('guilds:getGuild:alpha'); + expect(value).toBeNull(); + }); + + it('invalidates by prefix when adapter supports deleteByPrefix', async () => { + const adapter = new InMemoryCacheAdapter(); + await adapter.set('wallet:0xabc:access:1', { ok: true }); + await adapter.set('wallet:0xabc:roles:1', { ok: true }); + await adapter.set('wallet:0xdef:access:1', { ok: true }); + + const client = new GuildPassClient({ + apiUrl: 'https://api.guildpass.xyz', + cache: adapter, + }); + + await client.cache.invalidate({ prefix: 'wallet:0xabc:' }); + + expect(await adapter.get('wallet:0xabc:access:1')).toBeNull(); + expect(await adapter.get('wallet:0xabc:roles:1')).toBeNull(); + expect(await adapter.get('wallet:0xdef:access:1')).toEqual({ ok: true }); + }); + + it('falls back to clear() when prefix invalidation is requested and adapter lacks deleteByPrefix', async () => { + const store = new Map(); + + const adapter = { + get: vi.fn(async (key: string) => (store.has(key) ? store.get(key) : null)), + set: vi.fn(async (key: string, value: unknown) => { + store.set(key, value); + }), + delete: vi.fn(async (key: string) => { + store.delete(key); + }), + clear: vi.fn(async () => { + store.clear(); + }), + }; + + await adapter.set('wallet:0xabc:access:1', { ok: true }); + await adapter.set('wallet:0xdef:access:1', { ok: true }); + + const client = new GuildPassClient({ + apiUrl: 'https://api.guildpass.xyz', + cache: adapter, + }); + + await client.cache.invalidate({ pattern: 'wallet:0xabc:' }); + + expect(adapter.clear).toHaveBeenCalledTimes(1); + expect(await adapter.get('wallet:0xabc:access:1')).toBeNull(); + expect(await adapter.get('wallet:0xdef:access:1')).toBeNull(); + }); + + it('is a no-op when cache is not configured', async () => { + const client = new GuildPassClient({ + apiUrl: 'https://api.guildpass.xyz', + }); + + await expect(client.cache.invalidate({ key: 'anything' })).resolves.toBeUndefined(); + await expect(client.cache.invalidate({ prefix: 'anything:' })).resolves.toBeUndefined(); + }); + + it('accepts string shorthand as a single key', async () => { + const adapter = new InMemoryCacheAdapter(); + await adapter.set('guilds:getGuild:alpha', { id: 'alpha' }); + + const client = new GuildPassClient({ + apiUrl: 'https://api.guildpass.xyz', + cache: adapter, + }); + + await client.cache.invalidate('guilds:getGuild:alpha'); + + expect(await adapter.get('guilds:getGuild:alpha')).toBeNull(); + }); +});