diff --git a/src/services/gameSyncService.ts b/src/services/gameSyncService.ts index 6df184a..b1b626f 100644 --- a/src/services/gameSyncService.ts +++ b/src/services/gameSyncService.ts @@ -52,26 +52,63 @@ export function toGameDoc(year: number, g: ScheduleGame): Game | null { return doc; } +/** + * 새로 도출한 game doc이 기존 문서와 동일한지 비교한다(merge write가 no-op이 될지 판정). + * + * `merge: true`로 쓰므로 새 doc에 **포함된 필드만** 비교한다. 새 doc이 생략한 필드 + * (예: scheduled 경기의 `winningTeamCode`)는 merge가 건드리지 않으므로 비교 대상에서 제외한다. + * `time`은 Timestamp이므로 millis로 비교한다. + */ +function gameDocUnchanged(existing: Partial, doc: Game): boolean { + for (const [key, value] of Object.entries(doc) as Array<[keyof Game, unknown]>) { + if (key === "time") { + const a = existing.time?.toMillis(); + const b = (value as Timestamp).toMillis(); + if (a !== b) return false; + } else if (existing[key] !== value) { + return false; + } + } + return true; +} + /** * 특정 연도·월의 KBO 경기 일정을 fetch하여 Firestore `games` 컬렉션에 upsert한다. * * - `merge: true`로 기존 문서의 수동 필드(예: 외부에서 채운 `winningTeamCode`)를 보존한다. - * - gameId가 없는 일정 엔트리는 스킵. + * - 사전 `getAll` read로 기존 값과 비교하여 **변경이 있는 문서만** write한다 — + * 대부분 불변인 일정 데이터를 매일 같은 값으로 덮어쓰는 무의미한 write를 줄인다. + * (값이 동일하면 `onGameCompleted` 트리거도 점화되지 않으므로 후속 영향 없음.) + * - gameId가 없는 일정 엔트리는 스킵. 더블헤더 등으로 gameId가 중복되면 마지막 값으로 합친다. * - * @returns upsert한 문서 개수 + * @returns 실제로 write가 일어난 문서 개수 */ export async function syncGamesForMonth(year: number, month: number): Promise { const result = await fetchScheduleFromKbo({ year, month }); - const batch = firestore.batch(); - let count = 0; + + // gameId 기준 dedupe (마지막 엔트리 우선) — 순차 set의 "last wins"와 동일. + const byGameId = new Map(); for (const g of result.games) { const doc = toGameDoc(year, g); if (!doc) continue; - batch.set(firestore.collection(COLLECTION).doc(g.gameId as string), doc, { - merge: true, - }); - count++; + byGameId.set(g.gameId as string, doc); } + if (byGameId.size === 0) return 0; + + const entries = [...byGameId.entries()]; + const refs = entries.map(([gameId]) => firestore.collection(COLLECTION).doc(gameId)); + const snaps = await firestore.getAll(...refs); + + const batch = firestore.batch(); + let count = 0; + snaps.forEach((snap, i) => { + const [gameId, doc] = entries[i]; + const existing = snap.exists ? (snap.data() as Partial) : null; + if (existing && gameDocUnchanged(existing, doc)) return; + batch.set(firestore.collection(COLLECTION).doc(gameId), doc, { merge: true }); + count++; + }); + if (count > 0) await batch.commit(); return count; }