Database
Updated to just use DATABASE_* in everything other than local docker.
This commit is contained in:
19 files changed
+120
-56
No files matched your search
+5
-1
@@ -1,6 +1,10 @@
|
||||
# Database (MySQL 8.0 — migrated from PostgreSQL in Phase 7, see
|
||||
# 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
|
||||
# for a hosting provider with a small max-connections limit.
|
||||
DATABASE_CONNECTION_LIMIT="5"
|
||||
|
||||
@@ -28,7 +28,11 @@ concurrency:
|
||||
env:
|
||||
# A throwaway, non-secret DB the mysql service creates for this run only
|
||||
# — 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
|
||||
# to be consistent for the lifetime of this one CI run.
|
||||
AUTH_SECRET: ci-run-secret-do-not-use-in-production
|
||||
|
||||
@@ -51,3 +51,7 @@ yarn-error.log*
|
||||
.env.local.example
|
||||
.env.example
|
||||
.env.example
|
||||
|
||||
.agents/
|
||||
.claude/
|
||||
.windsurf/
|
||||
+14
-9
@@ -26,18 +26,23 @@ FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# DATABASE_URL isn't needed to generate the client (no DB connection at
|
||||
# generate time) — only real values needed at runtime, never baked in here.
|
||||
# These database env vars aren't needed to generate the client (no DB
|
||||
# connection at generate time) — only real values needed at runtime, never
|
||||
# baked in here.
|
||||
RUN npx prisma generate
|
||||
# `next build`'s page-data collection step imports every route module
|
||||
# (including /api/health) to inspect it — that pulls in
|
||||
# src/lib/db/prisma.ts, which requires DATABASE_URL just to construct its
|
||||
# connection-pool config object, even though nothing actually connects at
|
||||
# build time. This placeholder only exists to satisfy that check; the
|
||||
# real value is supplied at container runtime via env_file (see
|
||||
# docker-compose.local.yaml) and this one is never used to connect to
|
||||
# anything or baked into a layer that matters.
|
||||
ENV DATABASE_URL="mysql://build:build@localhost:3306/build"
|
||||
# src/lib/db/prisma.ts, which requires DATABASE_HOST/NAME/USER/PASSWORD
|
||||
# just to construct its connection-pool config object, even though nothing
|
||||
# actually connects at build time. These placeholders only exist to
|
||||
# satisfy that check; the real values are supplied at container runtime
|
||||
# via env_file (see docker-compose.local.yaml) and these are never used to
|
||||
# connect to anything or baked into a layer that matters.
|
||||
ENV DATABASE_HOST="localhost"
|
||||
ENV DATABASE_PORT="3306"
|
||||
ENV DATABASE_NAME="build"
|
||||
ENV DATABASE_USER="build"
|
||||
ENV DATABASE_PASSWORD="build"
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
+8
-7
@@ -2,16 +2,17 @@ import { execFileSync } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Applies migrations and loads the dev/demo seed data against whatever
|
||||
* DATABASE_URL points at before the suite runs. Deliberately does not
|
||||
* create or tear down the database itself — same division of
|
||||
* responsibility as `npm run test:integration`, where the caller is
|
||||
* responsible for pointing DATABASE_URL at a disposable Postgres.
|
||||
* DATABASE_HOST/PORT/NAME/USER/PASSWORD point at before the suite runs.
|
||||
* Deliberately does not create or tear down the database itself — same
|
||||
* division of responsibility as `npm run test:integration`, where the
|
||||
* caller is responsible for pointing these at a disposable database.
|
||||
*/
|
||||
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(
|
||||
"DATABASE_URL is not set. Point it at a disposable Postgres before running the e2e suite, e.g.\n" +
|
||||
' DATABASE_URL="postgresql://user:pass@localhost:5432/nextchair_e2e" npm run test:e2e',
|
||||
"Database connection is not configured. Point DATABASE_HOST, DATABASE_NAME, DATABASE_USER, and " +
|
||||
"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',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { SEEDED, completeBookingFlow, signIn, uniqueEmail } from "./helpers";
|
||||
|
||||
test.describe.serial("owner administration journeys", () => {
|
||||
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
|
||||
// upsert against on a re-run — clean up by name instead.
|
||||
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
|
||||
// then, not necessarily still "N days from today" — read the real date
|
||||
// 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 }[]>(
|
||||
"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" +
|
||||
|
||||
@@ -31,7 +31,7 @@ test.describe.serial("barber operations journeys", () => {
|
||||
// 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.
|
||||
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 `Break` WHERE label = 'Afternoon tea'");
|
||||
await pool.end();
|
||||
|
||||
@@ -2,12 +2,12 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* End-to-end customer journeys — real browser, real dev server, real
|
||||
* Postgres. Needs DATABASE_URL pointed at a throwaway database before
|
||||
* running (same "migrate, seed, test, tear down" pattern as
|
||||
* `npm run test:integration` — see docs/phase-3-summary.md and
|
||||
* docs/phase-4-summary.md). `globalSetup` applies migrations and seed
|
||||
* data automatically so a single `npm run test:e2e` is enough once
|
||||
* DATABASE_URL is set.
|
||||
* MySQL. Needs DATABASE_HOST/DATABASE_NAME/DATABASE_USER/DATABASE_PASSWORD
|
||||
* pointed at a throwaway database before running (same "migrate, seed,
|
||||
* test, tear down" pattern as `npm run test:integration` — see
|
||||
* docs/phase-3-summary.md and docs/phase-4-summary.md). `globalSetup`
|
||||
* applies migrations and seed data automatically so a single
|
||||
* `npm run test:e2e` is enough once those are set.
|
||||
*/
|
||||
const PORT = 3100;
|
||||
const baseURL = `http://localhost:${PORT}`;
|
||||
@@ -34,7 +34,6 @@ export default defineConfig({
|
||||
timeout: 60_000,
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: process.env.DATABASE_URL ?? "",
|
||||
// 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.
|
||||
AUTH_SECRET: process.env.AUTH_SECRET ?? "playwright-e2e-test-secret-do-not-use-in-production",
|
||||
|
||||
+7
-1
@@ -1,6 +1,12 @@
|
||||
import "dotenv/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({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
@@ -8,6 +14,6 @@ export default defineConfig({
|
||||
seed: "tsx prisma/seed.ts",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
url: buildMysqlConnectionUrl(),
|
||||
},
|
||||
});
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import "dotenv/config";
|
||||
@@ -28,7 +28,7 @@ function minutesFromNow(minutes: number): Date {
|
||||
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 WEEKDAYS: Weekday[] = [
|
||||
|
||||
+10
-2
@@ -19,11 +19,19 @@
|
||||
// non-GET request (Server Actions, API mutations) is never intercepted at
|
||||
// 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 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) => {
|
||||
event.waitUntil(
|
||||
|
||||
@@ -45,7 +45,7 @@ async function main() {
|
||||
const lastName = process.env.SUPER_ADMIN_LAST_NAME?.trim() ?? "";
|
||||
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 });
|
||||
|
||||
try {
|
||||
|
||||
@@ -102,7 +102,7 @@ export const getBranding = cache(async (): Promise<BrandingInfo> => {
|
||||
// Reached from the root layout's generateMetadata, which Next may
|
||||
// invoke while statically prerendering a route at *build* time (e.g.
|
||||
// /_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":
|
||||
// neutral fallback wording, never a crashed build or a 500 at runtime.
|
||||
let business;
|
||||
|
||||
@@ -1,31 +1,64 @@
|
||||
/**
|
||||
* Builds the `mariadb` pool config from `DATABASE_URL`, shared by every
|
||||
* Prisma Client instance in the app (the main app client, the seed
|
||||
* script, and the integration-test fixtures) so the UTC-timezone and
|
||||
* connection-limit handling can't drift between them. See
|
||||
* Builds the `mariadb` pool config from the discrete `DATABASE_HOST` /
|
||||
* `DATABASE_PORT` / `DATABASE_NAME` / `DATABASE_USER` / `DATABASE_PASSWORD`
|
||||
* env vars, shared by every Prisma Client instance in the app (the main
|
||||
* 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
|
||||
* file exists only because that reasoning needs to apply to all three
|
||||
* call sites, not because the logic itself is complex.
|
||||
*/
|
||||
export function buildMysqlPoolConfig(databaseUrl: string | undefined) {
|
||||
if (!databaseUrl) throw new Error("DATABASE_URL is not set.");
|
||||
export function buildMysqlPoolConfig() {
|
||||
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
|
||||
// string parser only recognizes the latter, but the `URL` global parses
|
||||
// either scheme fine since we build the pool config object ourselves.
|
||||
const url = new URL(databaseUrl);
|
||||
if (!host || !database || !user || password === undefined) {
|
||||
throw new Error(
|
||||
"Database connection is not configured. Set DATABASE_HOST, DATABASE_NAME, DATABASE_USER, and DATABASE_PASSWORD (DATABASE_PORT is optional, defaults to 3306).",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: url.port ? Number(url.port) : 3306,
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\//, ""),
|
||||
host,
|
||||
port: process.env.DATABASE_PORT ? Number(process.env.DATABASE_PORT) : 3306,
|
||||
user,
|
||||
password,
|
||||
database,
|
||||
connectionLimit: Number(process.env.DATABASE_CONNECTION_LIMIT ?? 5),
|
||||
connectTimeout: Number(process.env.DATABASE_CONNECT_TIMEOUT_MS ?? 10_000),
|
||||
// Load-bearing, not cosmetic — see src/lib/db/prisma.ts's file header.
|
||||
timezone: "Etc/UTC",
|
||||
sessionVariables: { time_zone: "+00:00" },
|
||||
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}`;
|
||||
}
|
||||
@@ -28,7 +28,7 @@ const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL));
|
||||
const adapter = new PrismaMariaDb(buildMysqlPoolConfig());
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
|
||||
@@ -7,7 +7,7 @@ import path from "node:path";
|
||||
* Real filesystem I/O against a throwaway temp directory (via
|
||||
* 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
|
||||
* 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
|
||||
* dynamically imported after MEDIA_STORAGE_ROOT is set, since it reads
|
||||
* that env var once at module-load time.
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { BusinessReviewSummary, ReviewsProvider } from "../reviews.service"
|
||||
describe("reviews.service", () => {
|
||||
// The module has no eager default provider (see reviews.service.ts's
|
||||
// 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.
|
||||
beforeEach(() => {
|
||||
setReviewsProvider(createDemoReviewsProvider());
|
||||
|
||||
@@ -102,8 +102,8 @@ export class DbReviewsProvider implements ReviewsProvider {
|
||||
* `null` = no explicit override; resolved lazily to a real DbReviewsProvider
|
||||
* on first use. Deliberately NOT constructed eagerly at module load — this
|
||||
* 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
|
||||
* throws at import time if that's unset.
|
||||
* database env vars set (see reviews.service.test.ts), and src/lib/db/prisma.ts
|
||||
* throws at import time if they're unset.
|
||||
*/
|
||||
let provider: 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).
|
||||
*/
|
||||
|
||||
const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL));
|
||||
const adapter = new PrismaMariaDb(buildMysqlPoolConfig());
|
||||
export const prisma = new PrismaClient({ adapter });
|
||||
|
||||
let counter = 0;
|
||||
|
||||
Reference in new issue
Block a user