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
This commit is contained in:
parent
85bca8bb77
commit
8186dbb525
2 changed files with 808 additions and 0 deletions
450
scripts/__tests__/backfill-canonical-layer.test.ts
Normal file
450
scripts/__tests__/backfill-canonical-layer.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
358
scripts/backfill-canonical-layer.ts
Normal file
358
scripts/backfill-canonical-layer.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue