Files
vngeoguessr/src/lib/upstash.js
T
tiennm99 53ee7957e7 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
2026-05-10 00:00:47 +07:00

171 lines
5.5 KiB
JavaScript

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;
}