Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1",
"hono": "^4.11.9",
"ioredis": "^5.6.1",
"postgres": "^3.4.8",
"uuid": "^13.0.0",
"zod": "^4.3.6"
Expand Down
51 changes: 49 additions & 2 deletions packages/api/src/routes/bounties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,30 @@ import { bounties, users, applications } from '../db/schema';
import { eq, and, gte, lte, sql, desc, or, lt, count } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import { z } from 'zod';
import { getCached, setCache, deleteCachePattern } from '../services/redis';

// Cache TTLs in seconds
const BOUNTY_LIST_CACHE_TTL = 30; // 30 seconds for paginated listing
const BOUNTY_LIST_USER_TTL = 15 * 60; // 15 minutes for user-specific filtered listing

// Build a deterministic cache key from query params
function getBountyListCacheKey(query: Record<string, string | string[] | undefined>, userId?: string): string {
const params = new URLSearchParams();
// Normalize params deterministically
const sortedKeys = Object.keys(query).sort();
for (const key of sortedKeys) {
const val = query[key];
if (val !== undefined && val !== '') {
if (Array.isArray(val)) {
for (const v of val) params.append(key, v);
} else {
params.append(key, val);
}
}
}
const qstr = params.toString();
return userId ? `bounty:list:${userId}:${qstr}` : `bounty:list:anon:${qstr}`;
}

const applySchema = z.object({
cover_letter: z.string().trim().min(1, 'cover_letter is required and must be a non-empty string'),
Expand Down Expand Up @@ -44,6 +68,15 @@ bountiesRouter.get('/', async (c) => {
return c.json({ error: `Invalid status. Allowed values are: ${allowedStatuses.join(', ')}` }, 400);
}

// Check Redis cache (only for first page, no cursor)
if (!cursor) {
const cacheKey = getBountyListCacheKey(query);
const cached = await getCached<{ data: any[]; meta: any }>(cacheKey);
if (cached) {
return c.json(cached);
}
}

let whereClause = undefined;
const filters = [];

Expand Down Expand Up @@ -120,14 +153,22 @@ bountiesRouter.get('/', async (c) => {
})).toString('base64');
}

return c.json({
const response = {
data,
meta: {
next_cursor: nextCursor,
has_more: hasMore,
count: data.length,
},
});
};

// Cache the response (only first page, no cursor)
if (!cursor) {
const cacheKey = getBountyListCacheKey(query);
await setCache(cacheKey, response, BOUNTY_LIST_CACHE_TTL);
}

return c.json(response);
});

/**
Expand Down Expand Up @@ -333,6 +374,9 @@ bountiesRouter.patch('/:id', ensureBountyCreator('id'), async (c) => {
})
.where(eq(bounties.id, id));

// Invalidate bounty listing cache
await deleteCachePattern('bounty:list:*');

return c.json({ success: true, message: 'Bounty updated' });
});

Expand All @@ -350,6 +394,9 @@ bountiesRouter.post('/:id/complete', ensureBountyAssignee('id'), async (c) => {
})
.where(eq(bounties.id, id));

// Invalidate bounty listing cache
await deleteCachePattern('bounty:list:*');

return c.json({ success: true, message: 'Bounty submitted for review' });
});

Expand Down
108 changes: 108 additions & 0 deletions packages/api/src/services/redis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import Redis from 'ioredis';

let redis: Redis | null = null;

/**
* Get or create a Redis client instance.
* Returns null if REDIS_URL is not configured.
*/
export function getRedisClient(): Redis | null {
if (!process.env.REDIS_URL) {
return null;
}

if (!redis) {
redis = new Redis(process.env.REDIS_URL, {
maxRetriesPerRequest: 3,
retryStrategy: (times) => {
if (times > 3) return null;
return Math.min(times * 200, 2000);
},
lazyConnect: true,
});

redis.on('error', (err) => {
console.error('[Redis] Connection error:', err.message);
});

redis.on('connect', () => {
console.log('[Redis] Connected successfully');
});
}

return redis;
}

/**
* Get a cached value from Redis and parse it as JSON.
* Returns null if Redis is not available or key does not exist.
*/
export async function getCached<T>(key: string): Promise<T | null> {
const client = getRedisClient();
if (!client) return null;

try {
const cached = await client.get(key);
if (cached) {
return JSON.parse(cached) as T;
}
return null;
} catch (err) {
console.error(`[Redis] Get error for key ${key}:`, err);
return null;
}
}

/**
* Set a cached value in Redis with a TTL (in seconds).
*/
export async function setCache(key: string, value: unknown, ttlSeconds: number): Promise<void> {
const client = getRedisClient();
if (!client) return;

try {
await client.setex(key, ttlSeconds, JSON.stringify(value));
} catch (err) {
console.error(`[Redis] Set error for key ${key}:`, err);
}
}

/**
* Delete a cached key from Redis.
*/
export async function deleteCache(key: string): Promise<void> {
const client = getRedisClient();
if (!client) return;

try {
await client.del(key);
} catch (err) {
console.error(`[Redis] Delete error for key ${key}:`, err);
}
}

/**
* Delete all cached keys matching a pattern (e.g., "bounty:list:*").
* Uses SCAN to avoid blocking Redis.
*/
export async function deleteCachePattern(pattern: string): Promise<void> {
const client = getRedisClient();
if (!client) return;

try {
let cursor = '0';
const keysToDelete: string[] = [];
do {
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = nextCursor;
keysToDelete.push(...keys);
} while (cursor !== '0');

if (keysToDelete.length > 0) {
await client.del(...keysToDelete);
console.log(`[Redis] Deleted ${keysToDelete.length} keys matching pattern: ${pattern}`);
}
} catch (err) {
console.error(`[Redis] Pattern delete error for ${pattern}:`, err);
}
}