From 65489d89b3f0690f6a24c68b987922cf5f1685e2 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Fri, 1 May 2026 23:12:50 +0000 Subject: [PATCH] schema: drop season_participant_surface_elos table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table had no remaining readers after Phase 3 and no remaining writers after Phase 4 Task 2. Also removes the now-unrunnable Phase 2 backfill scripts and the backfill:canonical npm script — the backfill ran once and is preserved in git history (commits 85bca8b, 8186dbb, 6f3438b, bc0f530, 327c7b9). Phase 4 Task 5. Co-Authored-By: Claude Opus 4.7 --- database/schema.ts | 35 +- package.json | 1 - .../backfill-canonical-layer.test.ts | 450 ------------------ scripts/backfill-canonical-layer.ts | 358 -------------- scripts/backfill-cli.ts | 82 ---- .../__tests__/match-tournament.test.ts | 44 -- scripts/backfill/match-tournament.ts | 11 - 7 files changed, 3 insertions(+), 978 deletions(-) delete mode 100644 scripts/__tests__/backfill-canonical-layer.test.ts delete mode 100644 scripts/backfill-canonical-layer.ts delete mode 100644 scripts/backfill-cli.ts delete mode 100644 scripts/backfill/__tests__/match-tournament.test.ts delete mode 100644 scripts/backfill/match-tournament.ts diff --git a/database/schema.ts b/database/schema.ts index 37f009b..782debf 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -1377,38 +1377,9 @@ export const pendingStandingsMappingsRelations = relations(pendingStandingsMappi }), })); -// ─── Participant Surface Elos ───────────────────────────────────────────────── -// Stores surface-specific Elo ratings for tennis (and future surface-based sports). -// One row per (participantId, sportsSeasonId); nullable per surface. - -export const seasonParticipantSurfaceElos = pgTable("season_participant_surface_elos", { - id: uuid("id").primaryKey().defaultRandom(), - participantId: uuid("participant_id") - .notNull() - .references(() => seasonParticipants.id, { onDelete: "cascade" }), - sportsSeasonId: uuid("sports_season_id") - .notNull() - .references(() => sportsSeasons.id, { onDelete: "cascade" }), - worldRanking: integer("world_ranking"), - eloHard: integer("elo_hard"), - eloClay: integer("elo_clay"), - eloGrass: integer("elo_grass"), - updatedAt: timestamp("updated_at").defaultNow().notNull(), -}, (t) => ({ - uniqueParticipantSeason: uniqueIndex("participant_surface_elos_unique") - .on(t.participantId, t.sportsSeasonId), -})); - -export const seasonParticipantSurfaceElosRelations = relations(seasonParticipantSurfaceElos, ({ one }) => ({ - participant: one(seasonParticipants, { - fields: [seasonParticipantSurfaceElos.participantId], - references: [seasonParticipants.id], - }), - sportsSeason: one(sportsSeasons, { - fields: [seasonParticipantSurfaceElos.sportsSeasonId], - references: [sportsSeasons.id], - }), -})); +// Surface Elos are now stored canonically in `participant_surface_elos` +// (defined above). The per-window `season_participant_surface_elos` table +// was dropped in Phase 4 of the canonical tournament layer migration. // ─── Participant Golf Skills ─────────────────────────────────────────────────── // Stores golf-specific skill data for qualifying-points golf seasons. diff --git a/package.json b/package.json index ee9c521..821019d 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "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/__tests__/backfill-canonical-layer.test.ts b/scripts/__tests__/backfill-canonical-layer.test.ts deleted file mode 100644 index fa93454..0000000 --- a/scripts/__tests__/backfill-canonical-layer.test.ts +++ /dev/null @@ -1,450 +0,0 @@ -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 deleted file mode 100644 index bfc3f46..0000000 --- a/scripts/backfill-canonical-layer.ts +++ /dev/null @@ -1,358 +0,0 @@ -/** - * 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; - } -} diff --git a/scripts/backfill-cli.ts b/scripts/backfill-cli.ts deleted file mode 100644 index 3782049..0000000 --- a/scripts/backfill-cli.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * 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 { DatabaseContext } from "~/database/context"; -import { db } from "../server/db"; -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 DatabaseContext.run(db, () => 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() - .then(() => process.exit(0)) - .catch((e) => { - console.error(e); - process.exit(1); - }); diff --git a/scripts/backfill/__tests__/match-tournament.test.ts b/scripts/backfill/__tests__/match-tournament.test.ts deleted file mode 100644 index 484ee30..0000000 --- a/scripts/backfill/__tests__/match-tournament.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index d869e6f..0000000 --- a/scripts/backfill/match-tournament.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Re-exports the canonical tournament identity extractor. The implementation - * lives in `app/lib/tournament-identity.ts` so it can be shared by the - * Phase 2 backfill and by the admin event-creation flow. - */ - -export { - extractTournamentIdentity, - type TournamentIdentity, - type ScoringEventIdentityInput as ScoringEventInput, -} from "~/lib/tournament-identity";