refactor(surface-elo): canonical-only; drop per-window path

batchUpsertSurfaceElos now writes only to the canonical
participant_surface_elos table (the mirror step is now the only step).
getSurfaceElosForSeason joins through season_participants → canonical
participants so the admin UI keeps its existing (id, participantId,
sportsSeasonId, ...) row shape.

season_participants without a canonical link get silently skipped. In
practice every qualifying-points roster entry is canonical-linked after
Phase 2 backfill + Phase 3 auto-linking on createParticipant.

Phase 4 Tasks 2 + 3 of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-05-01 23:01:59 +00:00
parent 5732afd93b
commit 549b437466
No known key found for this signature in database
2 changed files with 61 additions and 98 deletions

View file

@ -72,20 +72,21 @@ describe("batchUpsertSurfaceElos", () => {
expect(mockDb.insert).not.toHaveBeenCalled();
});
it("calls insert with the correct values on the per-window table", async () => {
// The function first upserts to the per-window table, then reads
// season_participants for canonical-id lookup. Make the follow-up select
// return empty so the canonical write path short-circuits and we only
// observe the per-window insert in this assertion.
mockDb.where.mockResolvedValue([]);
it("writes canonical rows using resolved canonical participant ids", async () => {
// season_participants lookup returns canonical links for p1 and p2.
mockDb.where.mockResolvedValue([
{ id: "p1", canonicalId: "canon-1" },
{ id: "p2", canonicalId: "canon-2" },
]);
const inputs = [
await batchUpsertSurfaceElos([
{ participantId: "p1", sportsSeasonId: "s1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 },
{ participantId: "p2", sportsSeasonId: "s1", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: undefined },
];
await batchUpsertSurfaceElos(inputs);
]);
expect(mockDb.insert).toHaveBeenCalledOnce();
expect(mockDb.values).toHaveBeenCalledOnce();
expect(mockDb.onConflictDoUpdate).toHaveBeenCalledOnce();
const passedValues = mockDb.values.mock.calls[0][0] as Array<{
participantId: string;
@ -94,42 +95,21 @@ describe("batchUpsertSurfaceElos", () => {
eloGrass: number | null;
}>;
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[1].participantId).toBe("canon-2");
expect(passedValues[1].eloHard).toBeNull();
expect(passedValues[1].eloGrass).toBeNull(); // undefined → null
});
it("uses onConflictDoUpdate for upsert behaviour", async () => {
mockDb.where.mockResolvedValue([]); // no canonical links; only per-window write
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" },
]);
it("skips season_participants that have no canonical link", async () => {
mockDb.where.mockResolvedValue([]); // no canonical links; short-circuits.
await batchUpsertSurfaceElos([
{ participantId: "p1", sportsSeasonId: "s1", worldRanking: 5, eloHard: 2000 },
]);
// One per-window insert, one canonical insert → two inserts total.
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);
expect(mockDb.insert).not.toHaveBeenCalled();
});
});

View file

@ -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).
* Each participant in a sports season can have separate Elo ratings for hard,
* clay, and grass courts.
* Surface Elos are stored in the canonical `participant_surface_elos` table,
* keyed by canonical participant id. The admin UI still works in terms of
* 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 { participantSurfaceElos, seasonParticipantSurfaceElos, seasonParticipants } from "~/database/schema";
import { participantSurfaceElos, seasonParticipants } from "~/database/schema";
import { eq, inArray, sql } from "drizzle-orm";
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.
* Returns one record per participant (seasonParticipants with no Elo record are excluded).
* Load surface Elos for a sports season's roster, joined with the per-window
* 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(
sportsSeasonId: string
sportsSeasonId: string,
): Promise<SurfaceEloWithName[]> {
const db = database();
const rows = await db
return await db
.select({
id: seasonParticipantSurfaceElos.id,
participantId: seasonParticipantSurfaceElos.participantId,
sportsSeasonId: seasonParticipantSurfaceElos.sportsSeasonId,
worldRanking: seasonParticipantSurfaceElos.worldRanking,
eloHard: seasonParticipantSurfaceElos.eloHard,
eloClay: seasonParticipantSurfaceElos.eloClay,
eloGrass: seasonParticipantSurfaceElos.eloGrass,
updatedAt: seasonParticipantSurfaceElos.updatedAt,
id: participantSurfaceElos.id,
participantId: seasonParticipants.id,
sportsSeasonId: seasonParticipants.sportsSeasonId,
worldRanking: participantSurfaceElos.worldRanking,
eloHard: participantSurfaceElos.eloHard,
eloClay: participantSurfaceElos.eloClay,
eloGrass: participantSurfaceElos.eloGrass,
updatedAt: participantSurfaceElos.updatedAt,
participantName: seasonParticipants.name,
})
.from(seasonParticipantSurfaceElos)
.innerJoin(seasonParticipants, eq(seasonParticipantSurfaceElos.participantId, seasonParticipants.id))
.where(eq(seasonParticipantSurfaceElos.sportsSeasonId, sportsSeasonId))
.from(seasonParticipants)
.innerJoin(
participantSurfaceElos,
eq(participantSurfaceElos.participantId, seasonParticipants.participantId),
)
.where(eq(seasonParticipants.sportsSeasonId, sportsSeasonId))
.orderBy(seasonParticipants.name);
return rows;
}
/**
* Upsert surface Elo ratings for a batch of seasonParticipants.
* Uses INSERT ... ON CONFLICT DO UPDATE so all three surface columns are
* overwritten atomically the admin always submits all three values.
* Upsert surface Elos for a batch of season_participants. Writes to the
* canonical `participant_surface_elos` table; callers pass season_participant
* 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(
inputs: SurfaceEloInput[]
inputs: SurfaceEloInput[],
): Promise<void> {
if (inputs.length === 0) return;
const db = database();
const now = new Date();
await db
.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.
// Resolve canonical ids for every season_participant in the batch.
const sps = await db
.select({ id: seasonParticipants.id, canonicalId: seasonParticipants.participantId })
.from(seasonParticipants)
@ -141,20 +128,16 @@ export async function batchUpsertSurfaceElos(
}
/**
* Returns a Map from participantId to surface Elos for use in the simulator.
* Participants with no record are absent from the map (simulator falls back to 1500).
*/
/**
* Build a map keyed by seasonParticipant.id surface Elo values, sourced from
* the canonical `participant_surface_elos` table. Joins seasonParticipants
* canonical participants canonical surfaceElo.
* Build a map keyed by season_participant.id surface Elo values, sourced
* from the canonical `participant_surface_elos` table. Used by the tennis
* simulator.
*
* Season participants without a linked canonical participant, or with no
* canonical surface Elo row, are simply absent from the map callers should
* handle `undefined` for such cases (as the tennis simulator does).
* canonical surface Elo row, are absent from the map callers handle
* `undefined` by falling back to 1500 (the simulator's default).
*/
export async function getSurfaceEloMap(
sportsSeasonId: string
sportsSeasonId: string,
): Promise<Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>> {
const db = database();
const rows = await db