import crypto from 'node:crypto'; const SYNC_SET = 'plaza:sync:post_ids'; const FEED_GEN_KEY = 'plaza:feed:gen'; function hashIp(ip) { return crypto.createHash('sha256').update(String(ip ?? 'unknown')).digest('hex').slice(0, 16); } function counterKey(postId, field) { return `plaza:post:${postId}:${field}`; } function viewDedupKey(postId, ipHash) { return `plaza:view:${ipHash}:${postId}`; } function feedCacheKey(gen, sort, categorySlug, cursor) { const category = categorySlug || 'all'; const page = cursor || 'root'; return `plaza:feed:${sort}:${category}:cursor:${page}:g${gen}`; } export function createNoopPlazaRedis(pool = null) { const viewDedup = new Map(); return { enabled: false, async connect() {}, async disconnect() {}, async recordView(postId, ip) { if (!pool) return { counted: false }; const ipHash = hashIp(ip); const dedupKey = `${ipHash}:${postId}`; if (viewDedup.has(dedupKey)) return { counted: false }; viewDedup.set(dedupKey, Date.now()); setTimeout(() => viewDedup.delete(dedupKey), 86_400_000).unref?.(); const now = Date.now(); const [result] = await pool.query( `UPDATE plaza_posts SET view_count = view_count + 1, updated_at = ? WHERE id = ? AND status = 'published'`, [now, postId], ); return { counted: (result.affectedRows ?? 0) > 0 }; }, async getFeedCache() { return null; }, async setFeedCache() {}, async invalidateFeedCaches() {}, async syncCountersToMySQL() { return { synced: 0 }; }, async checkCommentRateLimit() { return { allowed: true }; }, async markCommentRateLimit() {}, }; } export async function createPlazaRedis(redisUrl, pool) { if (!redisUrl) return createNoopPlazaRedis(pool); const { createClient } = await import('redis'); const client = createClient({ url: redisUrl }); client.on('error', (error) => { console.error('Plaza Redis error:', error.message); }); await client.connect(); const getFeedGen = async () => Number((await client.get(FEED_GEN_KEY)) ?? 0); return { enabled: true, client, async disconnect() { if (client.isOpen) await client.disconnect(); }, async recordView(postId, ip) { const ipHash = hashIp(ip); const dedup = viewDedupKey(postId, ipHash); const inserted = await client.set(dedup, '1', { NX: true, EX: 86_400 }); if (!inserted) return { counted: false }; await client.incr(counterKey(postId, 'view_count')); await client.sAdd(SYNC_SET, postId); return { counted: true }; }, async getFeedCache(sort, categorySlug, cursor) { const gen = await getFeedGen(); const key = feedCacheKey(gen, sort, categorySlug, cursor); const raw = await client.get(key); if (!raw) return null; try { return JSON.parse(raw); } catch { return null; } }, async setFeedCache(sort, categorySlug, cursor, payload, ttlSeconds = 300) { const gen = await getFeedGen(); const key = feedCacheKey(gen, sort, categorySlug, cursor); await client.set(key, JSON.stringify(payload), { EX: ttlSeconds }); }, async invalidateFeedCaches() { await client.incr(FEED_GEN_KEY); }, async syncCountersToMySQL() { const postIds = await client.sMembers(SYNC_SET); if (postIds.length === 0) return { synced: 0 }; let synced = 0; const now = Date.now(); for (const postId of postIds) { const delta = Number((await client.get(counterKey(postId, 'view_count'))) ?? 0); if (delta <= 0) { await client.sRem(SYNC_SET, postId); continue; } await pool.query( `UPDATE plaza_posts SET view_count = view_count + ?, updated_at = ? WHERE id = ?`, [delta, now, postId], ); await client.decrBy(counterKey(postId, 'view_count'), delta); const remaining = Number((await client.get(counterKey(postId, 'view_count'))) ?? 0); if (remaining <= 0) { await client.del(counterKey(postId, 'view_count')); await client.sRem(SYNC_SET, postId); } synced += 1; } return { synced }; }, async checkCommentRateLimit(userId, postId, maxPerHour = 10) { const key = `plaza:comment_rate:${userId}:${postId}`; const count = Number((await client.get(key)) ?? 0); return { allowed: count < maxPerHour, count }; }, async markCommentRateLimit(userId, postId) { const key = `plaza:comment_rate:${userId}:${postId}`; const count = await client.incr(key); if (count === 1) await client.expire(key, 3600); return count; }, }; }