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:
tiennm99 committed 2026-05-10 00:00:47 +07:00
1 parent f0f19508c4
commit 53ee7957e7
8 files changed
+423 -162

No files matched your search

+7 -5
View File
@@ -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
+16
View File
@@ -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",
+1
View File
@@ -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",
+121
View File
@@ -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
View File
@@ -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');
}
}
-16
View File
@@ -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
View File
@@ -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;
}
}
}
+170
View File
@@ -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;
}