The SDK uses a pluggable CacheAdapter interface to cache read responses. Any object that satisfies the interface works — in-memory, Redis, Cloudflare KV, or your own custom backend.
interface CacheAdapter {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttl?: number): Promise<void>;
delete(key: string): Promise<void>;
clear(): Promise<void>;
deleteByPrefix?(prefix: string): Promise<void>;
}- Returns the deserialized value or
nullwhen the key is missing or expired. - Never throw. Return
nullfor any error (malformed data, connection failure) so the SDK falls through to the network.
- TTL is in milliseconds.
undefinedor omitted means the entry should never expire (store until explicitly deleted). - The SDK always passes JSON-roundtrippable values. Adapters may store the raw JSON string or apply their own encoding.
- Remove a single entry. Must never throw.
- Remove all entries. Must never throw.
- Remove all entries whose key starts with
prefix. - Implement this for efficient invalidation. Without it:
invalidateGuildCache()falls back to deleting known exact keys (may miss entries with dynamic suffixes).invalidateWalletCache()falls back to clearing the entire cache.
- Must never throw.
ttl parameter |
Behaviour |
|---|---|
undefined |
Never expire. Store until explicitly deleted or evicted by LRU. |
0 |
Expires immediately — effectively disables caching. |
> 0 |
Expire after ttl milliseconds. |
Note:
cacheTtl: 0is a valid config value (sdkConfigaccepts anyttl >= 0), but it is not a "never expire" sentinel — it behaves as a zero-millisecond TTL. SinceexpiresAtis computed asDate.now() + ttl, an entry written withttl: 0is already expired by the time the nextget()checks it. In practice this means every read becomes a cache miss and triggers a fresh network call, silently disabling caching without any error. If you intend for entries to never expire, omitcacheTtl(or passundefined) — only an absent TTL means "no expiration."
The SDK passes cacheTtl (from client config) as the ttl argument. Methods that accept a per-call override subtract elapsed time from the deadline before storing.
Cache adapter errors never propagate to the caller. The SDK catches every error and routes it to the optional onCacheError hook:
const client = new GuildPassClient({
apiUrl: 'https://api.guildpass.xyz',
cache: myAdapter,
hooks: {
onCacheError: ({ operation, key, error }) => {
console.error(`Cache ${operation} failed for key ${key}`, error);
},
},
});If a hook throws or the hook itself is absent, the error is swallowed silently. The SDK continues to make network requests as if no cache was configured.
- Values passed to
set()are always JSON-roundtrippable (noundefined,BigInt, or circular references). - The SDK does not serialize/deserialize automatically — each adapter is responsible for its own encoding.
- For Redis-style stores,
JSON.stringify()/JSON.parse()is the standard approach. - For binary stores,
BufferorMessagePackcan be used as long asget()returns the original shape.
- Key namespaces: Cache keys are prefixed (
access:,membership:,roles:,guilds:,wallet:) and contain only public identifiers. No secrets are stored. - TTL accuracy: Rely on the adapter's native TTL mechanism (e.g. Redis
PX). Do not implement application-level expiry. - Consistency: The SDK does not require strong consistency. Stale data is acceptable — it will be overwritten on the next successful API call.
- Connection errors: Handle reconnection internally or let the adapter throw (the SDK catches it). Consider using a client with built-in retry and failover.
- Prefix deletion: For Redis, use
SCAN+DELor the built-inUNLINK. For DynamoDB, query by GSIK. Do not useKEYS *in production.
Note: A complete, runnable project for this Redis adapter — including integration tests — is available in the
examples/redis-cache-adapterdirectory.
import { CacheAdapter } from '@guildpass/sdk';
import { createClient, type RedisClientType } from 'redis';
export class RedisCacheAdapter implements CacheAdapter {
private readonly client: RedisClientType;
private readonly prefix: string;
constructor(url: string, prefix = 'guildpass:') {
this.client = createClient({ url });
this.prefix = prefix;
}
async connect(): Promise<void> {
if (!this.client.isOpen) {
await this.client.connect();
}
}
async disconnect(): Promise<void> {
if (this.client.isOpen) {
await this.client.quit();
}
}
private prefixed(key: string): string {
return this.prefix + key;
}
async get<T>(key: string): Promise<T | null> {
try {
const raw = await this.client.get(this.prefixed(key));
if (raw === null) return null;
return JSON.parse(raw) as T;
} catch {
return null;
}
}
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
try {
const k = this.prefixed(key);
const serialised = JSON.stringify(value);
if (ttl !== undefined) {
await this.client.set(k, serialised, { PX: ttl });
} else {
await this.client.set(k, serialised);
}
} catch {
// swallowed by SDK
}
}
async delete(key: string): Promise<void> {
try {
await this.client.del(this.prefixed(key));
} catch {
// swallowed by SDK
}
}
async clear(): Promise<void> {
try {
await this.client.flushDb();
} catch {
// swallowed by SDK
}
}
async deleteByPrefix(prefix: string): Promise<void> {
try {
const pattern = this.prefixed(prefix) + '*';
const batchSize = 100;
let keysToDelete: string[] = [];
for await (const key of this.client.scanIterator({ MATCH: pattern, COUNT: batchSize })) {
keysToDelete.push(key);
if (keysToDelete.length >= batchSize) {
await this.client.unlink(keysToDelete);
keysToDelete = [];
}
}
if (keysToDelete.length > 0) {
await this.client.unlink(keysToDelete);
}
} catch {
// swallowed by SDK
}
}
}import { CacheAdapter } from '@guildpass/sdk';
interface CacheEntry<T> {
value: T;
expiresAt: number | null;
}
export class MyMemoryAdapter implements CacheAdapter {
private readonly store = new Map<string, CacheEntry<unknown>>();
async get<T>(key: string): Promise<T | null> {
const entry = this.store.get(key) as CacheEntry<T> | undefined;
if (!entry) return null;
if (entry.expiresAt !== null && Date.now() >= entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.value;
}
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
this.store.set(key, {
value,
expiresAt: ttl !== undefined ? Date.now() + ttl : null,
});
}
async delete(key: string): Promise<void> {
this.store.delete(key);
}
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);
}
}
}
}import { GuildPassClient } from '@guildpass/sdk';
import { RedisCacheAdapter } from './adapters/RedisCacheAdapter';
const cache = new RedisCacheAdapter('redis://localhost:6379');
await cache.connect();
const client = new GuildPassClient({
apiUrl: 'https://api.guildpass.xyz',
cache,
cacheTtl: 30_000, // 30 second default TTL
});Call the following methods on the client instance:
// Evict entries scoped to a guild (uses deleteByPrefix when available)
await client.invalidateGuildCache('prime-guild');
// Evict entries scoped to a wallet address (uses deleteByPrefix when available)
await client.invalidateWalletCache('0x1234...5678');
// Wipe the entire cache
await client.clearCache();See the SDK Guide for more on the caching layer.
Custom adapters should run the exported conformance suite. It covers value round-tripping, TTL semantics, deletion, complete clearing, optional prefix deletion, concurrent writes, and store-failure isolation.
import { describe, it } from 'vitest';
import { runCacheAdapterConformanceTests } from '@guildpass/sdk/testing';
import { MyCustomAdapter } from './MyCustomAdapter';
runCacheAdapterConformanceTests(
{
// Each case must receive a fresh, empty adapter.
factory: async () => {
const adapter = new MyCustomAdapter('redis://localhost:6379');
await adapter.connect();
await adapter.clear();
return adapter;
},
// Strongly recommended: exercise the never-throw contract while the
// underlying store is unavailable.
brokenFactory: async () => {
const adapter = new MyCustomAdapter('redis://localhost:6379');
await adapter.connect();
await adapter.disconnect();
return adapter;
},
// Omit this to use real timers. A real store may need a longer wait or
// a backend-specific clock hook.
advanceTime: async (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
},
{ describe, it },
);Vitest and Jest projects with global describe and it functions may omit
the second argument. Other test frameworks can pass compatible registration
functions. For custom orchestration, createCacheAdapterConformanceTests()
returns the same cases without registering them.
If brokenFactory is omitted, store-failure cases are not registered. Passing
it is the recommended way to verify that get() falls back to null and that
write, delete, clear, and prefix-delete failures never escape the adapter.