- Fix Next.js build failure: remove duplicate route at (dashboard)/listings/[id] that conflicted with (public)/listings/[id] (same URL path in two route groups) - Fix 772 ESLint errors: auto-fix import ordering (import-x/order), remove unused imports/variables, convert empty interfaces to type aliases, replace require() with ESM imports, fix consistent-type-imports violations - Add CLAUDE.md for developer onboarding documentation - All checks pass: 0 lint errors, typecheck clean, 230 tests passing, build success Co-Authored-By: Paperclip <noreply@paperclip.ing>
109 lines
3.5 KiB
TypeScript
109 lines
3.5 KiB
TypeScript
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
|
import { InjectMetric } from '@willsoto/nestjs-prometheus';
|
|
import { type Counter } from 'prom-client';
|
|
import { type LoggerService } from './logger.service';
|
|
import { type RedisService } from './redis.service';
|
|
|
|
export const CACHE_HIT_TOTAL = 'cache_hit_total';
|
|
export const CACHE_MISS_TOTAL = 'cache_miss_total';
|
|
|
|
export enum CacheTTL {
|
|
/** Listing detail — moderate TTL, invalidated on mutation */
|
|
LISTING_DETAIL = 300, // 5 min
|
|
/** Search results — short TTL due to high variability */
|
|
SEARCH_RESULTS = 60, // 1 min
|
|
/** Market analytics — long TTL, data changes infrequently */
|
|
MARKET_DATA = 1800, // 30 min
|
|
/** User profile — moderate TTL, invalidated on mutation */
|
|
USER_PROFILE = 600, // 10 min
|
|
}
|
|
|
|
export enum CachePrefix {
|
|
LISTING = 'cache:listing',
|
|
SEARCH = 'cache:search',
|
|
MARKET_REPORT = 'cache:market:report',
|
|
MARKET_TREND = 'cache:market:trend',
|
|
MARKET_HEATMAP = 'cache:market:heatmap',
|
|
MARKET_DISTRICT = 'cache:market:district',
|
|
USER_PROFILE = 'cache:user:profile',
|
|
}
|
|
|
|
@Injectable()
|
|
export class CacheService implements OnModuleInit {
|
|
constructor(
|
|
private readonly redis: RedisService,
|
|
private readonly logger: LoggerService,
|
|
@InjectMetric(CACHE_HIT_TOTAL) private readonly cacheHitCounter: Counter,
|
|
@InjectMetric(CACHE_MISS_TOTAL) private readonly cacheMissCounter: Counter,
|
|
) {}
|
|
|
|
onModuleInit(): void {
|
|
this.logger.log('CacheService initialized', 'CacheService');
|
|
}
|
|
|
|
/**
|
|
* Cache-aside: get from cache, or execute loader and store result.
|
|
*/
|
|
async getOrSet<T>(
|
|
key: string,
|
|
loader: () => Promise<T>,
|
|
ttlSeconds: number,
|
|
resource: string,
|
|
): Promise<T> {
|
|
try {
|
|
const cached = await this.redis.get(key);
|
|
if (cached !== null) {
|
|
this.cacheHitCounter.inc({ resource });
|
|
return JSON.parse(cached) as T;
|
|
}
|
|
} catch (err) {
|
|
this.logger.warn(`Cache read error for ${key}: ${(err as Error).message}`, 'CacheService');
|
|
}
|
|
|
|
this.cacheMissCounter.inc({ resource });
|
|
const result = await loader();
|
|
|
|
try {
|
|
await this.redis.set(key, JSON.stringify(result), ttlSeconds);
|
|
} catch (err) {
|
|
this.logger.warn(`Cache write error for ${key}: ${(err as Error).message}`, 'CacheService');
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/** Invalidate a single cache key. */
|
|
async invalidate(key: string): Promise<void> {
|
|
try {
|
|
await this.redis.del(key);
|
|
} catch (err) {
|
|
this.logger.warn(`Cache invalidate error for ${key}: ${(err as Error).message}`, 'CacheService');
|
|
}
|
|
}
|
|
|
|
/** Invalidate all keys matching a prefix using SCAN (non-blocking). */
|
|
async invalidateByPrefix(prefix: string): Promise<void> {
|
|
try {
|
|
const client = this.redis.getClient();
|
|
let cursor = '0';
|
|
do {
|
|
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', `${prefix}:*`, 'COUNT', 100);
|
|
cursor = nextCursor;
|
|
if (keys.length > 0) {
|
|
await client.del(...keys);
|
|
}
|
|
} while (cursor !== '0');
|
|
} catch (err) {
|
|
this.logger.warn(`Cache prefix invalidate error for ${prefix}: ${(err as Error).message}`, 'CacheService');
|
|
}
|
|
}
|
|
|
|
/** Build a deterministic cache key from prefix + parts. */
|
|
static buildKey(prefix: CachePrefix, ...parts: (string | number | undefined)[]): string {
|
|
const sanitized = parts
|
|
.filter((p) => p !== undefined)
|
|
.map((p) => String(p).toLowerCase().replace(/\s+/g, '_'));
|
|
return `${prefix}:${sanitized.join(':')}`;
|
|
}
|
|
}
|