From 8186dbb5257772ae7447424c90f8d92b89b08ef6 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Fri, 1 May 2026 20:57:38 +0000 Subject: [PATCH] scripts: add backfill orchestrator for canonical layer Populates canonical tournaments, participants, tournament_results, and participant_surface_elos from per-window data for qualifying-points sports. Skips already-linked rows, is idempotent, and supports dry-run mode. Critical invariants enforced by the implementation: - qualifying_points_awarded is never copied to tournament_results - season_participant_qualifying_totals is never touched - conflicting surface-Elo values between windows raise a loud error (recorded in report.errors) rather than overwriting --- .../backfill-canonical-layer.test.ts | 450 ++++++++++++++++++ scripts/backfill-canonical-layer.ts | 358 ++++++++++++++ 2 files changed, 808 insertions(+) create mode 100644 scripts/__tests__/backfill-canonical-layer.test.ts create mode 100644 scripts/backfill-canonical-layer.ts diff --git a/scripts/__tests__/backfill-canonical-layer.test.ts b/scripts/__tests__/backfill-canonical-layer.test.ts new file mode 100644 index 0000000..fa93454 --- /dev/null +++ b/scripts/__tests__/backfill-canonical-layer.test.ts @@ -0,0 +1,450 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("~/database/context", () => ({ + database: vi.fn(), +})); + +import { runBackfill } from "../backfill-canonical-layer"; +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; + +/** + * Fixture rows used across multiple tests. + */ +const SPORT_ID = "sport-golf"; +const SEASON_ID = "season-golf-2026"; + +const GOLF_SEASON = { + id: SEASON_ID, + sportId: SPORT_ID, + scoringPattern: "qualifying_points", +} as const; + +function makeEvent(overrides: Partial> = {}) { + return { + id: "event-1", + sportsSeasonId: SEASON_ID, + tournamentId: null, + name: "Masters Tournament", + eventDate: "2026-04-09", + eventType: "tournament", + ...overrides, + }; +} + +function makeSeasonParticipant( + overrides: Partial> = {}, +) { + return { + id: "sp-1", + sportsSeasonId: SEASON_ID, + participantId: null, + name: "Scottie Scheffler", + ...overrides, + }; +} + +/** + * Builds a fake drizzle db that routes select/insert/update calls based + * on the target table. The caller supplies per-table select-result + * arrays (one per call to that table's select()). Inserts return the + * value they were given, extended with a stub id. Updates are recorded + * but otherwise no-op. + */ +interface TableState { + /** Queue of results to return for successive select() calls on this table. */ + selects?: unknown[][]; + /** Rows that insert().values().returning() should yield. */ + insertReturns?: unknown[]; + /** Incremented on each update() call. */ + updates?: { count: number }; +} + +interface FakeDbOptions { + tables: Map; + /** Records every insert call: tableRef → rows seen. */ + inserts?: Map; + /** Records every update call: tableRef → count. */ + updateCounts?: Map; +} + +function makeFakeDb(opts: FakeDbOptions) { + const { tables, inserts, updateCounts } = opts; + + // Each select() starts a new chain that ultimately resolves to the + // next queued selects[] entry for the passed table. The chain is a + // thenable via .limit/.where resolution. + const db = { + select: vi.fn().mockImplementation(() => { + let boundTable: unknown; + const chain: Record = { + from: vi.fn().mockImplementation((table: unknown) => { + boundTable = table; + return chain; + }), + where: vi.fn().mockImplementation(() => chain), + limit: vi.fn().mockImplementation(() => chain), + // Terminal: make chain thenable so `await chain` yields the queued result. + then: (resolve: (v: unknown) => unknown) => { + const state = tables.get(boundTable); + const queue = state?.selects ?? []; + const next = queue.shift() ?? []; + return Promise.resolve(next).then(resolve); + }, + }; + return chain; + }), + + insert: vi.fn().mockImplementation((table: unknown) => { + return { + values: vi.fn().mockImplementation((row: unknown) => { + if (inserts) { + const seen = inserts.get(table) ?? []; + seen.push(row); + inserts.set(table, seen); + } + const state = tables.get(table); + const returnRows = state?.insertReturns ?? [ + { ...(row as object), id: `generated-${Math.random()}` }, + ]; + const afterValues = { + returning: vi.fn().mockResolvedValue(returnRows), + // If the caller doesn't chain .returning(), make it awaitable anyway. + then: (resolve: (v: unknown) => unknown) => + Promise.resolve(returnRows).then(resolve), + }; + return afterValues; + }), + }; + }), + + update: vi.fn().mockImplementation((table: unknown) => { + if (updateCounts) { + updateCounts.set(table, (updateCounts.get(table) ?? 0) + 1); + } + return { + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }; + }), + }; + + return db; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ─── 1. empty DB no-op ───────────────────────────────────────────────────── + +describe("runBackfill - empty DB", () => { + it("returns zero counts when no qualifying-points seasons exist", async () => { + const tables = new Map([ + [schema.sportsSeasons, { selects: [[]] }], + ]); + vi.mocked(database).mockReturnValue(makeFakeDb({ tables }) as never); + + const report = await runBackfill({ dryRun: false }); + + expect(report).toMatchObject({ + tournamentsCreated: 0, + tournamentsLinked: 0, + participantsCreated: 0, + participantsLinked: 0, + tournamentResultsCreated: 0, + surfaceElosCreated: 0, + errors: [], + }); + }); +}); + +// ─── 2. golf window with 4 events creates 4 tournaments ─────────────────── + +describe("runBackfill - golf tournaments", () => { + it("creates 4 canonical tournaments for a golf season with 4 events", async () => { + const events = [ + makeEvent({ id: "ev-1", name: "Masters Tournament", eventDate: "2026-04-09" }), + makeEvent({ id: "ev-2", name: "PGA Championship", eventDate: "2026-05-14" }), + makeEvent({ id: "ev-3", name: "U.S. Open", eventDate: "2026-06-18" }), + makeEvent({ id: "ev-4", name: "The Open Championship", eventDate: "2026-07-16" }), + ]; + + const inserts = new Map(); + + // Build per-event insertReturns so each new tournament gets its id. + const tables = new Map([ + [schema.sportsSeasons, { selects: [[GOLF_SEASON]] }], + [ + schema.scoringEvents, + { + // 1st select: unlinked events; 2nd: refetch for results loop. + selects: [events, events.map((e, i) => ({ ...e, tournamentId: `t-${i + 1}` }))], + }, + ], + [ + schema.tournaments, + { + // One select-miss per event, all return [] (no existing row). + selects: [[], [], [], []], + // One insert per event; return sequential ids. + insertReturns: [{ id: "t-1" }], + }, + ], + [schema.seasonParticipants, { selects: [[]] }], + [schema.eventResults, { selects: [[], [], [], []] }], + [schema.seasonParticipantSurfaceElos, { selects: [[]] }], + ]); + + // Because our insertReturns is consumed once per test, re-queue per event + // by overriding insert behaviour via the fake db options. + const tournamentIds = ["t-1", "t-2", "t-3", "t-4"]; + let insertIdx = 0; + const db: ReturnType = makeFakeDb({ tables, inserts }); + // Override insert for tournaments specifically to return sequential ids. + db.insert.mockImplementation((table: unknown) => ({ + values: vi.fn().mockImplementation((row: unknown) => { + const seen = inserts.get(table) ?? []; + seen.push(row); + inserts.set(table, seen); + let returnRows: unknown[]; + if (table === schema.tournaments) { + returnRows = [{ ...(row as object), id: tournamentIds[insertIdx++] }]; + } else { + returnRows = [{ ...(row as object), id: "x" }]; + } + return { + returning: vi.fn().mockResolvedValue(returnRows), + then: (resolve: (v: unknown) => unknown) => + Promise.resolve(returnRows).then(resolve), + }; + }), + })); + + vi.mocked(database).mockReturnValue(db as never); + + const report = await runBackfill({ dryRun: false }); + + expect(report.tournamentsCreated).toBe(4); + expect(report.tournamentsLinked).toBe(4); + expect(inserts.get(schema.tournaments)).toHaveLength(4); + expect(report.errors).toEqual([]); + }); +}); + +// ─── 3. re-running doesn't duplicate ────────────────────────────────────── + +describe("runBackfill - idempotence", () => { + it("does not create new tournaments when they already exist", async () => { + const events = [ + makeEvent({ id: "ev-1", name: "Masters Tournament", eventDate: "2026-04-09" }), + ]; + const existingTournament = { + id: "t-existing", + sportId: SPORT_ID, + name: "Masters Tournament", + year: 2026, + }; + + const inserts = new Map(); + const updateCounts = new Map(); + const tables = new Map([ + [schema.sportsSeasons, { selects: [[GOLF_SEASON]] }], + [ + schema.scoringEvents, + { + selects: [events, [{ ...events[0], tournamentId: "t-existing" }]], + }, + ], + [schema.tournaments, { selects: [[existingTournament]] }], + [schema.seasonParticipants, { selects: [[]] }], + [schema.eventResults, { selects: [[]] }], + [schema.seasonParticipantSurfaceElos, { selects: [[]] }], + ]); + + vi.mocked(database).mockReturnValue( + makeFakeDb({ tables, inserts, updateCounts }) as never, + ); + + const report = await runBackfill({ dryRun: false }); + + expect(report.tournamentsCreated).toBe(0); + expect(report.tournamentsLinked).toBe(1); + expect(inserts.get(schema.tournaments)).toBeUndefined(); + }); +}); + +// ─── 4. participant backfill ─────────────────────────────────────────────── + +describe("runBackfill - participants", () => { + it("creates canonical participants and links season_participants", async () => { + const sps = [ + makeSeasonParticipant({ id: "sp-1", name: "Scottie Scheffler" }), + makeSeasonParticipant({ id: "sp-2", name: "Rory McIlroy" }), + ]; + + const inserts = new Map(); + const updateCounts = new Map(); + const pIds = ["p-1", "p-2"]; + let pIdx = 0; + + const tables = new Map([ + [schema.sportsSeasons, { selects: [[GOLF_SEASON]] }], + [schema.scoringEvents, { selects: [[], []] }], + [schema.seasonParticipants, { selects: [sps] }], + [schema.participants, { selects: [[], []] }], + [schema.eventResults, { selects: [] }], + [schema.seasonParticipantSurfaceElos, { selects: [[]] }], + ]); + + const db = makeFakeDb({ tables, inserts, updateCounts }); + db.insert.mockImplementation((table: unknown) => ({ + values: vi.fn().mockImplementation((row: unknown) => { + const seen = inserts.get(table) ?? []; + seen.push(row); + inserts.set(table, seen); + let returnRows: unknown[]; + if (table === schema.participants) { + returnRows = [{ ...(row as object), id: pIds[pIdx++] }]; + } else { + returnRows = [{ ...(row as object), id: "x" }]; + } + return { + returning: vi.fn().mockResolvedValue(returnRows), + then: (resolve: (v: unknown) => unknown) => + Promise.resolve(returnRows).then(resolve), + }; + }), + })); + + vi.mocked(database).mockReturnValue(db as never); + + const report = await runBackfill({ dryRun: false }); + + expect(report.participantsCreated).toBe(2); + expect(report.participantsLinked).toBe(2); + expect(inserts.get(schema.participants)).toHaveLength(2); + expect(updateCounts.get(schema.seasonParticipants)).toBe(2); + }); +}); + +// ─── 5. Masters 2026 round-trip ─────────────────────────────────────────── + +describe("runBackfill - tournament_results from event_results", () => { + it("copies placement and rawScore but NOT qualifying_points_awarded", async () => { + const events = [ + makeEvent({ + id: "ev-masters", + tournamentId: "t-masters", + name: "Masters Tournament", + eventDate: "2026-04-09", + }), + ]; + const seasonParticipant = { + id: "sp-1", + sportsSeasonId: SEASON_ID, + participantId: "p-scottie", + name: "Scottie Scheffler", + }; + const eventResult = { + id: "er-1", + scoringEventId: "ev-masters", + seasonParticipantId: "sp-1", + placement: 1, + rawScore: "-12.00", + qualifyingPointsAwarded: "100.00", + }; + + const inserts = new Map(); + + const tables = new Map([ + [schema.sportsSeasons, { selects: [[GOLF_SEASON]] }], + [ + schema.scoringEvents, + { + // 1st: no unlinked events (tournamentId already set) + // 2nd: refetch — events linked to tournament + selects: [[], events], + }, + ], + [schema.tournaments, { selects: [] }], + [schema.seasonParticipants, { selects: [[], [seasonParticipant]] }], + [schema.participants, { selects: [] }], + [schema.eventResults, { selects: [[eventResult]] }], + [schema.tournamentResults, { selects: [[]] }], + [schema.seasonParticipantSurfaceElos, { selects: [[]] }], + ]); + + vi.mocked(database).mockReturnValue( + makeFakeDb({ tables, inserts }) as never, + ); + + const report = await runBackfill({ dryRun: false }); + + expect(report.tournamentResultsCreated).toBe(1); + const trInserts = inserts.get(schema.tournamentResults) ?? []; + expect(trInserts).toHaveLength(1); + const inserted = trInserts[0] as Record; + expect(inserted.tournamentId).toBe("t-masters"); + expect(inserted.participantId).toBe("p-scottie"); + expect(inserted.placement).toBe(1); + expect(inserted.rawScore).toBe("-12.00"); + // CRITICAL: qualifying_points_awarded must NOT be copied. + expect(inserted).not.toHaveProperty("qualifyingPointsAwarded"); + }); +}); + +// ─── 6. Surface elo conflict detection ──────────────────────────────────── + +describe("runBackfill - surface elo conflicts", () => { + it("records a conflict error when two windows disagree on eloHard", async () => { + const sp = { + id: "sp-1", + sportsSeasonId: SEASON_ID, + participantId: "p-tennis-1", + name: "Carlos Alcaraz", + }; + const windowElo = { + id: "se-1", + participantId: "sp-1", + sportsSeasonId: SEASON_ID, + eloHard: 2050, + eloClay: 2100, + eloGrass: 2000, + worldRanking: 2, + }; + const existingCanonicalElo = { + id: "cpe-1", + participantId: "p-tennis-1", + eloHard: 2000, // differs! + eloClay: 2100, + eloGrass: 2000, + worldRanking: 2, + }; + + const inserts = new Map(); + const tables = new Map([ + [schema.sportsSeasons, { selects: [[GOLF_SEASON]] }], + [schema.scoringEvents, { selects: [[], []] }], + [schema.seasonParticipants, { selects: [[], [sp]] }], + [schema.participants, { selects: [] }], + [schema.eventResults, { selects: [] }], + [schema.seasonParticipantSurfaceElos, { selects: [[windowElo]] }], + [schema.participantSurfaceElos, { selects: [[existingCanonicalElo]] }], + ]); + + vi.mocked(database).mockReturnValue( + makeFakeDb({ tables, inserts }) as never, + ); + + const report = await runBackfill({ dryRun: false }); + + expect(report.surfaceElosCreated).toBe(0); + expect(report.errors).toHaveLength(1); + expect(report.errors[0]).toMatch(/conflict for participant p-tennis-1/); + expect(report.errors[0]).toMatch(/eloHard/); + // Must not have inserted a conflicting row. + expect(inserts.get(schema.participantSurfaceElos)).toBeUndefined(); + }); +}); diff --git a/scripts/backfill-canonical-layer.ts b/scripts/backfill-canonical-layer.ts new file mode 100644 index 0000000..bfc3f46 --- /dev/null +++ b/scripts/backfill-canonical-layer.ts @@ -0,0 +1,358 @@ +/** + * Phase 2 one-off backfill: populate canonical tables + * (`tournaments`, `participants`, `tournament_results`, + * `participant_surface_elos`) from existing per-window data. + * + * See CLAUDE.md and the Phase 2 plan docs. Rules that this script + * MUST obey: + * - Never copy `qualifying_points_awarded` from `event_results` to + * `tournament_results`. QP stays per-window. + * - Never touch `season_participant_qualifying_totals`. + * - Abort loud (collect into `report.errors`) if two windows disagree + * on a canonical participant's surface-Elo values. + * + * `dryRun: true` means: run every read, compute every count, but never + * issue an `insert()` or `update()`. + */ + +import { eq, and, isNull } from "drizzle-orm"; + +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; + +import { extractTournamentIdentity } from "./backfill/match-tournament"; + +export interface BackfillOptions { + dryRun: boolean; + sportId?: string; +} + +export interface BackfillReport { + tournamentsCreated: number; + /** Count of scoring_events whose tournament_id was (or would be) set. */ + tournamentsLinked: number; + participantsCreated: number; + /** Count of season_participants whose participant_id was (or would be) set. */ + participantsLinked: number; + tournamentResultsCreated: number; + surfaceElosCreated: number; + warnings: string[]; + errors: string[]; +} + +type Db = ReturnType; +type SportsSeasonRow = typeof schema.sportsSeasons.$inferSelect; +type ScoringEventRow = typeof schema.scoringEvents.$inferSelect; +type SeasonParticipantRow = typeof schema.seasonParticipants.$inferSelect; +type EventResultRow = typeof schema.eventResults.$inferSelect; +type SeasonParticipantSurfaceEloRow = + typeof schema.seasonParticipantSurfaceElos.$inferSelect; +type TournamentRow = typeof schema.tournaments.$inferSelect; +type ParticipantRow = typeof schema.participants.$inferSelect; +type TournamentResultRow = typeof schema.tournamentResults.$inferSelect; +type ParticipantSurfaceEloRow = + typeof schema.participantSurfaceElos.$inferSelect; + +export async function runBackfill( + opts: BackfillOptions, +): Promise { + const report: BackfillReport = { + tournamentsCreated: 0, + tournamentsLinked: 0, + participantsCreated: 0, + participantsLinked: 0, + tournamentResultsCreated: 0, + surfaceElosCreated: 0, + warnings: [], + errors: [], + }; + + const db = database(); + + // 1. Load qualifying-points seasons (optionally filtered by sport). + const whereClauses = [ + eq(schema.sportsSeasons.scoringPattern, "qualifying_points"), + ]; + if (opts.sportId) { + whereClauses.push(eq(schema.sportsSeasons.sportId, opts.sportId)); + } + + const seasons = (await db + .select() + .from(schema.sportsSeasons) + .where(and(...whereClauses))) as SportsSeasonRow[]; + + for (const season of seasons) { + await backfillSeason(db, season, opts, report); + } + + return report; +} + +async function backfillSeason( + db: Db, + season: SportsSeasonRow, + opts: BackfillOptions, + report: BackfillReport, +): Promise { + // ─── a. Tournament linking ──────────────────────────────────────────────── + const unlinkedEvents = (await db + .select() + .from(schema.scoringEvents) + .where( + and( + eq(schema.scoringEvents.sportsSeasonId, season.id), + isNull(schema.scoringEvents.tournamentId), + ), + )) as ScoringEventRow[]; + + for (const ev of unlinkedEvents) { + let identity; + try { + identity = extractTournamentIdentity({ + name: ev.name, + eventDate: ev.eventDate, + eventType: ev.eventType, + }); + } catch (e) { + report.warnings.push( + `skip scoring_event ${ev.id} (${ev.name}): ${(e as Error).message}`, + ); + continue; + } + + // Look up existing canonical tournament. + const [existing] = (await db + .select() + .from(schema.tournaments) + .where( + and( + eq(schema.tournaments.sportId, season.sportId), + eq(schema.tournaments.name, identity.name), + eq(schema.tournaments.year, identity.year), + ), + ) + .limit(1)) as TournamentRow[]; + + let tournamentId: string | undefined = existing?.id; + + if (!existing) { + const startsAt = ev.eventDate ? new Date(ev.eventDate) : null; + const status = + startsAt && startsAt.getTime() < Date.now() ? "completed" : "scheduled"; + + if (!opts.dryRun) { + const [inserted] = (await db + .insert(schema.tournaments) + .values({ + sportId: season.sportId, + name: identity.name, + year: identity.year, + startsAt, + status, + }) + .returning()) as TournamentRow[]; + tournamentId = inserted.id; + } + report.tournamentsCreated += 1; + } + + if (!opts.dryRun && tournamentId) { + await db + .update(schema.scoringEvents) + .set({ tournamentId, updatedAt: new Date() }) + .where(eq(schema.scoringEvents.id, ev.id)); + } + report.tournamentsLinked += 1; + } + + // ─── b. Participant linking ─────────────────────────────────────────────── + const unlinkedParticipants = (await db + .select() + .from(schema.seasonParticipants) + .where( + and( + eq(schema.seasonParticipants.sportsSeasonId, season.id), + isNull(schema.seasonParticipants.participantId), + ), + )) as SeasonParticipantRow[]; + + for (const sp of unlinkedParticipants) { + const [existing] = (await db + .select() + .from(schema.participants) + .where( + and( + eq(schema.participants.sportId, season.sportId), + eq(schema.participants.name, sp.name), + ), + ) + .limit(1)) as ParticipantRow[]; + + let participantId: string | undefined = existing?.id; + + if (!existing) { + if (!opts.dryRun) { + const [inserted] = (await db + .insert(schema.participants) + .values({ + sportId: season.sportId, + name: sp.name, + }) + .returning()) as ParticipantRow[]; + participantId = inserted.id; + } + report.participantsCreated += 1; + } + + if (!opts.dryRun && participantId) { + await db + .update(schema.seasonParticipants) + .set({ participantId, updatedAt: new Date() }) + .where(eq(schema.seasonParticipants.id, sp.id)); + } + report.participantsLinked += 1; + } + + // ─── c. Tournament results (copy completed event_results) ──────────────── + // Refetch events — they may now have tournamentId set (if !dryRun). + const allEvents = (await db + .select() + .from(schema.scoringEvents) + .where( + eq(schema.scoringEvents.sportsSeasonId, season.id), + )) as ScoringEventRow[]; + + for (const ev of allEvents) { + if (!ev.tournamentId) { + // In dry-run we may not have a tournamentId yet; skip result copy. + continue; + } + + const results = (await db + .select() + .from(schema.eventResults) + .where( + eq(schema.eventResults.scoringEventId, ev.id), + )) as EventResultRow[]; + + for (const r of results) { + // Only copy rows with real placement/rawScore data. + if (r.placement == null && r.rawScore == null) { + continue; + } + + // Look up the season_participants row to get canonical participantId. + const [sp] = (await db + .select() + .from(schema.seasonParticipants) + .where( + eq(schema.seasonParticipants.id, r.seasonParticipantId), + ) + .limit(1)) as SeasonParticipantRow[]; + + if (!sp || !sp.participantId) { + // Link step should have handled this; skip defensively. + continue; + } + + // Check if a tournament_results row already exists. + const [existingResult] = (await db + .select() + .from(schema.tournamentResults) + .where( + and( + eq(schema.tournamentResults.tournamentId, ev.tournamentId), + eq(schema.tournamentResults.participantId, sp.participantId), + ), + ) + .limit(1)) as TournamentResultRow[]; + + if (existingResult) { + continue; + } + + if (!opts.dryRun) { + // NOTE: intentionally do NOT copy qualifyingPointsAwarded. + await db.insert(schema.tournamentResults).values({ + tournamentId: ev.tournamentId, + participantId: sp.participantId, + placement: r.placement, + rawScore: r.rawScore, + }); + } + report.tournamentResultsCreated += 1; + } + } + + // ─── d. Surface Elo (per-window → canonical) ───────────────────────────── + const elos = (await db + .select() + .from(schema.seasonParticipantSurfaceElos) + .where( + eq(schema.seasonParticipantSurfaceElos.sportsSeasonId, season.id), + )) as SeasonParticipantSurfaceEloRow[]; + + for (const elo of elos) { + const [sp] = (await db + .select() + .from(schema.seasonParticipants) + .where( + eq(schema.seasonParticipants.id, elo.participantId), + ) + .limit(1)) as SeasonParticipantRow[]; + + if (!sp || !sp.participantId) { + continue; + } + + const canonicalParticipantId = sp.participantId; + + const [existingElo] = (await db + .select() + .from(schema.participantSurfaceElos) + .where( + eq( + schema.participantSurfaceElos.participantId, + canonicalParticipantId, + ), + ) + .limit(1)) as ParticipantSurfaceEloRow[]; + + if (existingElo) { + // Conflict detection: compare eloHard/eloClay/eloGrass/worldRanking. + const fields: Array = [ + "eloHard", + "eloClay", + "eloGrass", + "worldRanking", + ]; + const mismatches: string[] = []; + for (const f of fields) { + if (existingElo[f] !== elo[f as keyof SeasonParticipantSurfaceEloRow]) { + mismatches.push( + `${f}: existing=${String(existingElo[f])} vs incoming=${String(elo[f as keyof SeasonParticipantSurfaceEloRow])}`, + ); + } + } + if (mismatches.length > 0) { + report.errors.push( + `conflict for participant ${canonicalParticipantId} (season ${season.id}): ${mismatches.join(", ")}`, + ); + } + // Do not overwrite. + continue; + } + + if (!opts.dryRun) { + await db.insert(schema.participantSurfaceElos).values({ + participantId: canonicalParticipantId, + eloHard: elo.eloHard, + eloClay: elo.eloClay, + eloGrass: elo.eloGrass, + worldRanking: elo.worldRanking, + }); + } + report.surfaceElosCreated += 1; + } +}