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
8 changes: 8 additions & 0 deletions src/cache/cache.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ export interface CacheAdapter {
deleteByPrefix?(prefix: string): Promise<void>;
}

export type CacheInvalidationRequest =
| string
| { key: string }
| { prefix: string }
| { pattern: string };

export type CacheInvalidationKind = 'key' | 'prefix';

interface CacheEntry<T> {
value: T;
expiresAt: number | null;
Expand Down
109 changes: 92 additions & 17 deletions src/client/GuildPassClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
Expand Down Expand Up @@ -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<void>;
clear: () => Promise<void>;
};
private readonly cacheTtl: number | undefined;
private readonly deduplication: boolean;
private readonly inFlightRequests = new Map<string, Promise<any>>();
Expand All @@ -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;

Expand Down Expand Up @@ -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<void> {
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<void> {
if (!this.cache) return;
if (!this.cacheAdapter) return;
const prefixes = [
`${buildCacheKey('access', 'checkAccess', guildId)}:`,
`${buildCacheKey('access', 'checkRoleAccess', guildId)}:`,
Expand All @@ -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);
Expand All @@ -245,26 +320,26 @@ export class GuildPassClient {
*/
public async invalidateWalletCache(walletAddress: string): Promise<void> {
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<void> {
try {
await this.cache?.clear();
await this.cacheAdapter?.clear();
} catch (error: any) {
this.handleCacheError('clear', error);
}
Expand Down Expand Up @@ -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<T>(key);
const cached = await this.cacheAdapter.get<T>(key);
if (cached !== null) {
this.diagnostics.recordCacheHit(key);
return cached;
Expand All @@ -370,9 +445,9 @@ export class GuildPassClient {

const execute = async (): Promise<T> => {
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);
}
Expand Down
13 changes: 13 additions & 0 deletions src/contracts/watchAndInvalidate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { GuildPassClient } from '../client/GuildPassClient';
import type { CacheAdapter } from '../cache/cache.types';

export interface WatchOptions {
chainId: number;
Expand All @@ -7,6 +8,18 @@ export interface WatchOptions {
confirmations?: number;
}

export async function invalidateByPrefixWithFallback(
adapter: CacheAdapter | undefined,
prefix: string,
): Promise<void> {
if (!adapter) return;
if (adapter.deleteByPrefix) {
await adapter.deleteByPrefix(prefix);
return;
}
await adapter.clear();
}

export class WatchAndInvalidateService {
private client: GuildPassClient;
private activeSubscriptions: Map<string, boolean> = new Map();
Expand Down
92 changes: 92 additions & 0 deletions tests/cache-manual-invalidation.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>();

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();
});
});