mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
Differ
This commit is contained in:
100
apps/web-react/scripts/ensure-e2e-build.ts
Normal file
100
apps/web-react/scripts/ensure-e2e-build.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
const BUILD_MARKER_FILE = ".e2e-build-marker";
|
||||
const BUILD_INPUTS = [
|
||||
"app",
|
||||
"public",
|
||||
"package.json",
|
||||
"../../pnpm-lock.yaml",
|
||||
"vite.config.ts",
|
||||
"react-router.config.ts",
|
||||
];
|
||||
|
||||
/**
|
||||
* Ensures `build/` holds a production build made with the e2e flag baked in
|
||||
* (`VITE_E2E_TEST_RUN=true`), reusing the previous build when no build input
|
||||
* changed since its marker was written. Shared by the e2e global setup and the
|
||||
* differ, which both serve this build. `siteDomain` is baked into the client
|
||||
* bundle; runtime servers on other ports override it via their environment.
|
||||
*
|
||||
* The last e2e build can be reused when no build input changed since its
|
||||
* marker was written. Directory mtimes catch deletes and renames; a build made
|
||||
* outside the e2e flow (no marker, or output newer than the marker) forces a
|
||||
* rebuild, as does a marker from a different port setup. E2E_FORCE_BUILD=true
|
||||
* overrides.
|
||||
*/
|
||||
export function ensureE2eBuild(siteDomain: string): { reused: boolean } {
|
||||
if (isBuildFresh(siteDomain)) {
|
||||
return { reused: true };
|
||||
}
|
||||
|
||||
fs.rmSync(path.join(ROOT_DIR, BUILD_MARKER_FILE), { force: true });
|
||||
execSync("pnpm run build", {
|
||||
stdio: "inherit",
|
||||
cwd: ROOT_DIR,
|
||||
env: {
|
||||
...process.env,
|
||||
VITE_E2E_TEST_RUN: "true",
|
||||
VITE_SITE_DOMAIN: siteDomain,
|
||||
// Skalop is disconnected in e2e: all workers sharing one instance
|
||||
// cross-talk (identical seeded row ids -> colliding room names ->
|
||||
// spurious revalidations). When e2e tests for chat etc. are added
|
||||
// this needs an actual solution: one skalop (or stub) per worker
|
||||
// with a runtime-derived ws URL, since this build is shared.
|
||||
VITE_SKALOP_WS_URL: "",
|
||||
},
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(ROOT_DIR, BUILD_MARKER_FILE),
|
||||
JSON.stringify({ siteDomain, skalopWsUrl: "" }),
|
||||
);
|
||||
|
||||
return { reused: false };
|
||||
}
|
||||
|
||||
function isBuildFresh(siteDomain: string): boolean {
|
||||
if (process.env.E2E_FORCE_BUILD === "true") return false;
|
||||
|
||||
const markerPath = path.join(ROOT_DIR, BUILD_MARKER_FILE);
|
||||
const serverEntryPath = path.join(ROOT_DIR, "build/server/index.js");
|
||||
if (!fs.existsSync(markerPath)) return false;
|
||||
if (!fs.existsSync(serverEntryPath)) return false;
|
||||
|
||||
try {
|
||||
const marker = JSON.parse(fs.readFileSync(markerPath, "utf8"));
|
||||
if (marker.siteDomain !== siteDomain) return false;
|
||||
if (marker.skalopWsUrl !== "") return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const markerMtime = fs.statSync(markerPath).mtimeMs;
|
||||
if (fs.statSync(serverEntryPath).mtimeMs > markerMtime) return false;
|
||||
|
||||
try {
|
||||
const changedInput = execSync(
|
||||
`find ${BUILD_INPUTS.join(" ")} -newer ${BUILD_MARKER_FILE} -print -quit`,
|
||||
{ cwd: ROOT_DIR },
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
return changedInput === "";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const siteDomain = process.env.E2E_BUILD_SITE_DOMAIN;
|
||||
if (!siteDomain) {
|
||||
throw new Error("E2E_BUILD_SITE_DOMAIN must be set");
|
||||
}
|
||||
const { reused } = ensureE2eBuild(siteDomain);
|
||||
// biome-ignore lint/suspicious/noConsole: CLI script output
|
||||
console.log(reused ? "Reusing existing e2e build" : "Built the application");
|
||||
}
|
||||
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();
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { seed } from "~/db/seed";
|
||||
import { installSeedClock } from "~/db/seed/core/frozen-clock";
|
||||
|
||||
const startedAt = Date.now();
|
||||
// installed before the seed modules load so even module-scope dates are frozen
|
||||
installSeedClock();
|
||||
|
||||
const startedAt = performance.now();
|
||||
|
||||
const { seed } = await import("~/db/seed");
|
||||
await seed();
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole: CLI script output
|
||||
console.log(`Seeded in ${((Date.now() - startedAt) / 1000).toFixed(1)}s`);
|
||||
console.log(
|
||||
`Seeded in ${((performance.now() - startedAt) / 1000).toFixed(1)}s`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user