mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 07:37:55 -05:00
Differ
This commit is contained in:
503
apps/web-react/scripts/route-census.ts
Normal file
503
apps/web-react/scripts/route-census.ts
Normal file
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* Generates the differ's route census: every URL pattern the app serves,
|
||||
* expanded with representative params drawn from the seeded database at
|
||||
* `DB_PATH`. Never hand-listed — the pattern list comes from walking
|
||||
* `app/routes.ts`, and a dynamic segment without a resolver below is a hard
|
||||
* error so new routes cannot silently drop out of differential coverage.
|
||||
*
|
||||
* Usage: DB_PATH=<seeded db> vite-node scripts/route-census.ts [--out census.json]
|
||||
*
|
||||
* A resolver that finds no seed data marks its rows `skipped` (with the reason
|
||||
* in the census) instead of failing: that is a seed-coverage gap to close, not
|
||||
* a census bug, and the differ report keeps it visible.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// routes.ts branches on NODE_ENV at module scope; the census must describe the
|
||||
// production build the differ serves, never the dev-only routes
|
||||
process.env.NODE_ENV = "production";
|
||||
|
||||
// the production config demands real credentials at import time, but this
|
||||
// script only ever reads the database; the e2e flag makes config fall back to
|
||||
// its development defaults, matching the build the differ serves
|
||||
process.env.VITE_E2E_TEST_RUN = "true";
|
||||
|
||||
if (!process.env.DB_PATH) {
|
||||
throw new Error("DB_PATH must point to a seeded database");
|
||||
}
|
||||
|
||||
const APP_DIR = fileURLToPath(new URL("..", import.meta.url));
|
||||
|
||||
type RouteEntry = {
|
||||
file: string;
|
||||
path?: string;
|
||||
index?: boolean;
|
||||
children?: RouteEntry[];
|
||||
};
|
||||
|
||||
export interface CensusRow {
|
||||
pattern: string;
|
||||
url: string | null;
|
||||
file: string;
|
||||
kind: "page" | "resource";
|
||||
skipped?: string;
|
||||
}
|
||||
|
||||
const routes = (await import("../app/routes")).default as RouteEntry[];
|
||||
const { db } = await import("~/db/sql");
|
||||
const { ADMIN_DISCORD_ID } = await import("~/features/admin/admin-constants");
|
||||
const { weaponNameSlugToId } = await import("~/utils/unslugify.server");
|
||||
const { databaseTimestampToDate, weekNumberToDateRange } = await import(
|
||||
"~/utils/dates"
|
||||
);
|
||||
|
||||
const rows = await buildCensus();
|
||||
|
||||
const outFlagIndex = process.argv.indexOf("--out");
|
||||
const outPath = outFlagIndex === -1 ? null : process.argv[outFlagIndex + 1];
|
||||
|
||||
const adminUser = await db
|
||||
.selectFrom("User")
|
||||
.select(["id"])
|
||||
.where("discordId", "=", ADMIN_DISCORD_ID)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const census = {
|
||||
seedNow: process.env.SEED_NOW ?? null,
|
||||
adminUserId: adminUser.id,
|
||||
rows,
|
||||
};
|
||||
|
||||
if (outPath) {
|
||||
fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
|
||||
fs.writeFileSync(path.resolve(outPath), JSON.stringify(census, null, "\t"));
|
||||
const skipped = rows.filter((row) => row.skipped);
|
||||
// biome-ignore lint/suspicious/noConsole: CLI script output
|
||||
console.log(
|
||||
`Census: ${rows.length} rows (${skipped.length} skipped) -> ${outPath}`,
|
||||
);
|
||||
for (const row of skipped) {
|
||||
// biome-ignore lint/suspicious/noConsole: CLI script output
|
||||
console.log(` skipped ${row.pattern}: ${row.skipped}`);
|
||||
}
|
||||
} else {
|
||||
// biome-ignore lint/suspicious/noConsole: CLI script output
|
||||
console.log(JSON.stringify(census, null, "\t"));
|
||||
}
|
||||
|
||||
async function buildCensus(): Promise<CensusRow[]> {
|
||||
const patterns = new Map<string, { file: string }>();
|
||||
|
||||
const walk = (entries: RouteEntry[], parentPath: string) => {
|
||||
for (const entry of entries) {
|
||||
const fullPath = entry.index
|
||||
? parentPath
|
||||
: joinPaths(parentPath, entry.path);
|
||||
|
||||
if (entry.children) {
|
||||
walk(entry.children, fullPath);
|
||||
}
|
||||
|
||||
const rendersUrl = entry.index || entry.path !== undefined;
|
||||
if (rendersUrl && !patterns.has(fullPath)) {
|
||||
patterns.set(fullPath, { file: entry.file });
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(routes, "/");
|
||||
|
||||
const resolve = createParamResolver();
|
||||
const result: CensusRow[] = [];
|
||||
|
||||
for (const [pattern, { file }] of patterns) {
|
||||
const kind: CensusRow["kind"] = file.endsWith(".tsx") ? "page" : "resource";
|
||||
|
||||
for (const variant of expandOptionalParams(pattern)) {
|
||||
const paramNames = [...variant.matchAll(/:([A-Za-z]+)/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
|
||||
if (paramNames.length === 0) {
|
||||
result.push({ pattern: variant, url: variant, file, kind });
|
||||
continue;
|
||||
}
|
||||
|
||||
const params = await resolve(variant);
|
||||
if (params === null) {
|
||||
result.push({
|
||||
pattern: variant,
|
||||
url: null,
|
||||
file,
|
||||
kind,
|
||||
skipped: "no seed data for this pattern's params",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let url = variant;
|
||||
for (const name of paramNames) {
|
||||
const value = params[name];
|
||||
if (value === undefined || value === null) {
|
||||
throw new Error(
|
||||
`Resolver for ${variant} did not provide a value for :${name}`,
|
||||
);
|
||||
}
|
||||
url = url.replace(`:${name}`, encodeURIComponent(String(value)));
|
||||
}
|
||||
result.push({ pattern: variant, url, file, kind });
|
||||
}
|
||||
}
|
||||
|
||||
return result.sort((a, b) => a.pattern.localeCompare(b.pattern));
|
||||
}
|
||||
|
||||
function joinPaths(parent: string, child?: string) {
|
||||
if (child === undefined) return parent;
|
||||
if (child.startsWith("/")) return child.replace(/\/+$/, "") || "/";
|
||||
return `${parent === "/" ? "" : parent}/${child}`;
|
||||
}
|
||||
|
||||
/** `registration/:tid?` expands into rows both with and without the segment. */
|
||||
function expandOptionalParams(pattern: string): string[] {
|
||||
const optional = pattern.match(/\/:([A-Za-z]+)\?/);
|
||||
if (!optional) return [pattern];
|
||||
|
||||
const without = pattern.replace(optional[0], "");
|
||||
const withParam = pattern.replace(optional[0], `/:${optional[1]}`);
|
||||
return [
|
||||
...expandOptionalParams(without || "/"),
|
||||
...expandOptionalParams(withParam),
|
||||
];
|
||||
}
|
||||
|
||||
type ParamValues = Record<string, string | number> | null;
|
||||
|
||||
/**
|
||||
* Maps a URL pattern to representative param values from the seeded database.
|
||||
* Rules are matched by pattern prefix so related routes share one lookup;
|
||||
* a pattern with params that no rule covers is a hard error.
|
||||
*/
|
||||
function createParamResolver() {
|
||||
const cache = new Map<string, Promise<ParamValues>>();
|
||||
const cached = (key: string, load: () => Promise<ParamValues>) => {
|
||||
let value = cache.get(key);
|
||||
if (!value) {
|
||||
value = load();
|
||||
cache.set(key, value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const adminUser = () =>
|
||||
cached("adminUser", async () => {
|
||||
const user = await db
|
||||
.selectFrom("User")
|
||||
.select(["id", "discordId", "customUrl"])
|
||||
.where("discordId", "=", ADMIN_DISCORD_ID)
|
||||
.executeTakeFirst();
|
||||
if (!user) return null;
|
||||
return {
|
||||
id: user.id,
|
||||
userId: user.id,
|
||||
identifier: user.customUrl ?? user.discordId,
|
||||
customUrl: user.customUrl ?? user.discordId,
|
||||
};
|
||||
});
|
||||
|
||||
const tournamentContext = () =>
|
||||
cached("tournament", async () => {
|
||||
const match = await db
|
||||
.selectFrom("TournamentMatch")
|
||||
.innerJoin(
|
||||
"TournamentStage",
|
||||
"TournamentStage.id",
|
||||
"TournamentMatch.stageId",
|
||||
)
|
||||
.select(["TournamentMatch.id as mid", "TournamentStage.tournamentId"])
|
||||
.where("TournamentMatch.startedAt", "is not", null)
|
||||
.orderBy("TournamentMatch.id", "asc")
|
||||
.executeTakeFirst();
|
||||
if (!match) return null;
|
||||
|
||||
const team = await db
|
||||
.selectFrom("TournamentTeam")
|
||||
.select(["id"])
|
||||
.where("tournamentId", "=", match.tournamentId)
|
||||
.orderBy("id", "asc")
|
||||
.executeTakeFirst();
|
||||
if (!team) return null;
|
||||
|
||||
return {
|
||||
id: match.tournamentId,
|
||||
mid: match.mid,
|
||||
tid: team.id,
|
||||
teamId: team.id,
|
||||
bidx: 0,
|
||||
};
|
||||
});
|
||||
|
||||
const weaponSlug = () =>
|
||||
cached("weaponSlug", async () => {
|
||||
const slug = "splattershot";
|
||||
if (weaponNameSlugToId(slug) === null) {
|
||||
throw new Error(`"${slug}" no longer resolves to a weapon id`);
|
||||
}
|
||||
return { slug };
|
||||
});
|
||||
|
||||
const calendarWeek = () =>
|
||||
cached("calendarWeek", async () => {
|
||||
const eventDate = await db
|
||||
.selectFrom("CalendarEventDate")
|
||||
.select(["startsAt"])
|
||||
.orderBy("startsAt", "asc")
|
||||
.executeTakeFirst();
|
||||
if (!eventDate) return null;
|
||||
|
||||
const date = databaseTimestampToDate(eventDate.startsAt);
|
||||
for (const year of [date.getFullYear() - 1, date.getFullYear()]) {
|
||||
for (let week = 1; week <= 53; week++) {
|
||||
const { startTime, endTime } = weekNumberToDateRange({ year, week });
|
||||
if (date >= startTime && date <= endTime) {
|
||||
return { year, week };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const rules: Array<{
|
||||
matches: (pattern: string) => boolean;
|
||||
params: () => Promise<ParamValues>;
|
||||
}> = [
|
||||
{
|
||||
matches: (pattern) =>
|
||||
pattern.startsWith("/u/:identifier") ||
|
||||
pattern.startsWith("/api/user/:identifier") ||
|
||||
pattern.startsWith("/api/user/:userId") ||
|
||||
pattern.startsWith("/api/sendouq/active-match/:userId") ||
|
||||
pattern.startsWith("/user-card/:id") ||
|
||||
pattern === "/user-report/:id",
|
||||
params: adminUser,
|
||||
},
|
||||
{
|
||||
matches: (pattern) => pattern === "/short/:customUrl",
|
||||
params: () =>
|
||||
cached("shortUrlUser", async () => {
|
||||
const user = await db
|
||||
.selectFrom("User")
|
||||
.select(["customUrl"])
|
||||
.where("customUrl", "is not", null)
|
||||
.orderBy("id", "asc")
|
||||
.executeTakeFirst();
|
||||
return user?.customUrl ? { customUrl: user.customUrl } : null;
|
||||
}),
|
||||
},
|
||||
{
|
||||
matches: (pattern) => pattern.startsWith("/badges/:id"),
|
||||
params: () =>
|
||||
cached("badge", async () => {
|
||||
const badge = await db
|
||||
.selectFrom("Badge")
|
||||
.select(["id"])
|
||||
.orderBy("id", "asc")
|
||||
.executeTakeFirst();
|
||||
return badge ? { id: badge.id } : null;
|
||||
}),
|
||||
},
|
||||
{
|
||||
matches: (pattern) => pattern.startsWith("/trophies/:id"),
|
||||
params: () =>
|
||||
cached("trophy", async () => {
|
||||
const owner = await db
|
||||
.selectFrom("TrophyOwner")
|
||||
.select(["trophyId", "userId"])
|
||||
.orderBy("trophyId", "asc")
|
||||
.executeTakeFirst();
|
||||
if (owner) return { id: owner.trophyId, userId: owner.userId };
|
||||
|
||||
const trophy = await db
|
||||
.selectFrom("Trophy")
|
||||
.select(["id"])
|
||||
.orderBy("id", "asc")
|
||||
.executeTakeFirst();
|
||||
const admin = await adminUser();
|
||||
if (!trophy || !admin) return null;
|
||||
return { id: trophy.id, userId: admin.id };
|
||||
}),
|
||||
},
|
||||
{
|
||||
matches: (pattern) => pattern.startsWith("/calendar/:id"),
|
||||
params: () =>
|
||||
cached("calendarEvent", async () => {
|
||||
const event = await db
|
||||
.selectFrom("CalendarEvent")
|
||||
.select(["id"])
|
||||
.where("tournamentId", "is", null)
|
||||
.orderBy("id", "asc")
|
||||
.executeTakeFirst();
|
||||
return event ? { id: event.id } : null;
|
||||
}),
|
||||
},
|
||||
{
|
||||
matches: (pattern) => pattern === "/api/calendar/:year/:week",
|
||||
params: calendarWeek,
|
||||
},
|
||||
{
|
||||
matches: (pattern) =>
|
||||
pattern.startsWith("/to/:id") ||
|
||||
pattern.startsWith("/api/tournament/:id"),
|
||||
params: tournamentContext,
|
||||
},
|
||||
{
|
||||
matches: (pattern) => pattern === "/api/tournament-match/:id",
|
||||
params: () =>
|
||||
cached("tournamentMatch", async () => {
|
||||
const context = await tournamentContext();
|
||||
return context ? { id: context.mid } : null;
|
||||
}),
|
||||
},
|
||||
{
|
||||
matches: (pattern) => pattern.startsWith("/org/:slug"),
|
||||
params: () =>
|
||||
cached("org", async () => {
|
||||
const org = await db
|
||||
.selectFrom("TournamentOrganization")
|
||||
.select(["slug"])
|
||||
.orderBy("id", "asc")
|
||||
.executeTakeFirst();
|
||||
return org ? { slug: org.slug } : null;
|
||||
}),
|
||||
},
|
||||
{
|
||||
matches: (pattern) => pattern === "/api/org/:id",
|
||||
params: () =>
|
||||
cached("orgId", async () => {
|
||||
const org = await db
|
||||
.selectFrom("TournamentOrganization")
|
||||
.select(["id"])
|
||||
.orderBy("id", "asc")
|
||||
.executeTakeFirst();
|
||||
return org ? { id: org.id } : null;
|
||||
}),
|
||||
},
|
||||
{
|
||||
matches: (pattern) => pattern.startsWith("/t/:customUrl"),
|
||||
params: () =>
|
||||
cached("team", async () => {
|
||||
const team = await db
|
||||
.selectFrom("Team")
|
||||
.select(["customUrl"])
|
||||
.orderBy("id", "asc")
|
||||
.executeTakeFirst();
|
||||
return team ? { customUrl: team.customUrl } : null;
|
||||
}),
|
||||
},
|
||||
{
|
||||
matches: (pattern) => pattern === "/api/team/:id",
|
||||
params: () =>
|
||||
cached("teamId", async () => {
|
||||
const team = await db
|
||||
.selectFrom("Team")
|
||||
.select(["id"])
|
||||
.orderBy("id", "asc")
|
||||
.executeTakeFirst();
|
||||
return team ? { id: team.id } : null;
|
||||
}),
|
||||
},
|
||||
{
|
||||
matches: (pattern) => pattern === "/vods/:id",
|
||||
params: () =>
|
||||
cached("vod", async () => {
|
||||
const vod = await db
|
||||
.selectFrom("UnvalidatedVideo")
|
||||
.select(["id"])
|
||||
.orderBy("id", "asc")
|
||||
.executeTakeFirst();
|
||||
return vod ? { id: vod.id } : null;
|
||||
}),
|
||||
},
|
||||
{
|
||||
matches: (pattern) =>
|
||||
pattern.startsWith("/builds/:slug") || pattern === "/params/:slug",
|
||||
params: weaponSlug,
|
||||
},
|
||||
{
|
||||
matches: (pattern) => pattern === "/xsearch/player/:id",
|
||||
params: () =>
|
||||
cached("xrankPlayer", async () => {
|
||||
const placement = await db
|
||||
.selectFrom("XRankPlacement")
|
||||
.select(["playerId"])
|
||||
.orderBy("id", "asc")
|
||||
.executeTakeFirst();
|
||||
return placement ? { id: placement.playerId } : null;
|
||||
}),
|
||||
},
|
||||
{
|
||||
matches: (pattern) =>
|
||||
pattern === "/q/match/:id" || pattern === "/api/sendouq/match/:matchId",
|
||||
params: () =>
|
||||
cached("groupMatch", async () => {
|
||||
const match = await db
|
||||
.selectFrom("GroupMatch")
|
||||
.select(["id"])
|
||||
.orderBy("id", "asc")
|
||||
.executeTakeFirst();
|
||||
return match ? { id: match.id, matchId: match.id } : null;
|
||||
}),
|
||||
},
|
||||
{
|
||||
matches: (pattern) => pattern === "/scrims/:id",
|
||||
params: () =>
|
||||
cached("scrimPost", async () => {
|
||||
const post = await db
|
||||
.selectFrom("ScrimPost")
|
||||
.select(["id"])
|
||||
.orderBy("id", "asc")
|
||||
.executeTakeFirst();
|
||||
return post ? { id: post.id } : null;
|
||||
}),
|
||||
},
|
||||
{
|
||||
matches: (pattern) => pattern === "/a/:slug",
|
||||
params: () =>
|
||||
cached("article", async () => {
|
||||
const articlesDir = path.join(APP_DIR, "content", "articles");
|
||||
const slug = fs
|
||||
.readdirSync(articlesDir)
|
||||
.filter((file) => file.endsWith(".md"))
|
||||
.sort()[0]
|
||||
?.replace(/\.md$/, "");
|
||||
return slug ? { slug } : null;
|
||||
}),
|
||||
},
|
||||
{
|
||||
matches: (pattern) =>
|
||||
pattern === "/plus/suggestions/comment/:tier/:userId",
|
||||
params: () =>
|
||||
cached("plusSuggestion", async () => {
|
||||
const suggestion = await db
|
||||
.selectFrom("PlusSuggestion")
|
||||
.select(["tier", "suggestedId"])
|
||||
.orderBy("id", "asc")
|
||||
.executeTakeFirst();
|
||||
return suggestion
|
||||
? { tier: suggestion.tier, userId: suggestion.suggestedId }
|
||||
: null;
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
return async (pattern: string): Promise<ParamValues> => {
|
||||
const rule = rules.find((candidate) => candidate.matches(pattern));
|
||||
if (!rule) {
|
||||
throw new Error(
|
||||
`No param resolver covers "${pattern}" — add one to scripts/route-census.ts`,
|
||||
);
|
||||
}
|
||||
return await rule.params();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user