Skip to content

Commit 74febe4

Browse files
authored
Merge pull request #20 from RaoUsama7/fix/link-cache-invalidation
fix(cache): invalidate link resolution cache on update and delete
2 parents 02141a0 + 7c2510a commit 74febe4

File tree

3 files changed

+100
-2
lines changed

3 files changed

+100
-2
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { invalidateLinkResolutionCache } from './link-resolution-cache';
3+
4+
describe('invalidateLinkResolutionCache', () => {
5+
it('no-ops when redis is null', async () => {
6+
await expect(invalidateLinkResolutionCache(null, 'abc')).resolves.toBeUndefined();
7+
});
8+
9+
it('no-ops when redis is undefined', async () => {
10+
await expect(invalidateLinkResolutionCache(undefined, 'abc')).resolves.toBeUndefined();
11+
});
12+
13+
it('deletes legacy key when no templateSlug', async () => {
14+
const del = vi.fn().mockResolvedValue(1);
15+
await invalidateLinkResolutionCache({ del } as any, 'abc');
16+
expect(del).toHaveBeenCalledTimes(1);
17+
expect(del).toHaveBeenCalledWith('link:abc');
18+
});
19+
20+
it('deletes both legacy and template keys when templateSlug is provided', async () => {
21+
const del = vi.fn().mockResolvedValue(2);
22+
await invalidateLinkResolutionCache({ del } as any, 'abc', 'mytemplate');
23+
expect(del).toHaveBeenCalledTimes(1);
24+
expect(del).toHaveBeenCalledWith('link:abc', 'link:mytemplate:abc');
25+
});
26+
27+
it('skips template key when templateSlug is null', async () => {
28+
const del = vi.fn().mockResolvedValue(1);
29+
await invalidateLinkResolutionCache({ del } as any, 'abc', null);
30+
expect(del).toHaveBeenCalledTimes(1);
31+
expect(del).toHaveBeenCalledWith('link:abc');
32+
});
33+
34+
it('skips template key when templateSlug is empty string', async () => {
35+
const del = vi.fn().mockResolvedValue(1);
36+
await invalidateLinkResolutionCache({ del } as any, 'abc', '');
37+
expect(del).toHaveBeenCalledTimes(1);
38+
expect(del).toHaveBeenCalledWith('link:abc');
39+
});
40+
41+
it('swallows errors from redis.del without throwing', async () => {
42+
const del = vi.fn().mockRejectedValue(new Error('connection lost'));
43+
await expect(invalidateLinkResolutionCache({ del } as any, 'abc')).resolves.toBeUndefined();
44+
expect(del).toHaveBeenCalledTimes(1);
45+
});
46+
});

src/lib/link-resolution-cache.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Invalidate the Redis cache entries used by the redirect and SDK resolve
3+
* read paths for a given link. Safe to call when Redis is not configured.
4+
*
5+
* Cache key patterns (set in redirect.ts / sdk.ts):
6+
* link:${shortCode}
7+
* link:${templateSlug}:${shortCode}
8+
*/
9+
export async function invalidateLinkResolutionCache(
10+
redis: { del(...keys: string[]): Promise<number> } | null | undefined,
11+
shortCode: string,
12+
templateSlug?: string | null,
13+
): Promise<void> {
14+
if (!redis) return;
15+
try {
16+
const keys = [`link:${shortCode}`];
17+
if (templateSlug) keys.push(`link:${templateSlug}:${shortCode}`);
18+
await redis.del(...keys);
19+
} catch {
20+
// Swallow — a cache miss on the next read is self-healing.
21+
}
22+
}

src/routes/links.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ import { FastifyInstance, FastifyRequest } from 'fastify';
22
import { z } from 'zod';
33
import { db } from '../lib/database.js';
44
import { generateShortCode } from '../lib/utils.js';
5+
import { invalidateLinkResolutionCache } from '../lib/link-resolution-cache.js';
6+
7+
async function getTemplateSlug(templateId: string | null): Promise<string | null> {
8+
if (!templateId) return null;
9+
const result = await db.query(
10+
'SELECT slug FROM link_templates WHERE id = $1',
11+
[templateId]
12+
);
13+
return result.rows[0]?.slug ?? null;
14+
}
515

616
const createLinkSchema = z.object({
717
userId: z.string().uuid().optional(),
@@ -227,6 +237,12 @@ export async function linkRoutes(fastify: FastifyInstance) {
227237

228238
const data = updateLinkSchema.parse(request.body);
229239

240+
// Capture current identifiers for cache invalidation after the update
241+
const oldLinkResult = await db.query(
242+
'SELECT short_code, template_id FROM links WHERE id = $1',
243+
[id]
244+
);
245+
230246
// Build update query dynamically
231247
const updates: string[] = [];
232248
const values: any[] = [];
@@ -271,6 +287,16 @@ export async function linkRoutes(fastify: FastifyInstance) {
271287
}
272288

273289
const link = result.rows[0];
290+
291+
// Invalidate cached link JSON for both old and new template associations
292+
const oldRow = oldLinkResult.rows[0];
293+
const oldTemplateSlug = await getTemplateSlug(oldRow?.template_id);
294+
const newTemplateSlug = await getTemplateSlug(link.template_id);
295+
await invalidateLinkResolutionCache(fastify.redis, link.short_code, oldTemplateSlug);
296+
if (newTemplateSlug !== oldTemplateSlug) {
297+
await invalidateLinkResolutionCache(fastify.redis, link.short_code, newTemplateSlug);
298+
}
299+
274300
return {
275301
...link,
276302
utmParameters: link.utm_parameters,
@@ -388,12 +414,12 @@ export async function linkRoutes(fastify: FastifyInstance) {
388414
let result;
389415
if (userId) {
390416
result = await db.query(
391-
'DELETE FROM links WHERE id = $1 AND user_id = $2 RETURNING id',
417+
'DELETE FROM links WHERE id = $1 AND user_id = $2 RETURNING id, short_code, template_id',
392418
[id, userId]
393419
);
394420
} else {
395421
result = await db.query(
396-
'DELETE FROM links WHERE id = $1 RETURNING id',
422+
'DELETE FROM links WHERE id = $1 RETURNING id, short_code, template_id',
397423
[id]
398424
);
399425
}
@@ -402,6 +428,10 @@ export async function linkRoutes(fastify: FastifyInstance) {
402428
throw new Error('Link not found');
403429
}
404430

431+
const deleted = result.rows[0];
432+
const templateSlug = await getTemplateSlug(deleted.template_id);
433+
await invalidateLinkResolutionCache(fastify.redis, deleted.short_code, templateSlug);
434+
405435
return { success: true };
406436
});
407437
}

0 commit comments

Comments
 (0)