- /userVotesByDate/{date}/{uid}/{gameId} fan-out 인덱스 추가 — RTDB에는 부분 노드 읽기가 없어서 dailyArchive가 하루치를 처리하려면 /userVotes 트리 전체(전 유저 x 전 보존 날짜)를 내려받아야 했다
- 투표를 쓰는 3곳(submitVote, changeVote, processGameEndWithGame의 판정 결과 주입)이 원자 update 하나로 원본과 미러를 함께 기록
- 삭제 경로도 동기화: deleteUserVoteGame은 양쪽에서, deleteUserVoteIndex는 유저의 날짜 목록을 먼저 읽어 해당 날짜 미러만 정리
- database.rules.json에 userVotesByDate·userVotesByDateMeta를 read/write false로 추가 — 교차 유저 데이터라 서버 전용
- 백필 스크립트 추가(npm run backfill:vote-index). dry-run 기본, --apply로 500건씩 청킹 기록, 멱등. 완료 시 /userVotesByDateMeta/backfilledAt 마커를 남겨 잡이 원본 스캔을 그만두는 근거로 쓴다
- 미러 정합성 테스트 8건 추가
86 lines
2.8 KiB
TypeScript
86 lines
2.8 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 # 실제 쓰기
|
|
*/
|
|
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 snap = await rtdb.ref("/userVotes").get();
|
|
if (!snap.exists()) {
|
|
console.log("no /userVotes data — nothing to backfill");
|
|
if (apply) 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);
|
|
});
|