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(); 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);
}); });
}); });

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). * 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