diff --git a/next.config.ts b/next.config.ts index 4b3ca4cc0d..795d91c436 100644 --- a/next.config.ts +++ b/next.config.ts @@ -44,6 +44,8 @@ const nextConfig: NextConfig = { static: 180, }, optimizePackageImports: ["lucide-react"], + // Keep build-time DB fan-out low (shared MySQL with emulator). + staticGenerationMaxConcurrency: 3, }, // Add caching headers for static assets diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 457cfba885..206c1f3708 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,6 +1,10 @@ import type { MetadataRoute } from "next"; import { prisma } from "@/lib/prisma"; +// Built at request time — avoids competing with SSG workers for scarce DB +// connections during `next build` (pool timeouts killed deploy on sitemap). +export const dynamic = "force-dynamic"; + export default async function sitemap(): Promise { const appUrl = process.env.APP_URL ?? "http://localhost:3000"; diff --git a/src/lib/prisma-pool.test.ts b/src/lib/prisma-pool.test.ts new file mode 100644 index 0000000000..aa96762d42 --- /dev/null +++ b/src/lib/prisma-pool.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { resolvePrismaConnectionLimit } from "./prisma-pool"; + +describe("resolvePrismaConnectionLimit", () => { + it("caps the pool during production build phases", () => { + expect(resolvePrismaConnectionLimit(40, "phase-production-build")).toBe(5); + expect( + resolvePrismaConnectionLimit(40, "phase-production-compile"), + ).toBe(5); + expect(resolvePrismaConnectionLimit(3, "phase-production-build")).toBe(3); + }); + + it("keeps the configured pool outside build", () => { + expect(resolvePrismaConnectionLimit(40, undefined)).toBe(40); + expect(resolvePrismaConnectionLimit(40, "phase-development-server")).toBe( + 40, + ); + }); +}); diff --git a/src/lib/prisma-pool.ts b/src/lib/prisma-pool.ts new file mode 100644 index 0000000000..6aa532d8c6 --- /dev/null +++ b/src/lib/prisma-pool.ts @@ -0,0 +1,16 @@ +/** + * Cap pool size during `next build` so N workers × pool don't exhaust MySQL. + * Kept free of Prisma/env imports so unit tests can exercise it in isolation. + */ +export function resolvePrismaConnectionLimit( + poolSize: number, + phase = process.env.NEXT_PHASE, +): number { + if ( + phase === "phase-production-build" || + phase === "phase-production-compile" + ) { + return Math.min(poolSize, 5); + } + return poolSize; +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index ad3e6d92f4..dcf0322b7c 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -1,20 +1,29 @@ import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import { env } from "@/env"; import { PrismaClient } from "@/generated/prisma/client"; +import { resolvePrismaConnectionLimit } from "@/lib/prisma-pool"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; function createPrismaClient(): PrismaClient { const url = new URL(env.DATABASE_URL); + const isBuild = + process.env.NEXT_PHASE === "phase-production-build" || + process.env.NEXT_PHASE === "phase-production-compile"; + const connectionLimit = resolvePrismaConnectionLimit(env.DATABASE_POOL_SIZE); + // Fail fast during SSG so Next can retry/fallback instead of hanging 30s+. + const connectTimeout = isBuild + ? Math.min(env.DATABASE_CONNECT_TIMEOUT_MS, 5_000) + : env.DATABASE_CONNECT_TIMEOUT_MS; const adapter = new PrismaMariaDb({ host: url.hostname, port: Number(url.port) || 3306, user: decodeURIComponent(url.username), password: decodeURIComponent(url.password), database: url.pathname.replace(/^\//, ""), - connectionLimit: env.DATABASE_POOL_SIZE, - connectTimeout: env.DATABASE_CONNECT_TIMEOUT_MS, - acquireTimeout: env.DATABASE_CONNECT_TIMEOUT_MS, + connectionLimit, + connectTimeout, + acquireTimeout: connectTimeout, idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS, }); return new PrismaClient({