sendou.ink/app/features/builds/BuildRepository.server.ts
Kalle 8dc92140fc
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled
Optimize builds loaders (#3076)
2026-05-17 16:21:13 +03:00

545 lines
15 KiB
TypeScript

import { type NotNull, sql, type Transaction } from "kysely";
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { BuildWeapon, DB, TablesInsertable } from "~/db/tables";
import { modesShort } from "~/modules/in-game-lists/modes";
import type {
Ability,
BuildAbilitiesTuple,
MainWeaponId,
ModeShort,
} from "~/modules/in-game-lists/types";
import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { LimitReachedError } from "~/utils/errors";
import invariant from "~/utils/invariant";
import { commonUserJsonObject } from "~/utils/kysely.server";
import {
MAIN_SLOT_AP,
SUB_SLOT_AP,
} from "../build-analyzer/analyzer-constants";
import { BUILD } from "./builds-constants";
import { sortAbilities } from "./core/ability-sorting.server";
export async function allByUserId(
userId: number,
options: {
showPrivate?: boolean;
sortAbilities?: boolean;
limit?: number;
} = {},
) {
const {
showPrivate = false,
sortAbilities: shouldSortAbilities = false,
limit,
} = options;
const rows = await db
.selectFrom("Build")
.select(({ eb }) => [
"Build.id",
"Build.title",
"Build.description",
"Build.modes",
"Build.headGearSplId",
"Build.clothesGearSplId",
"Build.shoesGearSplId",
"Build.updatedAt",
"Build.private",
"Build.abilities",
jsonArrayFrom(
eb
.selectFrom("BuildWeapon")
.select(["BuildWeapon.weaponSplId", "BuildWeapon.sortValue"])
.orderBy("BuildWeapon.weaponSplId", "asc")
.whereRef("BuildWeapon.buildId", "=", "Build.id"),
).as("weapons"),
])
.where("Build.ownerId", "=", userId)
.$if(!showPrivate, (qb) => qb.where("Build.private", "=", 0))
.$if(typeof limit === "number", (qb) => qb.limit(limit!))
.orderBy("Build.updatedAt", "desc")
.execute();
return rows.map((row) => buildRowToResult(row, shouldSortAbilities));
}
interface CreateArgs {
ownerId: TablesInsertable["Build"]["ownerId"];
title: TablesInsertable["Build"]["title"];
description: TablesInsertable["Build"]["description"];
modes: Array<ModeShort> | null;
headGearSplId: number | null;
clothesGearSplId: number | null;
shoesGearSplId: number | null;
weaponSplIds: Array<BuildWeapon["weaponSplId"]>;
abilities: BuildAbilitiesTuple;
private: TablesInsertable["Build"]["private"];
}
export async function create(args: CreateArgs) {
return db.transaction().execute(async (trx) => {
const computed = await computeBuildData(trx, args);
const updatedAt = dateToDatabaseTimestamp(new Date());
const { id: buildId } = await trx
.insertInto("Build")
.values({
ownerId: args.ownerId,
title: args.title,
description: args.description,
modes: serializeModes(args.modes),
headGearSplId: args.headGearSplId,
clothesGearSplId: args.clothesGearSplId,
shoesGearSplId: args.shoesGearSplId,
private: args.private,
abilities: JSON.stringify(args.abilities),
abilitiesSignature: computed.abilitiesSignature,
updatedAt,
})
.returning("id")
.executeTakeFirstOrThrow();
await insertBuildChildrenInTrx({
trx,
buildId,
args,
computed,
updatedAt,
});
const { count } = await trx
.selectFrom("Build")
.select((eb) => eb.fn.countAll<number>().as("count"))
.where("ownerId", "=", args.ownerId)
.executeTakeFirstOrThrow();
if (count > BUILD.MAX_COUNT) {
throw new LimitReachedError("Max amount of builds reached");
}
});
}
export async function update(args: CreateArgs & { id: number }) {
return db.transaction().execute(async (trx) => {
const computed = await computeBuildData(trx, args);
const updatedAt = dateToDatabaseTimestamp(new Date());
await trx
.updateTable("Build")
.set({
title: args.title,
description: args.description,
modes: serializeModes(args.modes),
headGearSplId: args.headGearSplId,
clothesGearSplId: args.clothesGearSplId,
shoesGearSplId: args.shoesGearSplId,
private: args.private,
abilities: JSON.stringify(args.abilities),
abilitiesSignature: computed.abilitiesSignature,
updatedAt,
})
.where("id", "=", args.id)
.execute();
await trx
.deleteFrom("BuildWeapon")
.where("buildId", "=", args.id)
.execute();
await trx
.deleteFrom("BuildAbilitySum")
.where("buildId", "=", args.id)
.execute();
await trx
.deleteFrom("BuildWeaponAbility")
.where("buildId", "=", args.id)
.execute();
await insertBuildChildrenInTrx({
trx,
buildId: args.id,
args,
computed,
updatedAt,
});
});
}
export function deleteById(id: number) {
return db.deleteFrom("Build").where("id", "=", id).execute();
}
export async function ownerIdById(buildId: number) {
const result = await db
.selectFrom("Build")
.select("ownerId")
.where("id", "=", buildId)
.executeTakeFirst();
return result?.ownerId ?? null;
}
export async function abilityPointAverages(weaponSplId?: MainWeaponId | null) {
// Sum tables only contain rows for public builds,
// so the queries below need no private filter and no `Build` join.
if (typeof weaponSplId === "number") {
return db
.selectFrom("BuildWeaponAbility")
.select(({ fn }) => [
"BuildWeaponAbility.ability",
fn
.sum<number>("BuildWeaponAbility.abilityPoints")
.as("abilityPointsSum"),
])
.where(
"BuildWeaponAbility.canonicalWeaponSplId",
"=",
canonicalWeaponSplId(weaponSplId),
)
.groupBy("BuildWeaponAbility.ability")
.execute();
}
return db
.selectFrom("BuildAbilitySum")
.select(({ fn }) => [
"BuildAbilitySum.ability",
fn.sum<number>("BuildAbilitySum.abilityPoints").as("abilityPointsSum"),
])
.groupBy("BuildAbilitySum.ability")
.execute();
}
export async function popularAbilitiesByWeaponId(weaponSplId: MainWeaponId) {
// One signature per user — otherwise a user with several builds for the
// same weapon (e.g. three different Slosher loadouts) would inflate three
// different signature buckets. The CTE picks each user's most recently
// updated public build for the weapon via SQLite's MAX() + bare columns
// rule (https://www.sqlite.org/lang_select.html#bareagg).
return db
.with("UserSignature", (cte) =>
cte
.selectFrom("Build")
.innerJoin("BuildWeapon", "BuildWeapon.buildId", "Build.id")
.select(({ fn }) => [
"Build.abilitiesSignature",
fn.max("Build.updatedAt").as("latestUpdatedAt"),
])
.where(
"BuildWeapon.canonicalWeaponSplId",
"=",
canonicalWeaponSplId(weaponSplId),
)
.where("Build.private", "=", 0)
.where("Build.abilitiesSignature", "is not", null)
.groupBy("Build.ownerId"),
)
.selectFrom("UserSignature")
.select(({ fn }) => [
"UserSignature.abilitiesSignature",
fn.count<number>("UserSignature.abilitiesSignature").as("count"),
])
.groupBy("UserSignature.abilitiesSignature")
.having((eb) => eb(eb.fn.count("UserSignature.abilitiesSignature"), ">", 1))
.orderBy("count", "desc")
.orderBy("UserSignature.abilitiesSignature", "asc")
.limit(25)
.$narrowType<{ abilitiesSignature: NotNull }>()
.execute();
}
export type AverageAbilityPointsResult = Awaited<
ReturnType<typeof abilityPointAverages>
>[number];
export type PopularBuildsRow = Awaited<
ReturnType<typeof popularAbilitiesByWeaponId>
>[number];
export async function allByWeaponId(
weaponId: MainWeaponId,
options: { limit: number; sortAbilities?: boolean },
) {
const { limit, sortAbilities: shouldSortAbilities = false } = options;
const rows = await db
.selectFrom("BuildWeapon")
.innerJoin("Build", "Build.id", "BuildWeapon.buildId")
.innerJoin("User", "User.id", "Build.ownerId")
.leftJoin("PlusTier", "PlusTier.userId", "Build.ownerId")
.select(({ eb }) => [
"Build.id",
"Build.title",
"Build.description",
"Build.modes",
"Build.headGearSplId",
"Build.clothesGearSplId",
"Build.shoesGearSplId",
"Build.updatedAt",
"Build.private",
"Build.abilities",
"PlusTier.tier as plusTier",
commonUserJsonObject(eb).as("owner"),
jsonArrayFrom(
eb
.selectFrom("BuildWeapon as bw_inner")
.select(["bw_inner.weaponSplId", "bw_inner.sortValue"])
.orderBy("bw_inner.weaponSplId", "asc")
.whereRef("bw_inner.buildId", "=", "Build.id"),
).as("weapons"),
])
.where(
"BuildWeapon.canonicalWeaponSplId",
"=",
canonicalWeaponSplId(weaponId),
)
.where("BuildWeapon.sortValue", "is not", null)
.orderBy("BuildWeapon.sortValue", "asc")
.orderBy("BuildWeapon.updatedAt", "desc")
.limit(limit)
.execute();
return rows.map((row) => buildRowToResult(row, shouldSortAbilities));
}
/** Recomputes `BuildWeapon.sortValue` for every (build, weapon) from scratch
* (plus tier + per-weapon top500). When `userId` is provided, only the builds
* owned by that user are recomputed. */
export async function recalculateAllSortValues(userId?: number) {
await db.transaction().execute(async (trx) => {
// Pass 1: tier*2 + 1 for public, NULL for private.
await trx
.updateTable("BuildWeapon")
.set({
sortValue: sql<number | null>`(
select case
when "b"."private" = 1 then null
else coalesce(
(select "tier" from "PlusTier" where "userId" = "b"."ownerId"),
4
) * 2 + 1
end
from "Build" as "b"
where "b"."id" = "BuildWeapon"."buildId"
)`,
})
.$if(userId !== undefined, (qb) =>
qb.where((eb) =>
eb.exists(
eb
.selectFrom("Build as b")
.select("b.id")
.whereRef("b.id", "=", "BuildWeapon.buildId")
.where("b.ownerId", "=", userId!),
),
),
)
.execute();
// Pass 2: subtract 1 where this specific weapon is top500 for the owner.
await trx
.updateTable("BuildWeapon")
.set({
sortValue: sql<number>`"BuildWeapon"."sortValue" - 1`,
})
.where("BuildWeapon.sortValue", "is not", null)
.where((eb) =>
eb.exists(
eb
.selectFrom("Build as b")
.innerJoin("SplatoonPlayer as sp", "sp.userId", "b.ownerId")
.innerJoin("XRankPlacement as xrp", (join) =>
join
.onRef("xrp.playerId", "=", "sp.id")
.onRef("xrp.weaponSplId", "=", "BuildWeapon.weaponSplId"),
)
.select("b.id")
.whereRef("b.id", "=", "BuildWeapon.buildId")
.$if(userId !== undefined, (qb) =>
qb.where("b.ownerId", "=", userId!),
),
),
)
.execute();
});
}
// ---
function weaponIsTop500(sortValue: number | null): boolean {
return sortValue != null && sortValue % 2 === 0;
}
interface BuildRowToResultInput {
abilities: BuildAbilitiesTuple | null;
weapons: Array<{ weaponSplId: MainWeaponId; sortValue: number | null }>;
}
type BuildRowToResultOutput<T extends BuildRowToResultInput> = Omit<
T,
"abilities" | "weapons"
> & {
abilities: BuildAbilitiesTuple;
weapons: Array<{ weaponSplId: MainWeaponId; isTop500: number }>;
};
function buildRowToResult<T extends BuildRowToResultInput>(
row: T,
shouldSortAbilities: boolean,
): BuildRowToResultOutput<T> {
invariant(row.abilities, "expected build abilities to be populated");
return {
...row,
abilities: shouldSortAbilities
? sortAbilities(row.abilities)
: row.abilities,
weapons: row.weapons.map((w) => ({
weaponSplId: w.weaponSplId,
isTop500: weaponIsTop500(w.sortValue) ? 1 : 0,
})),
};
}
function serializeModes(modes: Array<ModeShort> | null) {
if (!modes || modes.length === 0) return null;
return JSON.stringify(
modes.slice().sort((a, b) => modesShort.indexOf(a) - modesShort.indexOf(b)),
);
}
interface ComputedBuildData {
abilitySums: Array<[Ability, number]>;
abilitiesSignature: string;
sortValueByWeaponSplId: Map<MainWeaponId, number | null>;
}
async function computeBuildData(
trx: Transaction<DB>,
args: CreateArgs,
): Promise<ComputedBuildData> {
const abilitySums = computeAbilitySums(args.abilities);
const tier =
(
await trx
.selectFrom("PlusTier")
.select("tier")
.where("userId", "=", args.ownerId)
.executeTakeFirst()
)?.tier ?? 4;
const top500Weapons = new Set<MainWeaponId>();
if (args.weaponSplIds.length > 0) {
const rows = await trx
.selectFrom("XRankPlacement")
.innerJoin(
"SplatoonPlayer",
"SplatoonPlayer.id",
"XRankPlacement.playerId",
)
.select("XRankPlacement.weaponSplId")
.where("SplatoonPlayer.userId", "=", args.ownerId)
.where("XRankPlacement.weaponSplId", "in", args.weaponSplIds)
.distinct()
.execute();
for (const r of rows) top500Weapons.add(r.weaponSplId);
}
const sortValueByWeaponSplId = new Map<MainWeaponId, number | null>();
for (const weaponSplId of args.weaponSplIds) {
sortValueByWeaponSplId.set(
weaponSplId,
args.private ? null : tier * 2 + (top500Weapons.has(weaponSplId) ? 0 : 1),
);
}
return {
abilitySums,
abilitiesSignature: serializeSignature(abilitySums),
sortValueByWeaponSplId,
};
}
function computeAbilitySums(
abilities: BuildAbilitiesTuple,
): Array<[Ability, number]> {
const sums = new Map<Ability, number>();
for (const row of abilities) {
for (let slotIdx = 0; slotIdx < row.length; slotIdx++) {
const ability = row[slotIdx];
const ap = slotIdx === 0 ? MAIN_SLOT_AP : SUB_SLOT_AP;
sums.set(ability, (sums.get(ability) ?? 0) + ap);
}
}
return [...sums.entries()];
}
function serializeSignature(sums: Array<[Ability, number]>): string {
return sums
.slice()
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([ability, ap]) => `${ability}_${ap}`)
.join(",");
}
async function insertBuildChildrenInTrx({
trx,
buildId,
args,
computed,
updatedAt,
}: {
trx: Transaction<DB>;
buildId: number;
args: CreateArgs;
computed: ComputedBuildData;
updatedAt: number;
}) {
await trx
.insertInto("BuildWeapon")
.values(
args.weaponSplIds.map((weaponSplId) => ({
buildId,
weaponSplId,
canonicalWeaponSplId: canonicalWeaponSplId(weaponSplId),
sortValue: computed.sortValueByWeaponSplId.get(weaponSplId) ?? null,
updatedAt,
})),
)
.execute();
// Private builds are excluded from the sum tables so the stats queries can
// run as pure covering-index scans. Visibility flips are handled implicitly
// by `update`'s delete-then-reinsert.
if (args.private) return;
await trx
.insertInto("BuildAbilitySum")
.values(
computed.abilitySums.map(([ability, abilityPoints]) => ({
buildId,
ability,
abilityPoints,
})),
)
.execute();
const weaponAbilityRows: TablesInsertable["BuildWeaponAbility"][] =
args.weaponSplIds.flatMap((weaponSplId) =>
computed.abilitySums.map(([ability, abilityPoints]) => ({
canonicalWeaponSplId: canonicalWeaponSplId(weaponSplId),
buildId,
ability,
abilityPoints,
})),
);
if (weaponAbilityRows.length > 0) {
await trx
.insertInto("BuildWeaponAbility")
.values(weaponAbilityRows)
.execute();
}
}