Merge branch 'phase-2-backfill' into canonical-layer-pr1

This commit is contained in:
Chris Parsons 2026-05-01 20:07:06 -07:00
commit df298b4568
6 changed files with 992 additions and 0 deletions

View file

@ -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",

View file

@ -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<Record<string, unknown>> = {}) {
return {
id: "event-1",
sportsSeasonId: SEASON_ID,
tournamentId: null,
name: "Masters Tournament",
eventDate: "2026-04-09",
eventType: "tournament",
...overrides,
};
}
function makeSeasonParticipant(
overrides: Partial<Record<string, unknown>> = {},
) {
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<unknown, TableState>;
/** Records every insert call: tableRef → rows seen. */
inserts?: Map<unknown, unknown[]>;
/** Records every update call: tableRef → count. */
updateCounts?: Map<unknown, number>;
}
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<string, unknown> = {
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<unknown, TableState>([
[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<unknown, unknown[]>();
// Build per-event insertReturns so each new tournament gets its id.
const tables = new Map<unknown, TableState>([
[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<typeof makeFakeDb> = 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<unknown, unknown[]>();
const updateCounts = new Map<unknown, number>();
const tables = new Map<unknown, TableState>([
[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<unknown, unknown[]>();
const updateCounts = new Map<unknown, number>();
const pIds = ["p-1", "p-2"];
let pIdx = 0;
const tables = new Map<unknown, TableState>([
[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<unknown, unknown[]>();
const tables = new Map<unknown, TableState>([
[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<string, unknown>;
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<unknown, unknown[]>();
const tables = new Map<unknown, TableState>([
[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();
});
});

View file

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

82
scripts/backfill-cli.ts Normal file
View file

@ -0,0 +1,82 @@
/**
* CLI entry point for the Phase 2 canonical backfill.
*
* Usage:
* npm run backfill:canonical -- [--dry-run | --apply] [--sport=<uuid>]
*
* 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=<uuid> 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);
});

View file

@ -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/);
});
});

View file

@ -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"})`,
);
}