/** * Redis cache key semantic types and constants. * * This file provides TypeScript types and constants to enforce semantic correctness * when working with Redis cache keys. Use these to ensure: * - Video ID keys always contain video IDs (strings), never JSON * - Tag keys always contain Tag JSON, not video IDs * - Operations match the key type (LRANGE for LIST, ZREVRANGE for ZSET, etc.) * * SEE: libs/common/src/cache/CACHE_SEMANTICS.md for detailed documentation */ /** * Marker type for video ID strings (Mongo ObjectId format). * Use this to distinguish video IDs from other string types. * * Example: * const videoIds: VideoId[] = ['64a2b3c4d5e6f7g8h9i0j1k2', 'abc123...'] */ export type VideoId = string & { readonly __videoId: true }; /** * Marker type for tag ID strings. */ export type TagId = string & { readonly __tagId: true }; /** * Marker type for category ID strings. */ export type CategoryId = string & { readonly __categoryId: true }; /** * Marker type for channel ID strings. */ export type ChannelId = string & { readonly __channelId: true }; /** * Helper to create a VideoId (for cases where you have a validated string). * * Example: * const videoId = asVideoId('64a2b3c4d5e6f7g8h9i0j1k2'); * const listKey = tsCacheKeys.video.categoryList('ch-1'); * const videoIds = await redis.lrange(listKey, 0, -1) as VideoId[]; */ export function asVideoId(value: string): VideoId { return value as VideoId; } export function asTagId(value: string): TagId { return value as TagId; } export function asCategoryId(value: string): CategoryId { return value as CategoryId; } export function asChannelId(value: string): ChannelId { return value as ChannelId; } /** * Redis cache key semantic classification. * * Use these constants to document the expected type and operations for a key. */ export enum RedisKeySemanticType { /** * LIST of video IDs (strings only, no JSON). * Operations: LRANGE, RPUSH, LPUSH * Example: box:app:video:category:list:cat-1 → ['vid-1', 'vid-2', ...] */ VideoIdList = 'VideoIdList', /** * LIST of video IDs with tag filter (strings only, no JSON). * Operations: LRANGE, RPUSH, LPUSH * Example: box:app:video:tag:list:cat-1:tag-1 → ['vid-1', 'vid-3', ...] */ FilteredVideoIdList = 'FilteredVideoIdList', /** * ZSET of video IDs with timestamp scores for pagination. * Operations: ZREVRANGE, ZADD * Example: box:app:video:list:category:ch-1:cat-1:latest * Members: video IDs, Scores: timestamps */ VideoIdZSet = 'VideoIdZSet', /** * LIST of Tag JSON objects. * Operations: LRANGE, RPUSH, LPUSH (parse each element as JSON) * Example: box:app:tag:list:cat-1 → ['{"id":"tag-1","name":"Sports",...}', ...] */ TagJsonList = 'TagJsonList', /** * STRING containing JSON array of Tag objects. * Operations: GET, SET (parse result as JSON) * Example: box:app:tag:all → '[{"id":"tag-1","name":"Sports",...}, ...]' */ TagJsonString = 'TagJsonString', /** * STRING containing JSON object (video detail, category, etc.) * Operations: GET, SET (parse result as JSON) * Example: box:app:video:detail:vid-1 → '{"id":"vid-1","title":"Video 1",...}' */ DetailJson = 'DetailJson', /** * LIST of generic items (various uses). * Operations: LRANGE, RPUSH, LPUSH * Example: box:app:video:list:home:ch-1:latest → ['vid-1', 'vid-2', ...] */ ItemList = 'ItemList', } /** * Redis command type for a given semantic type. * * Maps semantic types to their primary read/write operations. */ export const RedisCommandsForSemanticType: Record< RedisKeySemanticType, { read: string[]; write: string[] } > = { [RedisKeySemanticType.VideoIdList]: { read: ['LRANGE'], write: ['DEL', 'RPUSH', 'LPUSH'], }, [RedisKeySemanticType.FilteredVideoIdList]: { read: ['LRANGE'], write: ['DEL', 'RPUSH', 'LPUSH'], }, [RedisKeySemanticType.VideoIdZSet]: { read: ['ZREVRANGE', 'ZRANGE'], write: ['DEL', 'ZADD'], }, [RedisKeySemanticType.TagJsonList]: { read: ['LRANGE'], write: ['DEL', 'RPUSH', 'LPUSH'], }, [RedisKeySemanticType.TagJsonString]: { read: ['GET'], write: ['SET', 'DEL'], }, [RedisKeySemanticType.DetailJson]: { read: ['GET'], write: ['SET', 'DEL'], }, [RedisKeySemanticType.ItemList]: { read: ['LRANGE'], write: ['DEL', 'RPUSH', 'LPUSH'], }, }; /** * Cache key metadata for documentation and validation. * * Define the semantics of each cache key pattern to enforce type safety * and prevent common mistakes. */ export interface CacheKeyMetadata { /** * Human-readable description of the key. */ description: string; /** * Semantic type (determines Redis type and operations). */ semanticType: RedisKeySemanticType; /** * Example key (with placeholders like {categoryId}). */ exampleKey: string; /** * Which builder creates/updates this key. */ builtBy: string; /** * Which service/component reads this key. */ readBy: string[]; /** * Notes or special considerations. */ notes?: string; } /** * Metadata registry for all major cache keys. * * Use this for validation, documentation generation, or runtime checks. */ export const CacheKeyMetadataRegistry: Record = { 'app:video:category:list': { description: 'All video IDs in a category, in business order', semanticType: RedisKeySemanticType.VideoIdList, exampleKey: 'box:app:video:category:list:{categoryId}', builtBy: 'VideoCategoryCacheBuilder.buildCategoryListForChannel()', readBy: ['VideoService.getCategoryListForChannel()'], notes: 'LIST of video IDs only (strings), never JSON objects. Used by category detail API.', }, 'app:video:tag:list': { description: 'Video IDs in category filtered by tag, in business order', semanticType: RedisKeySemanticType.FilteredVideoIdList, exampleKey: 'box:app:video:tag:list:{categoryId}:{tagId}', builtBy: 'VideoCategoryCacheBuilder.buildTagListForCategory()', readBy: ['VideoService.getTagListForCategory()'], notes: 'LIST of video IDs only (strings), subset of category videos. Distinct from tag.all().', }, 'app:video:list:category': { description: 'Scored video IDs for a category (for pagination with consistent ordering)', semanticType: RedisKeySemanticType.VideoIdZSet, exampleKey: 'box:app:video:list:category:{channelId}:{categoryId}:{sort}', builtBy: 'VideoListCacheBuilder.buildCategoryPoolsForChannel()', readBy: ['VideoService.getVideosByCategoryWithPaging()'], notes: 'ZSET with video IDs as members and timestamp scores. Use ZREVRANGE for pagination.', }, 'app:video:list:tag': { description: 'Scored video IDs for a tag (for pagination with ordering)', semanticType: RedisKeySemanticType.VideoIdZSet, exampleKey: 'box:app:video:list:tag:{channelId}:{tagId}:{sort}', builtBy: 'VideoListCacheBuilder.buildTagPoolsForChannel()', readBy: ['VideoService.getVideosByTagWithPaging()'], notes: 'ZSET with video IDs as members and timestamp scores. Use ZREVRANGE for pagination.', }, 'app:video:list:home': { description: 'Home page section videos', semanticType: RedisKeySemanticType.ItemList, exampleKey: 'box:app:video:list:home:{channelId}:{section}', builtBy: 'VideoListCacheBuilder.buildHomeSectionsForChannel()', readBy: ['VideoService.getHomeSectionVideos()'], notes: 'LIST of video IDs, top N most recent across all categories.', }, 'app:tag:all': { description: 'Global pool of all tags (for tag filter suggestions)', semanticType: RedisKeySemanticType.TagJsonString, exampleKey: 'box:app:tag:all', builtBy: 'TagCacheBuilder.buildAll()', readBy: ['TagCacheService.getAllTags()'], notes: 'JSON array of Tag objects. ✅ Contains TAG JSON, NOT video IDs. Distinct from video.tagList().', }, 'app:video:detail': { description: 'Single video metadata/detail', semanticType: RedisKeySemanticType.DetailJson, exampleKey: 'box:app:video:detail:{videoId}', builtBy: 'VideoDetailCacheBuilder (if applicable)', readBy: ['VideoService.getVideoDetail()'], }, }; /** * Validation helper: Check if a key is expected to contain video IDs. * * @param keyPattern - The cache key pattern (e.g., 'app:video:category:list') * @returns true if the key contains video IDs (not JSON) */ export function isVideoIdKey(keyPattern: string): boolean { const metadata = CacheKeyMetadataRegistry[keyPattern]; if (!metadata) return false; return ( metadata.semanticType === RedisKeySemanticType.VideoIdList || metadata.semanticType === RedisKeySemanticType.FilteredVideoIdList || metadata.semanticType === RedisKeySemanticType.VideoIdZSet ); } /** * Validation helper: Check if a key is expected to contain JSON objects. * * @param keyPattern - The cache key pattern * @returns true if the key contains JSON (not raw strings) */ export function isJsonKey(keyPattern: string): boolean { const metadata = CacheKeyMetadataRegistry[keyPattern]; if (!metadata) return false; return ( metadata.semanticType === RedisKeySemanticType.TagJsonList || metadata.semanticType === RedisKeySemanticType.TagJsonString || metadata.semanticType === RedisKeySemanticType.DetailJson ); } /** * Validation helper: Get Redis operation types for a key pattern. * * @param keyPattern - The cache key pattern * @returns Object with read and write operations */ export function getOperationsForKey( keyPattern: string, ): { read: string[]; write: string[] } | null { const metadata = CacheKeyMetadataRegistry[keyPattern]; if (!metadata) return null; return RedisCommandsForSemanticType[metadata.semanticType]; }