/** * `/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>>; /** RTDB update는 한 번에 너무 많은 경로를 담으면 실패하므로 나눠 커밋한다. */ const CHUNK = 500; async function main(): Promise { 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 = {}; let uidCount = 0; let entryCount = 0; const dates = new Set(); 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 = {}; 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 { 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); });