brackt/scripts/backfill-canonical-layer.ts
Chris Parsons 8186dbb525
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
2026-05-01 20:57:38 +00:00

358 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;
}
}