/** * 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; } }