359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<typeof database>;
|
||
|
|
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<BackfillReport> {
|
||
|
|
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<void> {
|
||
|
|
// ─── 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<keyof ParticipantSurfaceEloRow> = [
|
||
|
|
"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;
|
||
|
|
}
|
||
|
|
}
|