mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-14 12:17:07 +00:00
A guess is credited to the district its panorama sits in, then to that district's province, then to Vietnam. Each level keeps its own board, so a player can top District 7 without touching the national table. Every existing point survives untouched. The country maps to the leaderboard:vietnam and distance:vietnam keys that already exist rather than to a new leaderboard:city:vn, so the national board keeps accumulating instead of restarting. The ':city:' segment in the key namespace is now a misnomer -- it holds district and province codes alike -- but renaming it to ':region:' would strand every key holding a player's history, so it stays, with a comment recording why. Da Lat and Duc Hoa keep their bare codes for the same reason. Ha Noi, Da Nang and Ho Chi Minh keep their totals on the province: those points predate districts and cannot be attributed to one, so their boards stay continuous while district boards start at zero. Levels are written in parallel. They are independent keys and none reads another's state, so serialising them would add two round trips of latency to every guess for nothing. Region codes and the leaderboard limit are validated inside the library rather than only in the routes. The key builder lowercases whatever it is handed straight into a Redis key, and the routes are not its only callers -- the migration script and anything added later bypass them entirely. The adapter gains scanKeys, which the migration needs to enumerate what it is about to touch. It applies the key prefix to the pattern and strips it from the results, because a caller that scanned 'leaderboard:*' directly would match nothing at all: every physical key carries the prefix, and an empty result is indistinguishable from an empty database. An empty KEY_PREFIX is no longer accepted, since enumeration with no prefix would reach every other project sharing the database. The migration seeds Lam Dong and Long An from Da Lat and Duc Hoa, whose history they inherit. It runs after the deploy, not before: migrating first leaves a window where a Da Lat guess credits the town and the country but not the province. It copies absolute scores so a second run converges rather than doubling, and empties the destination first so a player trimmed out of the source cannot survive in the copy with a stale score. An empty source is refused before that delete -- otherwise the destination is wiped and nothing written back, which reads as a clean no-op. Afterwards it confirms each destination matches its source and that nothing else went backwards, accepting that boards grow while the app serves traffic. Its backup can be restored through the same script. The migration logic lives in scripts/lib/ so its guards are reachable from a test rather than only by running it against a live database.
162 lines
6.0 KiB
JavaScript
162 lines
6.0 KiB
JavaScript
// Backfill logic for the leaderboard migration.
|
|
//
|
|
// Separated from the CLI in scripts/migrate-leaderboards.mjs so every guard is
|
|
// callable from a test. This is the one place in the region-tree change where
|
|
// existing player points can be destroyed, and a script whose safety checks are
|
|
// only reachable by running it against a live database is not testable at all.
|
|
|
|
import {
|
|
scanKeys,
|
|
zAdd,
|
|
zRangeWithScores,
|
|
zRemRangeByRank,
|
|
} from '../../src/lib/upstash.js';
|
|
import { leaderboardKeys } from '../../src/lib/leaderboard.js';
|
|
|
|
// child -> parent. Lam Dong and Long An are new nodes whose only child already
|
|
// holds the full history.
|
|
export const BACKFILLS = [
|
|
{ from: 'DL', to: 'LD', label: 'Da Lat -> Lam Dong' },
|
|
{ from: 'DH', to: 'LA', label: 'Duc Hoa -> Long An' },
|
|
];
|
|
|
|
export const PATTERNS = ['leaderboard:*', 'distance:*'];
|
|
|
|
/**
|
|
* Every key each backfill pair touches, score and distance.
|
|
*
|
|
* Built through the same key functions the app uses, so a change to key naming
|
|
* cannot leave the migration writing to the old names.
|
|
* @returns {Array<{fromKey: string, toKey: string, label: string}>} Pairs.
|
|
*/
|
|
export function backfillPairs() {
|
|
return BACKFILLS.flatMap(({ from, to, label }) => [
|
|
{ fromKey: leaderboardKeys.score(from), toKey: leaderboardKeys.score(to), label },
|
|
{ fromKey: leaderboardKeys.distance(from), toKey: leaderboardKeys.distance(to), label },
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Read every leaderboard key with its members and scores.
|
|
* @param {Object} h Upstash handle.
|
|
* @returns {Promise<Object>} Logical key -> [{value, score}].
|
|
*/
|
|
export async function exportAll(h) {
|
|
const snapshot = {};
|
|
for (const pattern of PATTERNS) {
|
|
for (const key of await scanKeys(h, pattern)) {
|
|
snapshot[key] = await zRangeWithScores(h, key, 0, -1, false);
|
|
}
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
/**
|
|
* Copy one sorted set over another, replacing what was there.
|
|
*
|
|
* Absolute scores, not increments, so re-running converges instead of doubling.
|
|
* The destination is emptied first: writing member-by-member would leave behind
|
|
* anyone present only in the destination, which the 200-entry trim makes
|
|
* reachable -- a player can be trimmed out of the source while surviving in the
|
|
* copy, and would then keep a stale score forever.
|
|
*
|
|
* An empty source is refused BEFORE the delete. Otherwise this wipes the
|
|
* destination and writes nothing back, which is indistinguishable from a
|
|
* successful no-op in the output and destroys real points.
|
|
* @param {Object} h Upstash handle.
|
|
* @param {string} fromKey Source logical key.
|
|
* @param {string} toKey Destination logical key.
|
|
* @param {boolean} apply False to report without writing.
|
|
* @returns {Promise<{members: number, replaced: number, skipped: boolean}>}
|
|
*/
|
|
export async function copySortedSet(h, fromKey, toKey, apply) {
|
|
const source = await zRangeWithScores(h, fromKey, 0, -1, false);
|
|
const destination = await zRangeWithScores(h, toKey, 0, -1, false);
|
|
|
|
if (source.length === 0) {
|
|
if (destination.length > 0) {
|
|
throw new Error(
|
|
`${fromKey} is empty but ${toKey} holds ${destination.length} members. ` +
|
|
'Copying would delete them. Refusing: check KEY_PREFIX and the source key.'
|
|
);
|
|
}
|
|
// Both empty is the ordinary case for a board nobody has played yet.
|
|
return { members: 0, replaced: 0, skipped: true };
|
|
}
|
|
|
|
if (apply) {
|
|
await zRemRangeByRank(h, toKey, 0, -1);
|
|
for (const { value, score } of source) await zAdd(h, toKey, score, value);
|
|
}
|
|
return { members: source.length, replaced: destination.length, skipped: false };
|
|
}
|
|
|
|
/**
|
|
* Confirm the backfill landed: each destination must now equal its source.
|
|
*
|
|
* The earlier version verified only the keys it did NOT write, which meant the
|
|
* copy itself was never checked.
|
|
* @param {Object} h Upstash handle.
|
|
* @returns {Promise<string[]>} Pairs that disagree, empty when all match.
|
|
*/
|
|
export async function verifyTargets(h) {
|
|
const mismatched = [];
|
|
for (const { fromKey, toKey } of backfillPairs()) {
|
|
const source = await zRangeWithScores(h, fromKey, 0, -1, false);
|
|
const destination = await zRangeWithScores(h, toKey, 0, -1, false);
|
|
if (JSON.stringify(source) !== JSON.stringify(destination)) {
|
|
mismatched.push(`${toKey} does not match ${fromKey}`);
|
|
}
|
|
}
|
|
return mismatched;
|
|
}
|
|
|
|
/**
|
|
* Check that no untouched key lost anything while the migration ran.
|
|
*
|
|
* Ordering is deploy-then-migrate, so the app is serving guesses throughout and
|
|
* boards legitimately grow mid-run. Demanding byte-equality would throw on a
|
|
* healthy migration the moment one player scores. Forward-only drift -- members
|
|
* added, scores never reduced, nobody removed -- is expected; anything else is
|
|
* not.
|
|
* @param {Object} before Snapshot taken before the writes.
|
|
* @param {Object} after Snapshot taken after.
|
|
* @returns {string[]} Keys that regressed, empty when all are intact.
|
|
*/
|
|
export function findRegressions(before, after) {
|
|
const targets = new Set(backfillPairs().map(({ toKey }) => toKey));
|
|
const regressed = [];
|
|
|
|
for (const [key, entries] of Object.entries(before)) {
|
|
if (targets.has(key)) continue;
|
|
const now = after[key];
|
|
if (!now) {
|
|
regressed.push(`${key} disappeared`);
|
|
continue;
|
|
}
|
|
const nowByMember = new Map(now.map((e) => [e.value, e.score]));
|
|
for (const { value, score } of entries) {
|
|
if (!nowByMember.has(value)) {
|
|
regressed.push(`${key}: member ${value} removed`);
|
|
} else if (nowByMember.get(value) < score) {
|
|
regressed.push(`${key}: ${value} fell from ${score} to ${nowByMember.get(value)}`);
|
|
}
|
|
}
|
|
}
|
|
return regressed;
|
|
}
|
|
|
|
/**
|
|
* Restore a snapshot, making the backup an actual rollback rather than a file.
|
|
* @param {Object} h Upstash handle.
|
|
* @param {Object} snapshot Output of exportAll.
|
|
* @returns {Promise<number>} Keys restored.
|
|
*/
|
|
export async function restore(h, snapshot) {
|
|
for (const [key, entries] of Object.entries(snapshot)) {
|
|
await zRemRangeByRank(h, key, 0, -1);
|
|
for (const { value, score } of entries) await zAdd(h, key, score, value);
|
|
}
|
|
return Object.keys(snapshot).length;
|
|
}
|