- 스크립트 부트스트랩의 databaseURL 기본값에 리전 포함 — 기존 `...firebaseio.com`은 SDK가 "Database lives in a different region" 경고만 내고 다른 리전의 없는 DB를 보게 해서, 조회가 조용히 빈 결과를 돌려주고 실행이 멈췄다. 지금까지 스크립트가 RTDB를 안 써서 드러나지 않던 문제다. FIREBASE_DATABASE_REGION으로 덮어쓸 수 있다 - 백필 스크립트가 접속한 database URL을 먼저 출력하도록 변경 - 소스가 비었을 때 완료 마커를 자동으로 남기지 않고 --mark-empty를 요구하도록 변경 — 빈 조회는 "정말 투표가 없다"와 "엉뚱한 DB에 붙었다"를 구분하지 못하는데, 후자에서 마커가 켜지면 이후 dailyArchive가 인덱스만 믿고 전 유저를 건너뛴다
102 lines
3.7 KiB
TypeScript
102 lines
3.7 KiB
TypeScript
/**
|
|
* `/userVotes/{uid}/{date}/{gameId}` → `/userVotesByDate/{date}/{uid}/{gameId}` 백필.
|
|
*
|
|
* `dailyArchive`는 날짜별 인덱스만 읽는다(원래는 하루치를 위해 `/userVotes` 트리
|
|
* 전체를 내려받았다). 인덱스 도입 이전에 기록된 투표는 이 스크립트로 옮겨야
|
|
* 잡의 레거시 폴백 경로를 타지 않는다.
|
|
*
|
|
* 멱등하다 — 여러 번 돌려도 같은 값으로 덮어쓸 뿐이다.
|
|
*
|
|
* 실행:
|
|
* npx tsx scripts/backfill-user-votes-by-date.ts # 미리보기
|
|
* npx tsx scripts/backfill-user-votes-by-date.ts --apply # 실제 쓰기
|
|
* npx tsx scripts/backfill-user-votes-by-date.ts --apply --mark-empty # 소스가 빈 상태에서 마커만
|
|
*
|
|
* ⚠️ 소스(/userVotes)가 비었을 때는 마커를 자동으로 남기지 않는다. 빈 조회는
|
|
* "정말 투표가 없다"와 "엉뚱한 DB에 붙었다"를 구분하지 못하는데, 후자에서 마커가
|
|
* 켜지면 이후 아카이브가 인덱스만 믿고 전 유저를 건너뛴다. 비었다는 것을 사람이
|
|
* 확인했을 때만 --mark-empty로 명시한다.
|
|
*/
|
|
import "./_bootstrap";
|
|
import { rtdb } from "../src/firebase";
|
|
|
|
type VoteEntry = { team: string; result?: boolean; cancelled?: boolean };
|
|
type ByUid = Record<string, Record<string, Record<string, VoteEntry>>>;
|
|
|
|
/** RTDB update는 한 번에 너무 많은 경로를 담으면 실패하므로 나눠 커밋한다. */
|
|
const CHUNK = 500;
|
|
|
|
async function main(): Promise<void> {
|
|
const apply = process.argv.includes("--apply");
|
|
const markEmpty = process.argv.includes("--mark-empty");
|
|
|
|
console.log(`database: ${rtdb.app.options.databaseURL}`);
|
|
|
|
const snap = await rtdb.ref("/userVotes").get();
|
|
if (!snap.exists()) {
|
|
console.log("no /userVotes data — nothing to backfill");
|
|
if (!apply) return;
|
|
if (!markEmpty) {
|
|
console.log(
|
|
"마커를 남기지 않았다. 위 database URL이 맞고 정말 투표가 없다면 --mark-empty를 붙여 다시 실행할 것."
|
|
);
|
|
return;
|
|
}
|
|
await markBackfilled();
|
|
return;
|
|
}
|
|
|
|
const byUid = snap.val() as ByUid;
|
|
const updates: Record<string, unknown> = {};
|
|
let uidCount = 0;
|
|
let entryCount = 0;
|
|
const dates = new Set<string>();
|
|
|
|
for (const uid of Object.keys(byUid)) {
|
|
const byDate = byUid[uid] ?? {};
|
|
let touched = false;
|
|
for (const date of Object.keys(byDate)) {
|
|
const games = byDate[date] ?? {};
|
|
for (const gameId of Object.keys(games)) {
|
|
updates[`/userVotesByDate/${date}/${uid}/${gameId}`] = games[gameId];
|
|
entryCount += 1;
|
|
dates.add(date);
|
|
touched = true;
|
|
}
|
|
}
|
|
if (touched) uidCount += 1;
|
|
}
|
|
|
|
const paths = Object.keys(updates);
|
|
console.log(
|
|
`${entryCount} entries / ${uidCount} uids / ${dates.size} dates` +
|
|
(apply ? "" : " (dry run — pass --apply to write)")
|
|
);
|
|
if (!apply) return;
|
|
|
|
for (let i = 0; i < paths.length; i += CHUNK) {
|
|
const slice: Record<string, unknown> = {};
|
|
for (const p of paths.slice(i, i + CHUNK)) slice[p] = updates[p];
|
|
await rtdb.ref().update(slice);
|
|
console.log(` written ${Math.min(i + CHUNK, paths.length)}/${paths.length}`);
|
|
}
|
|
await markBackfilled();
|
|
console.log("backfill complete");
|
|
}
|
|
|
|
/**
|
|
* 완료 표시. 이 값이 있으면 `dailyArchive`는 인덱스가 비어 있어도
|
|
* "그날 투표가 없었다"로 해석하고 레거시 전체 스캔 폴백을 쓰지 않는다.
|
|
*/
|
|
async function markBackfilled(): Promise<void> {
|
|
await rtdb.ref("/userVotesByDateMeta/backfilledAt").set(new Date().toISOString());
|
|
console.log("marked /userVotesByDateMeta/backfilledAt");
|
|
}
|
|
|
|
main()
|
|
.then(() => process.exit(0))
|
|
.catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|