diff --git a/src/utils/cache-key-params.utils.test.ts b/src/utils/cache-key-params.utils.test.ts index e8bd039..0d959cf 100644 --- a/src/utils/cache-key-params.utils.test.ts +++ b/src/utils/cache-key-params.utils.test.ts @@ -1,4 +1,4 @@ -import { buildCanonicalParamString } from './cache-key-params.utils'; +import { buildCanonicalParamString, buildCacheKey } from './cache-key-params.utils'; describe('buildCanonicalParamString()', () => { it('produces identical output regardless of input key order', () => { @@ -79,3 +79,45 @@ describe('buildCanonicalParamString()', () => { ); }); }); + +describe('buildCacheKey()', () => { + it('produces identical cache keys for identical params in different order', () => { + const keyA = buildCacheKey('creator:123', { + limit: 20, + sort: 'createdAt', + order: 'desc', + }); + const keyB = buildCacheKey('creator:123', { + order: 'desc', + limit: 20, + sort: 'createdAt', + }); + + expect(keyA).toBe(keyB); + }); + + it('produces different cache keys for different params', () => { + const keyA = buildCacheKey('creator:123', { limit: 20, sort: 'createdAt' }); + const keyB = buildCacheKey('creator:123', { limit: 10, sort: 'createdAt' }); + + expect(keyA).not.toBe(keyB); + }); + + it('returns a string of fixed length format', () => { + const key1 = buildCacheKey('creator:123', { limit: 20 }); + const key2 = buildCacheKey('creator:123', { + limit: 100, + offset: 500, + sort: 'verylongsortfieldname', + search: 'searching for something very long and detailed', + }); + + expect(typeof key1).toBe('string'); + expect(typeof key2).toBe('string'); + // Base prefix ('creator:123:') + 16 chars hash hex = 28 length + expect(key1.length).toBe(key2.length); + expect(key1).toMatch(/^creator:123:[a-f0-9]{16}$/); + expect(key2).toMatch(/^creator:123:[a-f0-9]{16}$/); + }); +}); + diff --git a/src/utils/cache-key-params.utils.ts b/src/utils/cache-key-params.utils.ts index d925af0..6e6c41e 100644 --- a/src/utils/cache-key-params.utils.ts +++ b/src/utils/cache-key-params.utils.ts @@ -15,14 +15,30 @@ * // => "limit:20:order:desc:sort:createdAt" */ export function buildCanonicalParamString( - params: Record + params: Record ): string { return Object.entries(params) - .filter( - (entry): entry is [string, string | number | boolean] => - entry[1] !== undefined - ) + .filter((entry): entry is [string, unknown] => entry[1] !== undefined) .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, value]) => `${key}:${value}`) + .map(([key, value]) => `${key}:${typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value)}`) .join(':'); } + +import { createHash } from 'crypto'; + +/** + * Generates a stable, fixed-length cache key from a base string and query parameter set. + * + * Param keys are sorted lexicographically before hashing, ensuring that parameter order + * does not affect the generated key. + * + * @param base - The base prefix string (e.g. creator ID or endpoint prefix) + * @param params - Object containing query parameter key-value pairs + * @returns A fixed-length string cache key + */ +export function buildCacheKey(base: string, params: Record): string { + const canonical = buildCanonicalParamString(params); + const hash = createHash('sha256').update(canonical).digest('hex').slice(0, 16); + return `${base}:${hash}`; +} +