mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-11 03:13:56 +00:00
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
This commit is contained in:
1 parent
f0f19508c4
commit
53ee7957e7
8 files changed
+423
-162
No files matched your search
+7
-5
@@ -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
|
||||
Generated
+16
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+90
-115
@@ -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>} 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<Array>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RedisClient>} Redis client instance
|
||||
*/
|
||||
export async function getRedis() {
|
||||
if (!redis) {
|
||||
redis = await createClient({ url: process.env.REDIS_URL }).connect();
|
||||
}
|
||||
return redis;
|
||||
}
|
||||
+18
-26
@@ -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<boolean>} Success status
|
||||
* Store a game session in Upstash.
|
||||
* @param {string} sessionId Unique session identifier.
|
||||
* @param {Object} sessionData Session data to store.
|
||||
* @returns {Promise<boolean>} 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<Object|null>} Session data or null if not found
|
||||
* Retrieve a game session from Upstash.
|
||||
* @param {string} sessionId Session identifier.
|
||||
* @returns {Promise<Object|null>} 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<boolean>} Success status
|
||||
* Delete a game session from Upstash.
|
||||
* @param {string} sessionId Session identifier.
|
||||
* @returns {Promise<boolean>} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<any|null>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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<number|null>}
|
||||
*/
|
||||
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<Array<{value: string, score: number}>>}
|
||||
*/
|
||||
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<number|null>}
|
||||
*/
|
||||
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<number|null>}
|
||||
*/
|
||||
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>} 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;
|
||||
}
|
||||
Reference in new issue
Block a user