From 53ee7957e7e294d27dd9eff13aa85de3dd5c38f1 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 10 May 2026 00:00:47 +0700 Subject: [PATCH] refactor(storage): migrate to Upstash REST SDK with KEY_PREFIX Replace node-redis with @upstash/redis REST client and route all keys through a centralized adapter that prepends KEY_PREFIX (default 'vngeoguessr:'). The new Upstash DB can now be safely shared with other Vercel projects without key collisions. - src/lib/upstash.js: new adapter with getJson/putJson/del + zAdd/zRange/ zScore/zRank/zRevRank/zRemRangeByRank helpers; reads UPSTASH_REDIS_REST_URL or KV_REST_API_URL aliases - src/lib/session.js, src/lib/leaderboard.js: use the adapter; public APIs unchanged so api routes stay untouched - src/lib/redis.js: removed - scripts/migrate-upstash.js: one-shot copy from old TCP DB to new REST DB, prepending the prefix; idempotent, with --dry-run flag - docs/tech-stack.md: reflect the new SDK and prefix model --- docs/tech-stack.md | 12 ++- package-lock.json | 16 +++ package.json | 1 + scripts/migrate-upstash.js | 121 ++++++++++++++++++++++ src/lib/leaderboard.js | 205 ++++++++++++++++--------------------- src/lib/redis.js | 16 --- src/lib/session.js | 44 ++++---- src/lib/upstash.js | 170 ++++++++++++++++++++++++++++++ 8 files changed, 423 insertions(+), 162 deletions(-) create mode 100644 scripts/migrate-upstash.js delete mode 100644 src/lib/redis.js create mode 100644 src/lib/upstash.js diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 57dabee..7d8d05a 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -17,10 +17,12 @@ - **Server-side Calculations**: All geographic processing on backend ## Data Storage & Session Management -- **Redis**: Complete session and leaderboard storage -- **Redis Sorted Sets**: Leaderboard ranking with automatic trimming +- **Upstash Redis (REST)**: Session and leaderboard storage via `@upstash/redis` SDK +- **Vercel Marketplace integration**: credentials provided as `KV_REST_API_URL`/`KV_REST_API_TOKEN` (or vanilla `UPSTASH_REDIS_REST_URL`/`UPSTASH_REDIS_REST_TOKEN`) +- **Project key prefix**: all physical keys carry `KEY_PREFIX` (default `vngeoguessr:`) so the same Upstash DB can be safely shared with other Vercel projects without collisions. Prefix is applied centrally in `src/lib/upstash.js`; callers pass logical keys only. +- **Sorted Sets**: Leaderboard ranking with automatic trimming (top 200) - **UUID v4**: Session identifier generation -- **30-minute Session Expiry**: Automatic Redis-based cleanup +- **30-minute Session Expiry**: Automatic TTL-based cleanup ## UI Components & Styling - **shadcn/ui**: Complete component library with "new-york" style @@ -41,7 +43,7 @@ - **@vercel/speed-insights**: Performance monitoring ## Key Dependencies -- **uuid**: v11.1.0 for unique session identifier generation -- **redis**: v5.8.0 for data persistence +- **uuid**: unique session identifier generation +- **@upstash/redis**: REST-based Upstash client (replaces node-redis); fluid-compute friendly, no socket pooling required - **JavaScript Only**: No TypeScript - pure JavaScript implementation - **Individual Parameters**: Functions use separate parameters instead of object destructuring \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6b59146..4b4579c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", "@turf/turf": "^7.2.0", + "@upstash/redis": "^1.38.0", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", "class-variance-authority": "^0.7.1", @@ -4837,6 +4838,15 @@ "win32" ] }, + "node_modules/@upstash/redis": { + "version": "1.38.0", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.38.0.tgz", + "integrity": "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/@vercel/analytics": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.5.0.tgz", @@ -9363,6 +9373,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", diff --git a/package.json b/package.json index 401407b..5fc9731 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", "@turf/turf": "^7.2.0", + "@upstash/redis": "^1.38.0", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", "class-variance-authority": "^0.7.1", diff --git a/scripts/migrate-upstash.js b/scripts/migrate-upstash.js new file mode 100644 index 0000000..0caf6e3 --- /dev/null +++ b/scripts/migrate-upstash.js @@ -0,0 +1,121 @@ +// One-shot migration: copy leaderboard sorted-set data from the OLD Redis DB +// to the NEW Upstash DB, prepending KEY_PREFIX (default 'vngeoguessr:') on +// write. The two DBs use different protocols on Vercel: +// +// OLD (source) → TCP protocol via OLD_STORAGE_REDIS_URL (node-redis) +// NEW (destination) → REST API via KV_REST_API_URL/_TOKEN (@upstash/redis) +// +// Sessions are NOT migrated (30-min TTL, ephemeral). +// +// Usage: +// 1. Pull env into .env.migrate: vercel env pull .env.migrate +// (gives you OLD_STORAGE_REDIS_URL, KV_REST_API_URL, KV_REST_API_TOKEN) +// 2. Optionally set KEY_PREFIX in .env.migrate (defaults to 'vngeoguessr:') +// 3. Dry run: node --env-file=.env.migrate scripts/migrate-upstash.js --dry-run +// 4. Live: node --env-file=.env.migrate scripts/migrate-upstash.js + +import { createClient } from 'redis'; +import { Redis } from '@upstash/redis'; + +const DEFAULT_KEY_PREFIX = 'vngeoguessr:'; +const SCAN_PATTERNS = ['leaderboard:*', 'distance:*']; + +const dryRun = process.argv.includes('--dry-run'); +const PREFIX = process.env.KEY_PREFIX ?? DEFAULT_KEY_PREFIX; + +function requireEnv(name) { + const v = process.env[name]; + if (!v) { + console.error(`Missing required env var: ${name}`); + process.exit(1); + } + return v; +} + +const oldUrl = requireEnv('OLD_STORAGE_REDIS_URL'); +const newRestUrl = process.env.UPSTASH_REDIS_REST_URL ?? requireEnv('KV_REST_API_URL'); +const newRestToken = process.env.UPSTASH_REDIS_REST_TOKEN ?? requireEnv('KV_REST_API_TOKEN'); + +const newClient = new Redis({ url: newRestUrl, token: newRestToken }); + +console.log('Upstash leaderboard migration'); +console.log(` source: OLD_STORAGE_REDIS_URL (TCP / node-redis)`); +console.log(` dest: ${newRestUrl}`); +console.log(` prefix: ${PREFIX} (must match Vercel runtime KEY_PREFIX)`); +console.log(` mode: ${dryRun ? 'DRY RUN (no writes)' : 'LIVE'}`); +console.log(''); + +// Normalize NEW DB SDK zrange withScores response into [{ score, member }, ...]. +// The migration script also uses this shape for ZADD args. +function reshapeWithScores(raw) { + if (!Array.isArray(raw) || raw.length === 0) return []; + if (typeof raw[0] === 'object' && raw[0] !== null && 'member' in raw[0]) { + return raw.map((entry) => ({ score: Number(entry.score), member: entry.member })); + } + const out = []; + for (let i = 0; i < raw.length; i += 2) { + out.push({ member: raw[i], score: Number(raw[i + 1]) }); + } + return out; +} + +async function main() { + const oldClient = await createClient({ url: oldUrl }).connect(); + + // Discover source keys via SCAN — node-redis exposes scanIterator. + const sourceKeys = new Set(); + for (const pattern of SCAN_PATTERNS) { + for await (const key of oldClient.scanIterator({ MATCH: pattern, COUNT: 200 })) { + // node-redis v5 yields strings (or arrays of strings depending on version). + if (Array.isArray(key)) key.forEach((k) => sourceKeys.add(k)); + else sourceKeys.add(key); + } + } + + const sortedKeys = Array.from(sourceKeys).sort(); + console.log(`Discovered ${sortedKeys.length} source keys:`); + for (const k of sortedKeys) console.log(` - ${k}`); + console.log(''); + + let totalMembers = 0; + let migratedKeys = 0; + + for (const key of sortedKeys) { + // node-redis v5 returns [{ value, score }, ...] for zRangeWithScores. + const raw = await oldClient.zRangeWithScores(key, 0, -1); + if (!raw || raw.length === 0) { + console.log(` [skip] ${key} (empty)`); + continue; + } + const entries = raw.map((e) => ({ score: Number(e.score), member: e.value })); + + const physicalKey = `${PREFIX}${key}`; + console.log(` [${dryRun ? 'dry' : 'write'}] ${physicalKey} (${entries.length} members)`); + totalMembers += entries.length; + migratedKeys += 1; + + if (dryRun) continue; + + // ZADD against NEW DB via REST. Chunk to keep request sane. + const CHUNK = 500; + for (let i = 0; i < entries.length; i += CHUNK) { + const chunk = entries.slice(i, i + CHUNK); + await newClient.zadd(physicalKey, ...chunk); + } + } + + await oldClient.quit(); + + console.log(''); + console.log('Summary:'); + console.log(` source keys discovered: ${sortedKeys.length}`); + console.log(` keys migrated: ${migratedKeys}`); + console.log(` members copied: ${totalMembers}`); + console.log(` prefix applied: ${PREFIX}`); + if (dryRun) console.log(' (dry run — no writes performed)'); +} + +main().catch((err) => { + console.error('Migration failed:', err); + process.exit(1); +}); diff --git a/src/lib/leaderboard.js b/src/lib/leaderboard.js index 065a4c2..9d3a4d2 100644 --- a/src/lib/leaderboard.js +++ b/src/lib/leaderboard.js @@ -1,159 +1,144 @@ -import { getRedis } from './redis.js'; +import { + getUpstash, + zAdd, + zScore, + zRangeWithScores, + zRank, + zRevRank, + zRemRangeByRank, +} from './upstash.js'; -// Leaderboard constants +// Leaderboard logical key constants (prefix is applied inside the adapter). const GLOBAL_LEADERBOARD_KEY = 'leaderboard:vietnam'; const CITY_LEADERBOARD_PREFIX = 'leaderboard:city:'; const DISTANCE_GLOBAL_KEY = 'distance:vietnam'; const DISTANCE_CITY_PREFIX = 'distance:city:'; const MAX_LEADERBOARD_SIZE = 200; -// Helper function to get city leaderboard key +// Helper: city-specific score leaderboard key. function getCityLeaderboardKey(cityCode) { return `${CITY_LEADERBOARD_PREFIX}${cityCode.toLowerCase()}`; } -// Helper function to get distance leaderboard key -function getDistanceLeaderboardKey(cityCode = null) { +// Helper: distance leaderboard key (global or city). +function getDistanceLeaderboardKey(cityCode) { return cityCode ? `${DISTANCE_CITY_PREFIX}${cityCode.toLowerCase()}` : DISTANCE_GLOBAL_KEY; } - /** - * Get leaderboard (global Vietnam or city-specific) - * @param {string|null} cityCode - City code for city leaderboard, null for global Vietnam - * @param {number} limit - Number of entries to return (default: 100) - * @param {string} type - Leaderboard type: 'score' or 'distance' (default: 'score') - * @returns {Promise} Array of leaderboard entries + * Get leaderboard (global Vietnam or city-specific). + * @param {string|null} cityCode City code, or null for global. + * @param {number} limit Number of entries to return. + * @param {string} type 'score' (highest first) or 'distance' (lowest first). + * @returns {Promise} Leaderboard entries. */ export async function getLeaderboard(cityCode = null, limit = 100, type = 'score') { try { - const redis = await getRedis(); - - // Determine which leaderboard to fetch + const h = getUpstash(); + let leaderboardKey; if (type === 'distance') { leaderboardKey = getDistanceLeaderboardKey(cityCode); } else { leaderboardKey = cityCode ? getCityLeaderboardKey(cityCode) : GLOBAL_LEADERBOARD_KEY; } - - // Get entries from the sorted set - const leaderboardData = await redis.zRangeWithScores(leaderboardKey, 0, limit - 1, { - REV: type === 'score' // Reverse for scores (highest first), normal for distance (lowest first) - }); - + + // Score leaderboards: REV=true (highest first). Distance: REV=false (lowest first). + const rev = type === 'score'; + const leaderboardData = await zRangeWithScores(h, leaderboardKey, 0, limit - 1, rev); + const entries = []; - - // Process each entry (leaderboardData is array of {value, score} objects) for (let i = 0; i < leaderboardData.length; i++) { const entry = leaderboardData[i]; - if (type === 'distance') { - // For distance leaderboards, parse the entry format: "username:distance:timestamp" + // Distance entries encoded as "username:distance:timestamp". const [username, distance, timestamp] = entry.value.split(':'); entries.push({ username, distance: Number(distance), timestamp: Number(timestamp), - rank: i + 1 + rank: i + 1, }); } else { - // For score leaderboards entries.push({ username: entry.value, score: Number(entry.score), - rank: i + 1 + rank: i + 1, }); } } - + return entries; - } catch (error) { console.error('Error fetching leaderboard:', error); - throw error; // Pass through the original error for better debugging + throw error; } } /** - * Submit a score to both city and global leaderboards - * @param {string} username - Player username - * @param {number} score - Score achieved (0-5) - * @param {string} cityCode - City code where the game was played - * @returns {Promise} Submission result with both city and global ranks + * Submit a score to both city and global score leaderboards. + * @param {string} username Player username. + * @param {number} score Score achieved (0-5). + * @param {string} cityCode City code. + * @returns {Promise} Submission result with both city and global ranks. */ export async function submitScore(username, score, cityCode) { try { - const redis = await getRedis(); - - // Validate input + const h = getUpstash(); + if (!username || score === undefined || !cityCode) { throw new Error('Missing required fields: username, score, cityCode'); } - + const trimmedUsername = username.trim(); const numScore = Number(score); - - // Get leaderboard keys + const globalKey = GLOBAL_LEADERBOARD_KEY; const cityKey = getCityLeaderboardKey(cityCode); - - // Get existing scores for both leaderboards + const [globalExisting, cityExisting] = await Promise.all([ - redis.zScore(globalKey, trimmedUsername), - redis.zScore(cityKey, trimmedUsername) + zScore(h, globalKey, trimmedUsername), + zScore(h, cityKey, trimmedUsername), ]); - - const globalCurrentTotal = globalExisting || 0; - const cityCurrentTotal = cityExisting || 0; - - // Calculate new totals - const globalNewTotal = globalCurrentTotal + numScore; - const cityNewTotal = cityCurrentTotal + numScore; - - // Update both leaderboards + + const globalNewTotal = (globalExisting || 0) + numScore; + const cityNewTotal = (cityExisting || 0) + numScore; + await Promise.all([ - redis.zAdd(globalKey, { - score: globalNewTotal, - value: trimmedUsername - }), - redis.zAdd(cityKey, { - score: cityNewTotal, - value: trimmedUsername - }) + zAdd(h, globalKey, globalNewTotal, trimmedUsername), + zAdd(h, cityKey, cityNewTotal, trimmedUsername), ]); - - // Keep only top 200 entries in both leaderboards + + // Trim to top MAX_LEADERBOARD_SIZE (highest scores). Sorted set is ascending, + // so remove the lowest-ranked entries that fall outside the top window. await Promise.all([ - redis.zRemRangeByRank(globalKey, 0, -(MAX_LEADERBOARD_SIZE + 1)), - redis.zRemRangeByRank(cityKey, 0, -(MAX_LEADERBOARD_SIZE + 1)) + zRemRangeByRank(h, globalKey, 0, -(MAX_LEADERBOARD_SIZE + 1)), + zRemRangeByRank(h, cityKey, 0, -(MAX_LEADERBOARD_SIZE + 1)), ]); - - // Calculate current ranks + const [globalRank, cityRank] = await Promise.all([ - redis.zRevRank(globalKey, trimmedUsername), - redis.zRevRank(cityKey, trimmedUsername) + zRevRank(h, globalKey, trimmedUsername), + zRevRank(h, cityKey, trimmedUsername), ]); - + const actualGlobalRank = globalRank !== null ? globalRank + 1 : null; const actualCityRank = cityRank !== null ? cityRank + 1 : null; - + return { success: true, global: { username: trimmedUsername, score: Number(globalNewTotal), - rank: actualGlobalRank + rank: actualGlobalRank, }, city: { username: trimmedUsername, score: Number(cityNewTotal), rank: actualCityRank, - cityCode: cityCode + cityCode: cityCode, }, - message: `Score added! City: ${cityNewTotal} (+${numScore}), Global: ${globalNewTotal} (+${numScore})` + message: `Score added! City: ${cityNewTotal} (+${numScore}), Global: ${globalNewTotal} (+${numScore})`, }; - } catch (error) { console.error('Error submitting score:', error); throw new Error(error.message || 'Failed to submit score'); @@ -161,78 +146,68 @@ export async function submitScore(username, score, cityCode) { } /** - * Submit a distance record to both city and global distance leaderboards - * @param {string} username - Player username - * @param {number} distance - Distance achieved in meters - * @param {string} cityCode - City code where the game was played - * @returns {Promise} Submission result with distance ranks + * Submit a distance record to both city and global distance leaderboards. + * @param {string} username Player username. + * @param {number} distance Distance achieved in meters. + * @param {string} cityCode City code. + * @returns {Promise} Submission result with distance ranks. */ export async function submitDistanceRecord(username, distance, cityCode) { try { - const redis = await getRedis(); - - // Validate input + const h = getUpstash(); + if (!username || distance === undefined || !cityCode) { throw new Error('Missing required fields: username, distance, cityCode'); } - + const trimmedUsername = username.trim(); const numDistance = Number(distance); const timestamp = Date.now(); - - // Create unique entry identifier for this specific record + + // Unique entry id so each distance attempt gets its own slot. const entryId = `${trimmedUsername}:${numDistance}:${timestamp}`; - - // Get distance leaderboard keys - const globalDistanceKey = getDistanceLeaderboardKey(); + + const globalDistanceKey = getDistanceLeaderboardKey(null); const cityDistanceKey = getDistanceLeaderboardKey(cityCode); - - // Add distance record to both leaderboards (using distance as score, lower is better) + + // Distance leaderboards: lower = better, so use distance directly as score. await Promise.all([ - redis.zAdd(globalDistanceKey, { - score: numDistance, - value: entryId - }), - redis.zAdd(cityDistanceKey, { - score: numDistance, - value: entryId - }) + zAdd(h, globalDistanceKey, numDistance, entryId), + zAdd(h, cityDistanceKey, numDistance, entryId), ]); - - // Keep only top 200 entries in both distance leaderboards + + // Trim to top MAX_LEADERBOARD_SIZE (lowest distances). Sorted set is ascending, + // so remove ranks beyond MAX_LEADERBOARD_SIZE (the worst entries). await Promise.all([ - redis.zRemRangeByRank(globalDistanceKey, MAX_LEADERBOARD_SIZE, -1), - redis.zRemRangeByRank(cityDistanceKey, MAX_LEADERBOARD_SIZE, -1) + zRemRangeByRank(h, globalDistanceKey, MAX_LEADERBOARD_SIZE, -1), + zRemRangeByRank(h, cityDistanceKey, MAX_LEADERBOARD_SIZE, -1), ]); - - // Calculate current ranks for this specific record + const [globalRank, cityRank] = await Promise.all([ - redis.zRank(globalDistanceKey, entryId), - redis.zRank(cityDistanceKey, entryId) + zRank(h, globalDistanceKey, entryId), + zRank(h, cityDistanceKey, entryId), ]); - + const actualGlobalRank = globalRank !== null ? globalRank + 1 : null; const actualCityRank = cityRank !== null ? cityRank + 1 : null; - + return { success: true, globalDistance: { username: trimmedUsername, distance: numDistance, - rank: actualGlobalRank + rank: actualGlobalRank, }, cityDistance: { username: trimmedUsername, distance: numDistance, rank: actualCityRank, - cityCode: cityCode + cityCode: cityCode, }, - message: `Distance record: ${numDistance}m` + message: `Distance record: ${numDistance}m`, }; - } catch (error) { console.error('Error submitting distance record:', error); throw new Error(error.message || 'Failed to submit distance record'); } } - diff --git a/src/lib/redis.js b/src/lib/redis.js deleted file mode 100644 index c412090..0000000 --- a/src/lib/redis.js +++ /dev/null @@ -1,16 +0,0 @@ -import { createClient } from 'redis'; - -// Global redis connection - created once and reused -let redis = null; - -/** - * Get the global Redis connection - * Creates connection on first call, then reuses it - * @returns {Promise} Redis client instance - */ -export async function getRedis() { - if (!redis) { - redis = await createClient({ url: process.env.REDIS_URL }).connect(); - } - return redis; -} \ No newline at end of file diff --git a/src/lib/session.js b/src/lib/session.js index edc66a7..546c7a7 100644 --- a/src/lib/session.js +++ b/src/lib/session.js @@ -1,23 +1,20 @@ -import { getRedis } from './redis.js'; +import { getUpstash, getJson, putJson, del } from './upstash.js'; // Game session constants const SESSION_KEY_PREFIX = 'session:'; const SESSION_EXPIRY = 30 * 60; // 30 minutes in seconds /** - * Store a game session in Redis - * @param {string} sessionId - Unique session identifier - * @param {Object} sessionData - Session data to store - * @returns {Promise} Success status + * Store a game session in Upstash. + * @param {string} sessionId Unique session identifier. + * @param {Object} sessionData Session data to store. + * @returns {Promise} Success status. */ export async function storeGameSession(sessionId, sessionData) { try { - const redis = await getRedis(); + const h = getUpstash(); const key = SESSION_KEY_PREFIX + sessionId; - - // Store session data with expiry - await redis.setEx(key, SESSION_EXPIRY, JSON.stringify(sessionData)); - + await putJson(h, key, sessionData, SESSION_EXPIRY); return true; } catch (error) { console.error('Error storing game session:', error); @@ -26,18 +23,15 @@ export async function storeGameSession(sessionId, sessionData) { } /** - * Retrieve a game session from Redis - * @param {string} sessionId - Session identifier - * @returns {Promise} Session data or null if not found + * Retrieve a game session from Upstash. + * @param {string} sessionId Session identifier. + * @returns {Promise} Session data or null if not found. */ export async function getGameSession(sessionId) { try { - const redis = await getRedis(); + const h = getUpstash(); const key = SESSION_KEY_PREFIX + sessionId; - - const sessionData = await redis.get(key); - - return sessionData ? JSON.parse(sessionData) : null; + return await getJson(h, key); } catch (error) { console.error('Error retrieving game session:', error); throw error; @@ -45,20 +39,18 @@ export async function getGameSession(sessionId) { } /** - * Delete a game session from Redis - * @param {string} sessionId - Session identifier - * @returns {Promise} Success status + * Delete a game session from Upstash. + * @param {string} sessionId Session identifier. + * @returns {Promise} Success status. */ export async function deleteGameSession(sessionId) { try { - const redis = await getRedis(); + const h = getUpstash(); const key = SESSION_KEY_PREFIX + sessionId; - - await redis.del(key); - + await del(h, key); return true; } catch (error) { console.error('Error deleting game session:', error); throw error; } -} \ No newline at end of file +} diff --git a/src/lib/upstash.js b/src/lib/upstash.js new file mode 100644 index 0000000..b8498b3 --- /dev/null +++ b/src/lib/upstash.js @@ -0,0 +1,170 @@ +import { Redis } from '@upstash/redis'; + +// Upstash Redis adapter (REST SDK). +// +// Logical key namespace (callers pass these unprefixed): +// session:{sessionId} string, TTL 30 min +// leaderboard:vietnam sorted set (score) +// leaderboard:city:{cityCode} sorted set (score) +// distance:vietnam sorted set (distance) +// distance:city:{cityCode} sorted set (distance) +// +// Multi-tenancy: every physical Upstash key carries KEY_PREFIX (default +// 'vngeoguessr:') so this project can safely share an Upstash DB with other +// Vercel projects without key collisions. Prefix applied transparently here; +// callers never see it. + +const DEFAULT_KEY_PREFIX = 'vngeoguessr:'; + +let handle = null; + +/** + * Get the global Upstash handle (singleton). + * Accepts either UPSTASH_REDIS_REST_URL/_TOKEN (vanilla Upstash) or + * KV_REST_API_URL/_TOKEN (Vercel Marketplace integration alias). + * @returns {{ client: Redis, prefix: string }} + */ +export function getUpstash() { + if (handle) return handle; + const url = process.env.UPSTASH_REDIS_REST_URL ?? process.env.KV_REST_API_URL; + const token = process.env.UPSTASH_REDIS_REST_TOKEN ?? process.env.KV_REST_API_TOKEN; + if (!url) throw new Error('UPSTASH_REDIS_REST_URL or KV_REST_API_URL is required'); + if (!token) throw new Error('UPSTASH_REDIS_REST_TOKEN or KV_REST_API_TOKEN is required'); + const client = new Redis({ url, token }); + const prefix = process.env.KEY_PREFIX ?? DEFAULT_KEY_PREFIX; + handle = { client, prefix }; + return handle; +} + +// Build the physical Upstash key from a logical key by prepending the prefix. +function pkey(h, key) { + return `${h.prefix}${key}`; +} + +/** + * Read a JSON value. Returns null if key missing. + * @param {{ client: Redis, prefix: string }} h + * @param {string} key + * @returns {Promise} + */ +export async function getJson(h, key) { + const value = await h.client.get(pkey(h, key)); + if (value == null) return null; + // Upstash SDK auto-parses JSON in some versions, returns string in others. + return typeof value === 'string' ? JSON.parse(value) : value; +} + +/** + * Write a JSON value with optional TTL (seconds). + * @param {{ client: Redis, prefix: string }} h + * @param {string} key + * @param {any} value + * @param {number|null} ttlSeconds Optional TTL in seconds. + * @returns {Promise} + */ +export async function putJson(h, key, value, ttlSeconds) { + const setOpts = ttlSeconds != null ? { ex: ttlSeconds } : undefined; + await h.client.set(pkey(h, key), JSON.stringify(value), setOpts); +} + +/** + * Delete a key. + * @param {{ client: Redis, prefix: string }} h + * @param {string} key + * @returns {Promise} + */ +export async function del(h, key) { + await h.client.del(pkey(h, key)); +} + +/** + * Add a single member with score to a sorted set. + * @param {{ client: Redis, prefix: string }} h + * @param {string} key + * @param {number} score + * @param {string} member + * @returns {Promise} + */ +export async function zAdd(h, key, score, member) { + await h.client.zadd(pkey(h, key), { score, member }); +} + +/** + * Get a member's score, or null if absent. + * @param {{ client: Redis, prefix: string }} h + * @param {string} key + * @param {string} member + * @returns {Promise} + */ +export async function zScore(h, key, member) { + const result = await h.client.zscore(pkey(h, key), member); + return result == null ? null : Number(result); +} + +/** + * Range query on a sorted set, returning [{ value, score }, ...]. + * @param {{ client: Redis, prefix: string }} h + * @param {string} key + * @param {number} start + * @param {number} stop + * @param {boolean} rev True for descending (highest score first). + * @returns {Promise>} + */ +export async function zRangeWithScores(h, key, start, stop, rev) { + const raw = await h.client.zrange(pkey(h, key), start, stop, { + rev, + withScores: true, + }); + return reshapeWithScores(raw); +} + +/** + * Get a member's rank (ascending order). Null if absent. + * @param {{ client: Redis, prefix: string }} h + * @param {string} key + * @param {string} member + * @returns {Promise} + */ +export async function zRank(h, key, member) { + const result = await h.client.zrank(pkey(h, key), member); + return result == null ? null : Number(result); +} + +/** + * Get a member's rank (descending order). Null if absent. + * @param {{ client: Redis, prefix: string }} h + * @param {string} key + * @param {string} member + * @returns {Promise} + */ +export async function zRevRank(h, key, member) { + const result = await h.client.zrevrank(pkey(h, key), member); + return result == null ? null : Number(result); +} + +/** + * Trim a sorted set to a rank range. + * @param {{ client: Redis, prefix: string }} h + * @param {string} key + * @param {number} start + * @param {number} stop + * @returns {Promise} Number of removed members. + */ +export async function zRemRangeByRank(h, key, start, stop) { + return await h.client.zremrangebyrank(pkey(h, key), start, stop); +} + +// Normalize the two shapes Upstash SDK may return for zrange + withScores: +// newer: [{ score, member }, ...] +// older: [member, score, member, score, ...] +function reshapeWithScores(raw) { + if (!Array.isArray(raw) || raw.length === 0) return []; + if (typeof raw[0] === 'object' && raw[0] !== null && 'member' in raw[0]) { + return raw.map((entry) => ({ value: entry.member, score: Number(entry.score) })); + } + const out = []; + for (let i = 0; i < raw.length; i += 2) { + out.push({ value: raw[i], score: Number(raw[i + 1]) }); + } + return out; +}