From 85bca8bb775d9eb3aa00815ef8dba85d1bef5810 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Fri, 1 May 2026 20:47:23 +0000 Subject: [PATCH 1/5] scripts: add extractTournamentIdentity helper for backfill Pure function that derives canonical (name, year) identity from a scoring_events row, stripping trailing 4-digit years from the name or falling back to eventDate. Used by the Phase 2 backfill to group per-window events into canonical tournaments. --- .../__tests__/match-tournament.test.ts | 44 ++++++++++++++ scripts/backfill/match-tournament.ts | 57 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 scripts/backfill/__tests__/match-tournament.test.ts create mode 100644 scripts/backfill/match-tournament.ts diff --git a/scripts/backfill/__tests__/match-tournament.test.ts b/scripts/backfill/__tests__/match-tournament.test.ts new file mode 100644 index 0000000..484ee30 --- /dev/null +++ b/scripts/backfill/__tests__/match-tournament.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { extractTournamentIdentity } from "../match-tournament"; + +describe("extractTournamentIdentity", () => { + it("uses eventDate year when the name has no trailing year", () => { + const identity = extractTournamentIdentity({ + name: "Masters Tournament", + eventDate: "2026-04-09", + eventType: "tournament", + }); + + expect(identity).toEqual({ name: "Masters Tournament", year: 2026 }); + }); + + it("derives the year from eventDate for a simple tournament name", () => { + const identity = extractTournamentIdentity({ + name: "Wimbledon", + eventDate: "2026-07-01", + eventType: "tournament", + }); + + expect(identity).toEqual({ name: "Wimbledon", year: 2026 }); + }); + + it("strips a trailing year from the name and uses it as the canonical year", () => { + const identity = extractTournamentIdentity({ + name: "Wimbledon 2026", + eventDate: "2026-07-01", + eventType: "tournament", + }); + + expect(identity).toEqual({ name: "Wimbledon", year: 2026 }); + }); + + it("throws when neither the name nor eventDate supply a year", () => { + expect(() => + extractTournamentIdentity({ + name: "Wimbledon", + eventDate: null, + eventType: "tournament", + }), + ).toThrow(/cannot determine year/); + }); +}); diff --git a/scripts/backfill/match-tournament.ts b/scripts/backfill/match-tournament.ts new file mode 100644 index 0000000..e7c7c92 --- /dev/null +++ b/scripts/backfill/match-tournament.ts @@ -0,0 +1,57 @@ +/** + * Pure function(s) for extracting canonical tournament identity + * (name + year) from a `scoring_events` row. + * + * Used by the Phase 2 backfill to group per-window events into + * canonical `tournaments` rows. + */ + +export interface ScoringEventInput { + name: string; + eventDate: string | null; + eventType: string; +} + +export interface TournamentIdentity { + /** Tournament name with any trailing 4-digit year stripped. */ + name: string; + year: number; +} + +const TRAILING_YEAR_RE = / (\d{4})$/; + +/** + * Extracts the canonical `(name, year)` identity for a tournament + * from a scoring event. + * + * Resolution order for year: + * 1. A trailing 4-digit year on the event name (e.g. "Wimbledon 2026"). + * The year is stripped from the returned name. + * 2. The first 4 characters of `eventDate` (format `YYYY-MM-DD`). + * + * Throws if neither source supplies a year. + */ +export function extractTournamentIdentity( + ev: ScoringEventInput, +): TournamentIdentity { + const trimmedName = ev.name.trim(); + const match = trimmedName.match(TRAILING_YEAR_RE); + + if (match) { + const yearFromName = Number(match[1]); + const nameWithoutYear = trimmedName.slice(0, match.index).trim(); + return { name: nameWithoutYear, year: yearFromName }; + } + + if (ev.eventDate) { + const yearStr = ev.eventDate.slice(0, 4); + const yearFromDate = Number(yearStr); + if (Number.isFinite(yearFromDate) && yearStr.length === 4) { + return { name: trimmedName, year: yearFromDate }; + } + } + + throw new Error( + `cannot determine year for scoring event "${ev.name}" (eventDate=${ev.eventDate ?? "null"})`, + ); +} From 8186dbb5257772ae7447424c90f8d92b89b08ef6 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Fri, 1 May 2026 20:57:38 +0000 Subject: [PATCH 2/5] 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; + } +} From 6f3438b5d278c222f4e5afa3408acecc7140cce5 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Fri, 1 May 2026 20:59:13 +0000 Subject: [PATCH 3/5] scripts: add backfill CLI with dry-run default Wires backfill-canonical-layer.ts to a CLI entry point exposed as `npm run backfill:canonical`. Defaults to --dry-run; requires --apply to actually write. Supports --sport= to limit to a single sport. Exits 2 if the backfill reports errors (e.g., surface-Elo conflicts). --- package.json | 1 + scripts/backfill-cli.ts | 78 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 scripts/backfill-cli.ts diff --git a/package.json b/package.json index 821019d..ee9c521 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "db:generate": "dotenv -- drizzle-kit generate", "db:migrate": "dotenv -- drizzle-kit migrate", "db:sync-prod": "bash scripts/sync-prod-db.sh", + "backfill:canonical": "dotenv -- tsx scripts/backfill-cli.ts", "dev": "NODE_OPTIONS='--import ./instrument.server.mjs' dotenv -- tsx watch server.ts", "start": "NODE_ENV=production NODE_OPTIONS='--import ./instrument.server.mjs' node dist/server.js", "start:production": "NODE_ENV=production NODE_OPTIONS='--import ./instrument.server.mjs' node dist/server.js", diff --git a/scripts/backfill-cli.ts b/scripts/backfill-cli.ts new file mode 100644 index 0000000..4a6d3d0 --- /dev/null +++ b/scripts/backfill-cli.ts @@ -0,0 +1,78 @@ +/** + * CLI entry point for the Phase 2 canonical backfill. + * + * Usage: + * npm run backfill:canonical -- [--dry-run | --apply] [--sport=] + * + * Defaults to --dry-run for safety. No writes will be issued unless + * --apply is passed explicitly. + */ + +import { runBackfill } from "./backfill-canonical-layer"; +import type { BackfillOptions } from "./backfill-canonical-layer"; + +function printHelp(): void { + console.log( + [ + "Usage: backfill-cli [options]", + "", + "Options:", + " --dry-run Run without writing (default).", + " --apply Actually write to the database.", + " --sport= Only backfill for the given sport id.", + " --help Show this message.", + ].join("\n"), + ); +} + +function parseArgs(): BackfillOptions { + const args = process.argv.slice(2); + const opts: BackfillOptions = { dryRun: true }; // dry-run by default for safety + for (const a of args) { + if (a === "--apply") { + opts.dryRun = false; + } else if (a === "--dry-run") { + opts.dryRun = true; + } else if (a.startsWith("--sport=")) { + opts.sportId = a.slice("--sport=".length); + } else if (a === "--help" || a === "-h") { + printHelp(); + process.exit(0); + } else { + console.error(`unknown arg: ${a}`); + process.exit(1); + } + } + return opts; +} + +async function main() { + const opts = parseArgs(); + console.log( + `Running backfill (dryRun=${opts.dryRun}, sportId=${opts.sportId ?? "all"})`, + ); + const report = await runBackfill(opts); + + console.log("---"); + console.log(`tournamentsCreated: ${report.tournamentsCreated}`); + console.log(`tournamentsLinked: ${report.tournamentsLinked}`); + console.log(`participantsCreated: ${report.participantsCreated}`); + console.log(`participantsLinked: ${report.participantsLinked}`); + console.log(`tournamentResultsCreated: ${report.tournamentResultsCreated}`); + console.log(`surfaceElosCreated: ${report.surfaceElosCreated}`); + + if (report.warnings.length) { + console.log("\nWARNINGS:"); + for (const w of report.warnings) console.log(` ${w}`); + } + if (report.errors.length) { + console.log("\nERRORS:"); + for (const e of report.errors) console.log(` ${e}`); + process.exit(2); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); From bc0f5301afec77621348eb1502668951b411e3f7 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Fri, 1 May 2026 21:01:13 +0000 Subject: [PATCH 4/5] fix(backfill-cli): wrap runBackfill in DatabaseContext.run The orchestrator uses database() from ~/database/context, which requires AsyncLocalStorage to be populated. Wrap the CLI invocation with DatabaseContext.run(db, ...) using server/db's cached connection pool. Co-Authored-By: Claude Opus 4.7 --- scripts/backfill-cli.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/backfill-cli.ts b/scripts/backfill-cli.ts index 4a6d3d0..ffdf42c 100644 --- a/scripts/backfill-cli.ts +++ b/scripts/backfill-cli.ts @@ -8,6 +8,8 @@ * --apply is passed explicitly. */ +import { DatabaseContext } from "~/database/context"; +import { db } from "../server/db"; import { runBackfill } from "./backfill-canonical-layer"; import type { BackfillOptions } from "./backfill-canonical-layer"; @@ -51,7 +53,7 @@ async function main() { console.log( `Running backfill (dryRun=${opts.dryRun}, sportId=${opts.sportId ?? "all"})`, ); - const report = await runBackfill(opts); + const report = await DatabaseContext.run(db, () => runBackfill(opts)); console.log("---"); console.log(`tournamentsCreated: ${report.tournamentsCreated}`); From 327c7b91ca7358c8b6b60b3a0edf26b22117bf3f Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Fri, 1 May 2026 21:07:26 +0000 Subject: [PATCH 5/5] fix(backfill-cli): exit 0 on success so pg pool doesn't block The cached postgres connection pool keeps the Node event loop open after main() returns. Explicit process.exit(0) on success mirrors the pattern in scripts/capture-baseline.ts. Co-Authored-By: Claude Opus 4.7 --- scripts/backfill-cli.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/backfill-cli.ts b/scripts/backfill-cli.ts index ffdf42c..3782049 100644 --- a/scripts/backfill-cli.ts +++ b/scripts/backfill-cli.ts @@ -74,7 +74,9 @@ async function main() { } } -main().catch((e) => { - console.error(e); - process.exit(1); -}); +main() + .then(() => process.exit(0)) + .catch((e) => { + console.error(e); + process.exit(1); + });