Files
vngeoguessr/scripts/lib/leaderboard-migration.mjs
T
tiennm99 4770de51d4 feat(leaderboard): roll a guess up through the region tree
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.
2026-08-30 17:45:37 +07:00

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