Merge branch 'phase-4-cleanup' into canonical-layer-pr2
This commit is contained in:
commit
0bc8bfd72e
15 changed files with 5739 additions and 1119 deletions
|
|
@ -72,20 +72,21 @@ describe("batchUpsertSurfaceElos", () => {
|
||||||
expect(mockDb.insert).not.toHaveBeenCalled();
|
expect(mockDb.insert).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls insert with the correct values on the per-window table", async () => {
|
it("writes canonical rows using resolved canonical participant ids", async () => {
|
||||||
// The function first upserts to the per-window table, then reads
|
// season_participants lookup returns canonical links for p1 and p2.
|
||||||
// season_participants for canonical-id lookup. Make the follow-up select
|
mockDb.where.mockResolvedValue([
|
||||||
// return empty so the canonical write path short-circuits and we only
|
{ id: "p1", canonicalId: "canon-1" },
|
||||||
// observe the per-window insert in this assertion.
|
{ id: "p2", canonicalId: "canon-2" },
|
||||||
mockDb.where.mockResolvedValue([]);
|
]);
|
||||||
|
|
||||||
const inputs = [
|
await batchUpsertSurfaceElos([
|
||||||
{ participantId: "p1", sportsSeasonId: "s1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 },
|
{ participantId: "p1", sportsSeasonId: "s1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 },
|
||||||
{ participantId: "p2", sportsSeasonId: "s1", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: undefined },
|
{ participantId: "p2", sportsSeasonId: "s1", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: undefined },
|
||||||
];
|
]);
|
||||||
await batchUpsertSurfaceElos(inputs);
|
|
||||||
expect(mockDb.insert).toHaveBeenCalledOnce();
|
expect(mockDb.insert).toHaveBeenCalledOnce();
|
||||||
expect(mockDb.values).toHaveBeenCalledOnce();
|
expect(mockDb.values).toHaveBeenCalledOnce();
|
||||||
|
expect(mockDb.onConflictDoUpdate).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
const passedValues = mockDb.values.mock.calls[0][0] as Array<{
|
const passedValues = mockDb.values.mock.calls[0][0] as Array<{
|
||||||
participantId: string;
|
participantId: string;
|
||||||
|
|
@ -94,42 +95,21 @@ describe("batchUpsertSurfaceElos", () => {
|
||||||
eloGrass: number | null;
|
eloGrass: number | null;
|
||||||
}>;
|
}>;
|
||||||
expect(passedValues).toHaveLength(2);
|
expect(passedValues).toHaveLength(2);
|
||||||
expect(passedValues[0].participantId).toBe("p1");
|
expect(passedValues[0].participantId).toBe("canon-1");
|
||||||
expect(passedValues[0].eloHard).toBe(1800);
|
expect(passedValues[0].eloHard).toBe(1800);
|
||||||
|
expect(passedValues[1].participantId).toBe("canon-2");
|
||||||
expect(passedValues[1].eloHard).toBeNull();
|
expect(passedValues[1].eloHard).toBeNull();
|
||||||
expect(passedValues[1].eloGrass).toBeNull(); // undefined → null
|
expect(passedValues[1].eloGrass).toBeNull(); // undefined → null
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses onConflictDoUpdate for upsert behaviour", async () => {
|
it("skips season_participants that have no canonical link", async () => {
|
||||||
mockDb.where.mockResolvedValue([]); // no canonical links; only per-window write
|
mockDb.where.mockResolvedValue([]); // no canonical links; short-circuits.
|
||||||
|
|
||||||
await batchUpsertSurfaceElos([
|
|
||||||
{ participantId: "p1", sportsSeasonId: "s1", worldRanking: 5, eloHard: 2000 },
|
|
||||||
]);
|
|
||||||
expect(mockDb.onConflictDoUpdate).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("mirrors writes to canonical participant_surface_elos when linked", async () => {
|
|
||||||
// season_participants lookup returns canonical links for p1 only.
|
|
||||||
mockDb.where.mockResolvedValue([
|
|
||||||
{ id: "p1", canonicalId: "canon-1" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
await batchUpsertSurfaceElos([
|
await batchUpsertSurfaceElos([
|
||||||
{ participantId: "p1", sportsSeasonId: "s1", worldRanking: 5, eloHard: 2000 },
|
{ participantId: "p1", sportsSeasonId: "s1", worldRanking: 5, eloHard: 2000 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// One per-window insert, one canonical insert → two inserts total.
|
expect(mockDb.insert).not.toHaveBeenCalled();
|
||||||
expect(mockDb.insert).toHaveBeenCalledTimes(2);
|
|
||||||
expect(mockDb.onConflictDoUpdate).toHaveBeenCalledTimes(2);
|
|
||||||
|
|
||||||
const canonicalValues = mockDb.values.mock.calls[1][0] as Array<{
|
|
||||||
participantId: string;
|
|
||||||
eloHard: number | null;
|
|
||||||
}>;
|
|
||||||
expect(canonicalValues).toHaveLength(1);
|
|
||||||
expect(canonicalValues[0].participantId).toBe("canon-1");
|
|
||||||
expect(canonicalValues[0].eloHard).toBe(2000);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
/**
|
/**
|
||||||
* Model for Participant Surface Elos
|
* Surface Elo model (canonical).
|
||||||
*
|
*
|
||||||
* Manages surface-specific Elo ratings for tennis (and future surface-based sports).
|
* Surface Elos are stored in the canonical `participant_surface_elos` table,
|
||||||
* Each participant in a sports season can have separate Elo ratings for hard,
|
* keyed by canonical participant id. The admin UI still works in terms of
|
||||||
* clay, and grass courts.
|
* season_participants — we join through season_participants → canonical
|
||||||
|
* participant → canonical surface Elo so the UI doesn't need to know about
|
||||||
|
* the canonical layer.
|
||||||
|
*
|
||||||
|
* See `docs/superpowers/specs/2026-05-01-canonical-tournament-layer-design.md`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import { participantSurfaceElos, seasonParticipantSurfaceElos, seasonParticipants } from "~/database/schema";
|
import { participantSurfaceElos, seasonParticipants } from "~/database/schema";
|
||||||
import { eq, inArray, sql } from "drizzle-orm";
|
import { eq, inArray, sql } from "drizzle-orm";
|
||||||
|
|
||||||
export type CourtSurface = "hard" | "clay" | "grass";
|
export type CourtSurface = "hard" | "clay" | "grass";
|
||||||
|
|
@ -37,72 +41,55 @@ export interface SurfaceEloInput {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all surface Elo records for a sports season, joined with participant names.
|
* Load surface Elos for a sports season's roster, joined with the per-window
|
||||||
* Returns one record per participant (seasonParticipants with no Elo record are excluded).
|
* participant's name. Internally joins season_participants → canonical
|
||||||
|
* participants → canonical participant_surface_elos.
|
||||||
|
*
|
||||||
|
* The returned `id` is the canonical `participant_surface_elos.id`;
|
||||||
|
* `participantId` is the season_participant id (admin UI keys rows by that).
|
||||||
*/
|
*/
|
||||||
export async function getSurfaceElosForSeason(
|
export async function getSurfaceElosForSeason(
|
||||||
sportsSeasonId: string
|
sportsSeasonId: string,
|
||||||
): Promise<SurfaceEloWithName[]> {
|
): Promise<SurfaceEloWithName[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
const rows = await db
|
return await db
|
||||||
.select({
|
.select({
|
||||||
id: seasonParticipantSurfaceElos.id,
|
id: participantSurfaceElos.id,
|
||||||
participantId: seasonParticipantSurfaceElos.participantId,
|
participantId: seasonParticipants.id,
|
||||||
sportsSeasonId: seasonParticipantSurfaceElos.sportsSeasonId,
|
sportsSeasonId: seasonParticipants.sportsSeasonId,
|
||||||
worldRanking: seasonParticipantSurfaceElos.worldRanking,
|
worldRanking: participantSurfaceElos.worldRanking,
|
||||||
eloHard: seasonParticipantSurfaceElos.eloHard,
|
eloHard: participantSurfaceElos.eloHard,
|
||||||
eloClay: seasonParticipantSurfaceElos.eloClay,
|
eloClay: participantSurfaceElos.eloClay,
|
||||||
eloGrass: seasonParticipantSurfaceElos.eloGrass,
|
eloGrass: participantSurfaceElos.eloGrass,
|
||||||
updatedAt: seasonParticipantSurfaceElos.updatedAt,
|
updatedAt: participantSurfaceElos.updatedAt,
|
||||||
participantName: seasonParticipants.name,
|
participantName: seasonParticipants.name,
|
||||||
})
|
})
|
||||||
.from(seasonParticipantSurfaceElos)
|
.from(seasonParticipants)
|
||||||
.innerJoin(seasonParticipants, eq(seasonParticipantSurfaceElos.participantId, seasonParticipants.id))
|
.innerJoin(
|
||||||
.where(eq(seasonParticipantSurfaceElos.sportsSeasonId, sportsSeasonId))
|
participantSurfaceElos,
|
||||||
|
eq(participantSurfaceElos.participantId, seasonParticipants.participantId),
|
||||||
|
)
|
||||||
|
.where(eq(seasonParticipants.sportsSeasonId, sportsSeasonId))
|
||||||
.orderBy(seasonParticipants.name);
|
.orderBy(seasonParticipants.name);
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upsert surface Elo ratings for a batch of seasonParticipants.
|
* Upsert surface Elos for a batch of season_participants. Writes to the
|
||||||
* Uses INSERT ... ON CONFLICT DO UPDATE so all three surface columns are
|
* canonical `participant_surface_elos` table; callers pass season_participant
|
||||||
* overwritten atomically — the admin always submits all three values.
|
* ids and we resolve canonical ids internally.
|
||||||
|
*
|
||||||
|
* season_participants without a canonical link are silently skipped. In
|
||||||
|
* practice every qualifying-points roster entry is canonical-linked after
|
||||||
|
* Phase 2 + the Phase 3 auto-linking on createParticipant.
|
||||||
*/
|
*/
|
||||||
export async function batchUpsertSurfaceElos(
|
export async function batchUpsertSurfaceElos(
|
||||||
inputs: SurfaceEloInput[]
|
inputs: SurfaceEloInput[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (inputs.length === 0) return;
|
if (inputs.length === 0) return;
|
||||||
const db = database();
|
const db = database();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
await db
|
// Resolve canonical ids for every season_participant in the batch.
|
||||||
.insert(seasonParticipantSurfaceElos)
|
|
||||||
.values(
|
|
||||||
inputs.map(({ participantId, sportsSeasonId, worldRanking, eloHard, eloClay, eloGrass }) => ({
|
|
||||||
participantId,
|
|
||||||
sportsSeasonId,
|
|
||||||
worldRanking: worldRanking ?? null,
|
|
||||||
eloHard: eloHard ?? null,
|
|
||||||
eloClay: eloClay ?? null,
|
|
||||||
eloGrass: eloGrass ?? null,
|
|
||||||
updatedAt: now,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [seasonParticipantSurfaceElos.participantId, seasonParticipantSurfaceElos.sportsSeasonId],
|
|
||||||
set: {
|
|
||||||
worldRanking: sql`excluded.world_ranking`,
|
|
||||||
eloHard: sql`excluded.elo_hard`,
|
|
||||||
eloClay: sql`excluded.elo_clay`,
|
|
||||||
eloGrass: sql`excluded.elo_grass`,
|
|
||||||
updatedAt: sql`excluded.updated_at`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mirror writes to the canonical participant_surface_elos table.
|
|
||||||
// The simulator reads from canonical, so per-window-only edits would
|
|
||||||
// not affect simulations. Remove the per-window path entirely in Phase 4.
|
|
||||||
const sps = await db
|
const sps = await db
|
||||||
.select({ id: seasonParticipants.id, canonicalId: seasonParticipants.participantId })
|
.select({ id: seasonParticipants.id, canonicalId: seasonParticipants.participantId })
|
||||||
.from(seasonParticipants)
|
.from(seasonParticipants)
|
||||||
|
|
@ -141,20 +128,16 @@ export async function batchUpsertSurfaceElos(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a Map from participantId to surface Elos for use in the simulator.
|
* Build a map keyed by season_participant.id → surface Elo values, sourced
|
||||||
* Participants with no record are absent from the map (simulator falls back to 1500).
|
* from the canonical `participant_surface_elos` table. Used by the tennis
|
||||||
*/
|
* simulator.
|
||||||
/**
|
|
||||||
* Build a map keyed by seasonParticipant.id → surface Elo values, sourced from
|
|
||||||
* the canonical `participant_surface_elos` table. Joins seasonParticipants →
|
|
||||||
* canonical participants → canonical surfaceElo.
|
|
||||||
*
|
*
|
||||||
* Season participants without a linked canonical participant, or with no
|
* Season participants without a linked canonical participant, or with no
|
||||||
* canonical surface Elo row, are simply absent from the map — callers should
|
* canonical surface Elo row, are absent from the map — callers handle
|
||||||
* handle `undefined` for such cases (as the tennis simulator does).
|
* `undefined` by falling back to 1500 (the simulator's default).
|
||||||
*/
|
*/
|
||||||
export async function getSurfaceEloMap(
|
export async function getSurfaceEloMap(
|
||||||
sportsSeasonId: string
|
sportsSeasonId: string,
|
||||||
): Promise<Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>> {
|
): Promise<Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>> {
|
||||||
const db = database();
|
const db = database();
|
||||||
const rows = await db
|
const rows = await db
|
||||||
|
|
|
||||||
|
|
@ -384,52 +384,39 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
return { error: "Scoring event not found" };
|
return { error: "Scoring event not found" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scoringEvent.tournamentId) {
|
// Qualifying events always have a canonical tournament link (check
|
||||||
// Canonical path: translate season_participant.id → canonical participant.id
|
// constraint). Write results canonically and fan out.
|
||||||
const spRows = allSeasonParticipants.filter((sp) =>
|
if (!scoringEvent.tournamentId) {
|
||||||
incoming.some((r) => r.participantId === sp.id),
|
|
||||||
);
|
|
||||||
const missingCanonical = spRows.filter((sp) => !sp.participantId);
|
|
||||||
if (missingCanonical.length > 0) {
|
|
||||||
return {
|
|
||||||
error: `Some season participants are not linked to canonical participants: ${missingCanonical.map((sp) => sp.name).join(", ")}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const row of incoming) {
|
|
||||||
const sp = spRows.find((s) => s.id === row.participantId);
|
|
||||||
if (!sp?.participantId) continue;
|
|
||||||
await upsertTournamentResult({
|
|
||||||
tournamentId: scoringEvent.tournamentId,
|
|
||||||
participantId: sp.participantId,
|
|
||||||
placement: row.placement,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncReport = await syncTournamentResults(scoringEvent.tournamentId);
|
|
||||||
return {
|
return {
|
||||||
success: `${incoming.length} result${incoming.length === 1 ? "" : "s"} saved canonically. Synced to ${syncReport.windowsSynced} window${syncReport.windowsSynced === 1 ? "" : "s"}${syncReport.windowsFailed > 0 ? ` (${syncReport.windowsFailed} failed)` : ""}.`,
|
error:
|
||||||
syncReport,
|
"This scoring event has no canonical tournament link — cannot save batch results. Contact a developer.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy direct-write path for non-canonical events (team sports, etc.).
|
const spRows = allSeasonParticipants.filter((sp) =>
|
||||||
const existingResults = await getEventResults(params.eventId);
|
incoming.some((r) => r.participantId === sp.id),
|
||||||
const existingIds = new Set(existingResults.map((r) => r.seasonParticipantId));
|
);
|
||||||
const newResults = filterNewResults(incoming, existingIds);
|
const missingCanonical = spRows.filter((sp) => !sp.participantId);
|
||||||
|
if (missingCanonical.length > 0) {
|
||||||
if (newResults.length > 0) {
|
return {
|
||||||
await createEventResultsBulk(
|
error: `Some season participants are not linked to canonical participants: ${missingCanonical.map((sp) => sp.name).join(", ")}`,
|
||||||
newResults.map((r) => ({
|
};
|
||||||
scoringEventId: params.eventId,
|
|
||||||
participantId: r.participantId,
|
|
||||||
placement: r.placement,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const row of incoming) {
|
||||||
|
const sp = spRows.find((s) => s.id === row.participantId);
|
||||||
|
if (!sp?.participantId) continue;
|
||||||
|
await upsertTournamentResult({
|
||||||
|
tournamentId: scoringEvent.tournamentId,
|
||||||
|
participantId: sp.participantId,
|
||||||
|
placement: row.placement,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncReport = await syncTournamentResults(scoringEvent.tournamentId);
|
||||||
return {
|
return {
|
||||||
success: `${newResults.length} result${newResults.length === 1 ? "" : "s"} saved${incoming.length - newResults.length > 0 ? ` (${incoming.length - newResults.length} duplicate${incoming.length - newResults.length === 1 ? "" : "s"} skipped)` : ""}.`,
|
success: `${incoming.length} result${incoming.length === 1 ? "" : "s"} saved canonically. Synced to ${syncReport.windowsSynced} window${syncReport.windowsSynced === 1 ? "" : "s"}${syncReport.windowsFailed > 0 ? ` (${syncReport.windowsFailed} failed)` : ""}.`,
|
||||||
|
syncReport,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error saving batch results:", error);
|
logger.error("Error saving batch results:", error);
|
||||||
|
|
|
||||||
|
|
@ -1377,38 +1377,9 @@ export const pendingStandingsMappingsRelations = relations(pendingStandingsMappi
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ─── Participant Surface Elos ─────────────────────────────────────────────────
|
// Surface Elos are now stored canonically in `participant_surface_elos`
|
||||||
// Stores surface-specific Elo ratings for tennis (and future surface-based sports).
|
// (defined above). The per-window `season_participant_surface_elos` table
|
||||||
// One row per (participantId, sportsSeasonId); nullable per surface.
|
// was dropped in Phase 4 of the canonical tournament layer migration.
|
||||||
|
|
||||||
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],
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ─── Participant Golf Skills ───────────────────────────────────────────────────
|
// ─── Participant Golf Skills ───────────────────────────────────────────────────
|
||||||
// Stores golf-specific skill data for qualifying-points golf seasons.
|
// Stores golf-specific skill data for qualifying-points golf seasons.
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,33 @@
|
||||||
|
|
||||||
**NEVER** manually create migration SQL files or edit `drizzle/meta/_journal.json`. The journal and snapshots must stay in sync — drizzle-kit manages both. Missing snapshots cause `drizzle-kit generate` to fail with "data is malformed".
|
**NEVER** manually create migration SQL files or edit `drizzle/meta/_journal.json`. The journal and snapshots must stay in sync — drizzle-kit manages both. Missing snapshots cause `drizzle-kit generate` to fail with "data is malformed".
|
||||||
|
|
||||||
- Always use `drizzle-kit generate` (or `drizzle-kit generate --custom` for interactive rename prompts)
|
- Always use `drizzle-kit generate` and answer any interactive rename prompts (`"Is X renamed from Y?"`) — do NOT use `--custom` for renames, it copies the previous snapshot instead of diffing, which causes the next run to generate a duplicate migration
|
||||||
- Snapshot JSON must only contain PostgreSQL-valid fields — do not add `"autoincrement"` (Postgres uses sequences, and invalid fields cause Zod validation failures)
|
- Snapshot JSON must only contain PostgreSQL-valid fields — do not add `"autoincrement"` (Postgres uses sequences, and invalid fields cause Zod validation failures)
|
||||||
- After generating, run `drizzle-kit generate` again to confirm "No schema changes" — validates the snapshot chain
|
- After generating, run `drizzle-kit generate` again to confirm "No schema changes" — validates the snapshot chain
|
||||||
- For destructive migrations (drop/rename), use `IF EXISTS` / `IF NOT EXISTS` guards
|
- For destructive migrations (drop/rename), use `IF EXISTS` / `IF NOT EXISTS` guards
|
||||||
|
- Some existing FK constraints use Postgres auto-generated `*_fkey` names instead of the Drizzle `*_fk` convention. If drizzle-kit generates `DROP CONSTRAINT "<table>_<col>_<ref>_id_fk"` and it fails with "does not exist", add `IF EXISTS` (via `sed -i` on the SQL file) — a mismatched name is the likely cause
|
||||||
|
|
||||||
|
## Canonical vs. Per-Window Tables (Qualifying-Points Sports)
|
||||||
|
|
||||||
|
Golf, tennis, and CS2 use a split model to support rolling windows that overlap (e.g., a June and September tennis window both containing Wimbledon 2026 + US Open 2026).
|
||||||
|
|
||||||
|
- **Canonical** (real-world identity, shared across windows):
|
||||||
|
- `tournaments` — real-world tournaments (Wimbledon 2026 is one row per sport)
|
||||||
|
- `participants` — real-world players or teams (Djokovic, NAVI)
|
||||||
|
- `tournament_results` — final placements, entered once
|
||||||
|
- `participant_surface_elos` — tennis surface-specific Elo
|
||||||
|
- **Per-window** (scoped to a `sports_seasons` row):
|
||||||
|
- `season_participants` — draftable roster for this window; links to canonical via `participant_id`
|
||||||
|
- `season_participant_expected_values` — EV under this window's 4-event set
|
||||||
|
- `season_participant_qualifying_totals` — running QP sum for this window
|
||||||
|
- `season_participant_results` — final placement for fantasy scoring
|
||||||
|
- `event_results` — per-scoring-event placements (derived from `tournament_results` via `syncTournamentResults`)
|
||||||
|
|
||||||
|
Enter tournament results at `/admin/tournaments/:id`. The `syncTournamentResults` service (`app/services/sync-tournament-results.ts`) fans out to every window linked via `scoring_events.tournament_id` — each window in its own transaction, so partial failures isolate. A CHECK constraint on `scoring_events` enforces `NOT is_qualifying_event OR tournament_id IS NOT NULL`.
|
||||||
|
|
||||||
|
When admin creates a new qualifying event via the events admin page, `createScoringEvent` auto-provisions the matching canonical tournament. When admin creates a new season_participant, `createParticipant` auto-links to a canonical participant by `(sport_id, name)`.
|
||||||
|
|
||||||
|
Tennis note: Brackt models tennis as two sports (`Tennis - Men`, `Tennis - Women`). Each real-world tournament becomes two canonical rows. Entering women's Wimbledon does not fan out to men's windows — by design.
|
||||||
|
|
||||||
## Scoring Event Dates (Non-Obvious Gotcha)
|
## Scoring Event Dates (Non-Obvious Gotcha)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,19 @@ Season status drives UI visibility: draft order shown in `pre_draft`, draft cont
|
||||||
| Model | Description |
|
| Model | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Sport** | Base sport definition (e.g. NFL, NBA); type is `team` or `individual` |
|
| **Sport** | Base sport definition (e.g. NFL, NBA); type is `team` or `individual` |
|
||||||
| **Sports Season** | A specific season of a sport (e.g. "2024 NFL Season") |
|
| **Sports Season** | A specific rolling window of a sport (e.g. "Tennis June 2026") |
|
||||||
| **Participant** | An athlete or team that can be drafted |
|
| **Season Participant** | A draftable roster entry for one sports season — links to a canonical Participant |
|
||||||
| **Season Template** | Reusable configuration for creating league seasons |
|
| **Season Template** | Reusable configuration for creating league seasons |
|
||||||
| **Season Sport** | Junction: links a fantasy season to the sports it covers |
|
| **Season Sport** | Junction: links a fantasy season to the sports it covers |
|
||||||
| **Participant Result** | Final standings/scores for participants at season end |
|
| **Season Participant Result** | Final standings/scores for season participants |
|
||||||
|
|
||||||
|
## Canonical Layer (Qualifying-Points Sports)
|
||||||
|
|
||||||
|
Golf, tennis, and CS2 rolling windows share real-world data via a canonical layer. See `docs/agents/database.md` for the schema split.
|
||||||
|
|
||||||
|
| Model | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Participant (canonical)** | Real-world player or team (Djokovic, NAVI) — shared across windows |
|
||||||
|
| **Tournament** | Real-world tournament (Wimbledon 2026) — shared across windows |
|
||||||
|
| **Tournament Result** | Final placement in a real-world tournament — entered once at `/admin/tournaments/:id`, fanned out to every linked window via `syncTournamentResults` |
|
||||||
|
| **Participant Surface Elo** | Canonical surface-specific Elo (tennis) — read by the tennis simulator |
|
||||||
|
|
|
||||||
1
drizzle/0090_powerful_redwing.sql
Normal file
1
drizzle/0090_powerful_redwing.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE "season_participant_surface_elos" CASCADE;
|
||||||
5603
drizzle/meta/0090_snapshot.json
Normal file
5603
drizzle/meta/0090_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -631,6 +631,13 @@
|
||||||
"when": 1777675084016,
|
"when": 1777675084016,
|
||||||
"tag": "0089_lonely_tigra",
|
"tag": "0089_lonely_tigra",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 90,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777677410094,
|
||||||
|
"tag": "0090_powerful_redwing",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
"db:generate": "dotenv -- drizzle-kit generate",
|
"db:generate": "dotenv -- drizzle-kit generate",
|
||||||
"db:migrate": "dotenv -- drizzle-kit migrate",
|
"db:migrate": "dotenv -- drizzle-kit migrate",
|
||||||
"db:sync-prod": "bash scripts/sync-prod-db.sh",
|
"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",
|
"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": "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",
|
"start:production": "NODE_ENV=production NODE_OPTIONS='--import ./instrument.server.mjs' node dist/server.js",
|
||||||
|
|
|
||||||
|
|
@ -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<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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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<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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
});
|
|
||||||
|
|
@ -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/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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";
|
|
||||||
Loading…
Add table
Reference in a new issue