Updated to just use DATABASE_* in everything other than local docker.
This commit is contained in:
Stefan Lazic committed 2026-08-07 17:30:12 +01:00
1 parent 769d3f9ebc
commit 51de4f4ef5
19 files changed
+120 -56

No files matched your search

+5 -1
View File
@@ -1,6 +1,10 @@
# Database (MySQL 8.0 — migrated from PostgreSQL in Phase 7, see # Database (MySQL 8.0 — migrated from PostgreSQL in Phase 7, see
# docs/phase-7-summary.md; the production host only supports MySQL). # docs/phase-7-summary.md; the production host only supports MySQL).
DATABASE_URL="mysql://user:password@localhost:3306/nextchair" DATABASE_HOST="localhost"
DATABASE_PORT="3306"
DATABASE_NAME="nextchair"
DATABASE_USER="user"
DATABASE_PASSWORD="password"
# Optional: cap the connection pool (see src/lib/db/prisma.ts). Lower this # Optional: cap the connection pool (see src/lib/db/prisma.ts). Lower this
# for a hosting provider with a small max-connections limit. # for a hosting provider with a small max-connections limit.
DATABASE_CONNECTION_LIMIT="5" DATABASE_CONNECTION_LIMIT="5"
+5 -1
View File
@@ -28,7 +28,11 @@ concurrency:
env: env:
# A throwaway, non-secret DB the mysql service creates for this run only # A throwaway, non-secret DB the mysql service creates for this run only
# — never a real deployment's database. # — never a real deployment's database.
DATABASE_URL: mysql://root:root@127.0.0.1:3306/nextchair DATABASE_HOST: 127.0.0.1
DATABASE_PORT: "3306"
DATABASE_NAME: nextchair
DATABASE_USER: root
DATABASE_PASSWORD: root
# Any fixed value works (see src/lib/booking-draft/sign.ts) — only needs # Any fixed value works (see src/lib/booking-draft/sign.ts) — only needs
# to be consistent for the lifetime of this one CI run. # to be consistent for the lifetime of this one CI run.
AUTH_SECRET: ci-run-secret-do-not-use-in-production AUTH_SECRET: ci-run-secret-do-not-use-in-production
+4
View File
@@ -51,3 +51,7 @@ yarn-error.log*
.env.local.example .env.local.example
.env.example .env.example
.env.example .env.example
.agents/
.claude/
.windsurf/
+14 -9
View File
@@ -26,18 +26,23 @@ FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
# DATABASE_URL isn't needed to generate the client (no DB connection at # These database env vars aren't needed to generate the client (no DB
# generate time) — only real values needed at runtime, never baked in here. # connection at generate time) — only real values needed at runtime, never
# baked in here.
RUN npx prisma generate RUN npx prisma generate
# `next build`'s page-data collection step imports every route module # `next build`'s page-data collection step imports every route module
# (including /api/health) to inspect it — that pulls in # (including /api/health) to inspect it — that pulls in
# src/lib/db/prisma.ts, which requires DATABASE_URL just to construct its # src/lib/db/prisma.ts, which requires DATABASE_HOST/NAME/USER/PASSWORD
# connection-pool config object, even though nothing actually connects at # just to construct its connection-pool config object, even though nothing
# build time. This placeholder only exists to satisfy that check; the # actually connects at build time. These placeholders only exist to
# real value is supplied at container runtime via env_file (see # satisfy that check; the real values are supplied at container runtime
# docker-compose.local.yaml) and this one is never used to connect to # via env_file (see docker-compose.local.yaml) and these are never used to
# anything or baked into a layer that matters. # connect to anything or baked into a layer that matters.
ENV DATABASE_URL="mysql://build:build@localhost:3306/build" ENV DATABASE_HOST="localhost"
ENV DATABASE_PORT="3306"
ENV DATABASE_NAME="build"
ENV DATABASE_USER="build"
ENV DATABASE_PASSWORD="build"
RUN npm run build RUN npm run build
FROM node:22-alpine AS runner FROM node:22-alpine AS runner
+8 -7
View File
@@ -2,16 +2,17 @@ import { execFileSync } from "node:child_process";
/** /**
* Applies migrations and loads the dev/demo seed data against whatever * Applies migrations and loads the dev/demo seed data against whatever
* DATABASE_URL points at before the suite runs. Deliberately does not * DATABASE_HOST/PORT/NAME/USER/PASSWORD point at before the suite runs.
* create or tear down the database itself — same division of * Deliberately does not create or tear down the database itself — same
* responsibility as `npm run test:integration`, where the caller is * division of responsibility as `npm run test:integration`, where the
* responsible for pointing DATABASE_URL at a disposable Postgres. * caller is responsible for pointing these at a disposable database.
*/ */
export default function globalSetup() { export default function globalSetup() {
if (!process.env.DATABASE_URL) { if (!process.env.DATABASE_HOST || !process.env.DATABASE_NAME || !process.env.DATABASE_USER) {
throw new Error( throw new Error(
"DATABASE_URL is not set. Point it at a disposable Postgres before running the e2e suite, e.g.\n" + "Database connection is not configured. Point DATABASE_HOST, DATABASE_NAME, DATABASE_USER, and " +
' DATABASE_URL="postgresql://user:pass@localhost:5432/nextchair_e2e" npm run test:e2e', "DATABASE_PASSWORD at a disposable MySQL database before running the e2e suite, e.g.\n" +
' DATABASE_HOST=localhost DATABASE_NAME=nextchair_e2e DATABASE_USER=user DATABASE_PASSWORD=pass npm run test:e2e',
); );
} }
+2 -2
View File
@@ -20,7 +20,7 @@ import { SEEDED, completeBookingFlow, signIn, uniqueEmail } from "./helpers";
test.describe.serial("owner administration journeys", () => { test.describe.serial("owner administration journeys", () => {
test.afterAll(async () => { test.afterAll(async () => {
const pool = createPool(buildMysqlPoolConfig(process.env.DATABASE_URL)); const pool = createPool(buildMysqlPoolConfig());
// Journey 7 creates a category/service with no deterministic id to // Journey 7 creates a category/service with no deterministic id to
// upsert against on a re-run — clean up by name instead. // upsert against on a re-run — clean up by name instead.
await pool.query("DELETE FROM `Service` WHERE name LIKE 'E2E Test Service%'"); await pool.query("DELETE FROM `Service` WHERE name LIKE 'E2E Test Service%'");
@@ -108,7 +108,7 @@ test.describe.serial("owner administration journeys", () => {
// them from an earlier seed run their date is whatever it was back // them from an earlier seed run their date is whatever it was back
// then, not necessarily still "N days from today" — read the real date // then, not necessarily still "N days from today" — read the real date
// straight from the database instead of predicting it from wall-clock time. // straight from the database instead of predicting it from wall-clock time.
const pool = createPool(buildMysqlPoolConfig(process.env.DATABASE_URL)); const pool = createPool(buildMysqlPoolConfig());
const rows = await pool.query<{ id: string; start_at: Date; first_name: string; last_name: string }[]>( const rows = await pool.query<{ id: string; start_at: Date; first_name: string; last_name: string }[]>(
"SELECT a.id, a.startAt as start_at, c.firstName as first_name, c.lastName as last_name" + "SELECT a.id, a.startAt as start_at, c.firstName as first_name, c.lastName as last_name" +
" FROM Appointment a JOIN CustomerProfile c ON c.id = a.customerId" + " FROM Appointment a JOIN CustomerProfile c ON c.id = a.customerId" +
+1 -1
View File
@@ -31,7 +31,7 @@ test.describe.serial("barber operations journeys", () => {
// safety net removes them directly regardless of how the tests above // safety net removes them directly regardless of how the tests above
// exited, on top of (not instead of) each test's own UI-driven cleanup. // exited, on top of (not instead of) each test's own UI-driven cleanup.
test.afterAll(async () => { test.afterAll(async () => {
const pool = createPool(buildMysqlPoolConfig(process.env.DATABASE_URL)); const pool = createPool(buildMysqlPoolConfig());
await pool.query("DELETE FROM `TimeOff` WHERE reason = 'E2E conflict test'"); await pool.query("DELETE FROM `TimeOff` WHERE reason = 'E2E conflict test'");
await pool.query("DELETE FROM `Break` WHERE label = 'Afternoon tea'"); await pool.query("DELETE FROM `Break` WHERE label = 'Afternoon tea'");
await pool.end(); await pool.end();
+6 -7
View File
@@ -2,12 +2,12 @@ import { defineConfig, devices } from "@playwright/test";
/** /**
* End-to-end customer journeys — real browser, real dev server, real * End-to-end customer journeys — real browser, real dev server, real
* Postgres. Needs DATABASE_URL pointed at a throwaway database before * MySQL. Needs DATABASE_HOST/DATABASE_NAME/DATABASE_USER/DATABASE_PASSWORD
* running (same "migrate, seed, test, tear down" pattern as * pointed at a throwaway database before running (same "migrate, seed,
* `npm run test:integration` — see docs/phase-3-summary.md and * test, tear down" pattern as `npm run test:integration` — see
* docs/phase-4-summary.md). `globalSetup` applies migrations and seed * docs/phase-3-summary.md and docs/phase-4-summary.md). `globalSetup`
* data automatically so a single `npm run test:e2e` is enough once * applies migrations and seed data automatically so a single
* DATABASE_URL is set. * `npm run test:e2e` is enough once those are set.
*/ */
const PORT = 3100; const PORT = 3100;
const baseURL = `http://localhost:${PORT}`; const baseURL = `http://localhost:${PORT}`;
@@ -34,7 +34,6 @@ export default defineConfig({
timeout: 60_000, timeout: 60_000,
env: { env: {
...process.env, ...process.env,
DATABASE_URL: process.env.DATABASE_URL ?? "",
// Any fixed value is fine — see src/lib/booking-draft/sign.ts. Only // Any fixed value is fine — see src/lib/booking-draft/sign.ts. Only
// needs to be consistent for the lifetime of this one server process. // needs to be consistent for the lifetime of this one server process.
AUTH_SECRET: process.env.AUTH_SECRET ?? "playwright-e2e-test-secret-do-not-use-in-production", AUTH_SECRET: process.env.AUTH_SECRET ?? "playwright-e2e-test-secret-do-not-use-in-production",
+7 -1
View File
@@ -1,6 +1,12 @@
import "dotenv/config"; import "dotenv/config";
import { defineConfig } from "prisma/config"; import { defineConfig } from "prisma/config";
import { buildMysqlConnectionUrl } from "./src/lib/db/mysql-pool-config";
// The Prisma CLI (migrate/introspect) needs a connection string, not
// discrete fields — this app's own runtime code never uses this URL (see
// src/lib/db/mysql-pool-config.ts's buildMysqlPoolConfig, which reads
// DATABASE_HOST/PORT/NAME/USER/PASSWORD directly), so it's built here only
// to satisfy the CLI.
export default defineConfig({ export default defineConfig({
schema: "prisma/schema.prisma", schema: "prisma/schema.prisma",
migrations: { migrations: {
@@ -8,6 +14,6 @@ export default defineConfig({
seed: "tsx prisma/seed.ts", seed: "tsx prisma/seed.ts",
}, },
datasource: { datasource: {
url: process.env["DATABASE_URL"], url: buildMysqlConnectionUrl(),
}, },
}); });
+2 -2
View File
@@ -1,6 +1,6 @@
/** /**
* Dev/demo fixtures. Run with `npm run prisma:seed` (requires a reachable * Dev/demo fixtures. Run with `npm run prisma:seed` (requires a reachable
* DATABASE_URL — see .env.example). Intentionally small: enough to exercise * DATABASE_HOST/NAME/USER/PASSWORD — see .env.example). Intentionally small: enough to exercise
* every relation in the schema once, not a full demo dataset. * every relation in the schema once, not a full demo dataset.
*/ */
import "dotenv/config"; import "dotenv/config";
@@ -28,7 +28,7 @@ function minutesFromNow(minutes: number): Date {
return new Date(Date.now() + minutes * 60_000); return new Date(Date.now() + minutes * 60_000);
} }
const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL)); const adapter = new PrismaMariaDb(buildMysqlPoolConfig());
const prisma = new PrismaClient({ adapter }); const prisma = new PrismaClient({ adapter });
const WEEKDAYS: Weekday[] = [ const WEEKDAYS: Weekday[] = [
+10 -2
View File
@@ -19,11 +19,19 @@
// non-GET request (Server Actions, API mutations) is never intercepted at // non-GET request (Server Actions, API mutations) is never intercepted at
// all — this worker must never sit between the app and a write. // all — this worker must never sit between the app and a write.
const CACHE_VERSION = "v1"; // v2: /manifest.webmanifest was previously cache-first here alongside the
// truly static icon fallbacks — but it's dynamic, per-business content
// (name/icons/colors, editable from "App Icon & Home Screen" settings),
// not a static build asset. Cache-first meant a phone that had ever
// loaded it once would keep serving that stale response indefinitely,
// regardless of any later branding change or server-side fix — bumping
// CACHE_VERSION forces every existing install to drop its old cache
// (including any previously-cached manifest response) on next activate.
const CACHE_VERSION = "v2";
const CACHE_NAME = `nextchair-shell-${CACHE_VERSION}`; const CACHE_NAME = `nextchair-shell-${CACHE_VERSION}`;
const OFFLINE_URL = "/offline"; const OFFLINE_URL = "/offline";
const APP_SHELL = [OFFLINE_URL, "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"]; const APP_SHELL = [OFFLINE_URL, "/icons/icon-192.png", "/icons/icon-512.png"];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
+1 -1
View File
@@ -45,7 +45,7 @@ async function main() {
const lastName = process.env.SUPER_ADMIN_LAST_NAME?.trim() ?? ""; const lastName = process.env.SUPER_ADMIN_LAST_NAME?.trim() ?? "";
const name = [firstName, lastName].filter(Boolean).join(" ") || null; const name = [firstName, lastName].filter(Boolean).join(" ") || null;
const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL)); const adapter = new PrismaMariaDb(buildMysqlPoolConfig());
const prisma = new PrismaClient({ adapter }); const prisma = new PrismaClient({ adapter });
try { try {
+1 -1
View File
@@ -102,7 +102,7 @@ export const getBranding = cache(async (): Promise<BrandingInfo> => {
// Reached from the root layout's generateMetadata, which Next may // Reached from the root layout's generateMetadata, which Next may
// invoke while statically prerendering a route at *build* time (e.g. // invoke while statically prerendering a route at *build* time (e.g.
// /_not-found) — no real database is reachable then (see the // /_not-found) — no real database is reachable then (see the
// Dockerfile's placeholder build-time DATABASE_URL). A transient/ // Dockerfile's placeholder build-time database env vars). A transient/
// unreachable database is treated exactly like "not installed yet": // unreachable database is treated exactly like "not installed yet":
// neutral fallback wording, never a crashed build or a 500 at runtime. // neutral fallback wording, never a crashed build or a 500 at runtime.
let business; let business;
+48 -15
View File
@@ -1,31 +1,64 @@
/** /**
* Builds the `mariadb` pool config from `DATABASE_URL`, shared by every * Builds the `mariadb` pool config from the discrete `DATABASE_HOST` /
* Prisma Client instance in the app (the main app client, the seed * `DATABASE_PORT` / `DATABASE_NAME` / `DATABASE_USER` / `DATABASE_PASSWORD`
* script, and the integration-test fixtures) so the UTC-timezone and * env vars, shared by every Prisma Client instance in the app (the main
* connection-limit handling can't drift between them. See * app client, the seed script, and the integration-test fixtures) so the
* UTC-timezone and connection-limit handling can't drift between them. See
* src/lib/db/prisma.ts for why `timezone`/`sessionVariables` matter — this * src/lib/db/prisma.ts for why `timezone`/`sessionVariables` matter — this
* file exists only because that reasoning needs to apply to all three * file exists only because that reasoning needs to apply to all three
* call sites, not because the logic itself is complex. * call sites, not because the logic itself is complex.
*/ */
export function buildMysqlPoolConfig(databaseUrl: string | undefined) { export function buildMysqlPoolConfig() {
if (!databaseUrl) throw new Error("DATABASE_URL is not set."); const host = process.env.DATABASE_HOST;
const database = process.env.DATABASE_NAME;
const user = process.env.DATABASE_USER;
const password = process.env.DATABASE_PASSWORD;
// Accept both `mysql://` and `mariadb://` — the `mariadb` driver's own if (!host || !database || !user || password === undefined) {
// string parser only recognizes the latter, but the `URL` global parses throw new Error(
// either scheme fine since we build the pool config object ourselves. "Database connection is not configured. Set DATABASE_HOST, DATABASE_NAME, DATABASE_USER, and DATABASE_PASSWORD (DATABASE_PORT is optional, defaults to 3306).",
const url = new URL(databaseUrl); );
}
return { return {
host: url.hostname, host,
port: url.port ? Number(url.port) : 3306, port: process.env.DATABASE_PORT ? Number(process.env.DATABASE_PORT) : 3306,
user: decodeURIComponent(url.username), user,
password: decodeURIComponent(url.password), password,
database: url.pathname.replace(/^\//, ""), database,
connectionLimit: Number(process.env.DATABASE_CONNECTION_LIMIT ?? 5), connectionLimit: Number(process.env.DATABASE_CONNECTION_LIMIT ?? 5),
connectTimeout: Number(process.env.DATABASE_CONNECT_TIMEOUT_MS ?? 10_000), connectTimeout: Number(process.env.DATABASE_CONNECT_TIMEOUT_MS ?? 10_000),
// Load-bearing, not cosmetic — see src/lib/db/prisma.ts's file header. // Load-bearing, not cosmetic — see src/lib/db/prisma.ts's file header.
timezone: "Etc/UTC", timezone: "Etc/UTC",
sessionVariables: { time_zone: "+00:00" }, sessionVariables: { time_zone: "+00:00" },
ssl: process.env.DATABASE_SSL === "true" ? { rejectUnauthorized: true } : undefined, ssl: process.env.DATABASE_SSL === "true" ? { rejectUnauthorized: true } : undefined,
// MySQL 8's default auth plugin (caching_sha2_password) needs to send
// the client its RSA public key to encrypt the password, and the
// `mariadb` driver refuses to fetch it over a plain connection unless
// told to — surfacing as "Public Key Retrieval is not allowed" on
// every connection attempt (not intermittent, not env-specific: it
// fires against any caching_sha2_password server whenever DATABASE_SSL
// isn't "true"). Opting in doesn't weaken authentication itself, only
// lets the client ask for that public key; a connection that's already
// on `ssl` above doesn't need this exchange in the first place.
allowPublicKeyRetrieval: true,
}; };
} }
/**
* Builds a `mysql://` connection string from the same discrete env vars,
* for the one place that genuinely needs a URL rather than a pool config
* object: prisma.config.ts, whose `datasource.url` is a plain string
* because the Prisma CLI (migrate/introspect) doesn't accept discrete
* connection fields.
*/
export function buildMysqlConnectionUrl(): string | undefined {
const host = process.env.DATABASE_HOST;
const database = process.env.DATABASE_NAME;
const user = process.env.DATABASE_USER;
const password = process.env.DATABASE_PASSWORD;
if (!host || !database || !user || password === undefined) return undefined;
const port = process.env.DATABASE_PORT || "3306";
return `mysql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}:${port}/${database}`;
}
+1 -1
View File
@@ -28,7 +28,7 @@ const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined; prisma: PrismaClient | undefined;
}; };
const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL)); const adapter = new PrismaMariaDb(buildMysqlPoolConfig());
export const prisma = export const prisma =
globalForPrisma.prisma ?? globalForPrisma.prisma ??
@@ -7,7 +7,7 @@ import path from "node:path";
* Real filesystem I/O against a throwaway temp directory (via * Real filesystem I/O against a throwaway temp directory (via
* MEDIA_STORAGE_ROOT), not mocks — this module's whole job is getting * MEDIA_STORAGE_ROOT), not mocks — this module's whole job is getting
* real file paths right, so a mocked `fs` would test nothing useful. No * real file paths right, so a mocked `fs` would test nothing useful. No
* DATABASE_URL/Prisma dependency here (media-storage.ts is DB-free), so * database/Prisma dependency here (media-storage.ts is DB-free), so
* this stays safe to run in the plain unit suite. The module is * this stays safe to run in the plain unit suite. The module is
* dynamically imported after MEDIA_STORAGE_ROOT is set, since it reads * dynamically imported after MEDIA_STORAGE_ROOT is set, since it reads
* that env var once at module-load time. * that env var once at module-load time.
@@ -5,7 +5,7 @@ import type { BusinessReviewSummary, ReviewsProvider } from "../reviews.service"
describe("reviews.service", () => { describe("reviews.service", () => {
// The module has no eager default provider (see reviews.service.ts's // The module has no eager default provider (see reviews.service.ts's
// header — it lazily resolves a real, DB-backed provider on first use // header — it lazily resolves a real, DB-backed provider on first use
// otherwise, which would need a live DATABASE_URL). Every test here // otherwise, which would need live database env vars). Every test here
// explicitly opts into the demo provider so none of them ever touch it. // explicitly opts into the demo provider so none of them ever touch it.
beforeEach(() => { beforeEach(() => {
setReviewsProvider(createDemoReviewsProvider()); setReviewsProvider(createDemoReviewsProvider());
+2 -2
View File
@@ -102,8 +102,8 @@ export class DbReviewsProvider implements ReviewsProvider {
* `null` = no explicit override; resolved lazily to a real DbReviewsProvider * `null` = no explicit override; resolved lazily to a real DbReviewsProvider
* on first use. Deliberately NOT constructed eagerly at module load — this * on first use. Deliberately NOT constructed eagerly at module load — this
* file must stay importable from a pure unit-test context with no * file must stay importable from a pure unit-test context with no
* DATABASE_URL (see reviews.service.test.ts), and src/lib/db/prisma.ts * database env vars set (see reviews.service.test.ts), and src/lib/db/prisma.ts
* throws at import time if that's unset. * throws at import time if they're unset.
*/ */
let provider: ReviewsProvider | null = null; let provider: ReviewsProvider | null = null;
let cachedDefaultProvider: ReviewsProvider | null = null; let cachedDefaultProvider: ReviewsProvider | null = null;
@@ -11,7 +11,7 @@ import { Weekday } from "@/generated/prisma/enums";
* (migrated from PostgreSQL to MySQL in Phase 7). * (migrated from PostgreSQL to MySQL in Phase 7).
*/ */
const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL)); const adapter = new PrismaMariaDb(buildMysqlPoolConfig());
export const prisma = new PrismaClient({ adapter }); export const prisma = new PrismaClient({ adapter });
let counter = 0; let counter = 0;