diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 037161b..f4b5ea0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: with: bun-version: latest - run: bun install --frozen-lockfile - - run: bun run db:migrate + # Deployment-runner tests migrate the fresh service before storage tests. - run: bun run test:unit lint: diff --git a/README.md b/README.md index b35e298..ed52ca3 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,18 @@ Account brackets use Postgres, Drizzle migrations, and Clerk user IDs. Set `DATA to a pooled connection for the app and `DATABASE_URL_UNPOOLED` to a direct connection for migrations. Keep both in your environment manager; never commit connection strings. +Vercel Production and Preview builds automatically apply committed migrations before building the app. +Configure both variables in Vercel's **Production** environment using the Neon `main` branch. +For **Preview**, let the Neon integration supply both URLs for that preview's branch. +The migration and runtime URLs must target the same database branch. +Missing migration credentials or a failed migration stops deployment. A direct-connection +advisory lock serializes concurrent builds; Drizzle records applied migrations for safe retries. +Local builds skip this step; run `bun run db:migrate` to update your local development database. + +Migrations run before traffic switches, so schema changes must remain compatible with the +currently deployed app. Use additive changes first; remove old columns in a later release. +Rolling back an app deployment does not roll back the database schema. + ```bash bun run db:migrate bun run test:unit diff --git a/e2e/tests/auth-resilience.spec.ts b/e2e/tests/auth-resilience.spec.ts new file mode 100644 index 0000000..5b5de43 --- /dev/null +++ b/e2e/tests/auth-resilience.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from "../fixtures/test-fixtures"; + +test("guest brackets load and save when the Clerk script is blocked", async ({ + page, + seedUser: _seedUser, + mockEspnApi: _mock, +}) => { + let blocked = 0; + await page.route(/\/clerk\.browser\.js(?:\?|$)/, (route) => { + blocked++; + return route.abort(); + }); + await page.goto("/"); + await expect(page.getByTestId("bracket")).toBeVisible(); + await expect + .poll(() => + page.evaluate( + () => JSON.parse(localStorage.getItem("nfl-bracket:current") ?? "null")?.userName, + ), + ) + .toBe("Test User"); + await expect(page.getByText("Loading bracket…", { exact: true })).toHaveCount(0); + await expect.poll(() => blocked).toBeGreaterThan(0); +}); + +test("late guest authentication does not reset welcome input", async ({ + page, + clearLocalStorage: _clear, + mockEspnApi: _mock, +}) => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + await page.route(/\/clerk\.browser\.js(?:\?|$)/, async (route) => { + await gate; + await route.continue(); + }); + try { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.locator("#name").fill("Keep my name"); + release(); + await page.waitForFunction( + () => (window as unknown as { Clerk?: { loaded: boolean } }).Clerk?.loaded, + ); + await expect(page.locator("#name")).toHaveValue("Keep my name"); + await page.getByRole("button", { name: /start building/i }).click(); + await expect(page.getByTestId("bracket")).toBeVisible(); + await expect + .poll(() => + page.evaluate( + () => JSON.parse(localStorage.getItem("nfl-bracket:current") ?? "null")?.userName, + ), + ) + .toBe("Keep my name"); + } finally { + release(); + } +}); diff --git a/package.json b/package.json index 47a6b29..d768519 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", + "build": "node scripts/migrate-on-deploy.mjs && next build", "start": "next start", "lint": "oxlint", "lint:fix": "oxlint --fix", @@ -14,7 +14,7 @@ "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:debug": "playwright test --debug", - "test:unit": "bun --conditions=react-server test src/lib", + "test:unit": "node --test scripts/*.test.mjs && bun --conditions=react-server test src/lib", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate" }, diff --git a/scripts/migrate-on-deploy.mjs b/scripts/migrate-on-deploy.mjs new file mode 100644 index 0000000..a1dbf60 --- /dev/null +++ b/scripts/migrate-on-deploy.mjs @@ -0,0 +1,57 @@ +import { fileURLToPath } from "node:url"; +import pg from "pg"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; + +class MigrationConfigurationError extends Error {} + +async function migrateDeployment() { + const environment = process.env.VERCEL_ENV; + if (!["production", "preview"].includes(environment)) { + console.log("Skipping deployment migrations outside Vercel production and preview builds."); + return; + } + const connectionString = process.env.DATABASE_URL_UNPOOLED; + if (!connectionString || !process.env.DATABASE_URL) { + throw new MigrationConfigurationError( + `Set DATABASE_URL and DATABASE_URL_UNPOOLED in Vercel ${environment} before deploying.`, + ); + } + const connection = new URL(connectionString); + if (connection.hostname.includes("-pooler.")) { + throw new MigrationConfigurationError( + "Deployment migrations require a direct, unpooled connection.", + ); + } + const runtime = new URL(process.env.DATABASE_URL); + const databaseTarget = (url) => + `${url.hostname.replace(/-pooler(?=\.)/, "")}:${url.port || "5432"}${url.pathname}`; + if (databaseTarget(connection) !== databaseTarget(runtime)) { + throw new MigrationConfigurationError( + "DATABASE_URL and DATABASE_URL_UNPOOLED must target the same database branch.", + ); + } + const client = new pg.Client({ connectionString, connectionTimeoutMillis: 10000 }); + try { + await client.connect(); + await client.query("SET statement_timeout = '120s'"); + // Session lock and migrations must share this one direct connection. Closing + // it releases the lock even if a migration fails or the build is terminated. + await client.query("SELECT pg_advisory_lock(184731029)"); + await migrate(drizzle(client), { + migrationsFolder: fileURLToPath(new URL("../drizzle", import.meta.url)), + }); + console.log(`${environment} schema is up to date.`); + } finally { + await client.end(); + } +} + +migrateDeployment().catch((error) => { + // Driver errors can contain connection details; do not print credentials to + // public build logs. Configuration errors above contain only our own text. + const message = + error instanceof MigrationConfigurationError ? error.message : "Database migration failed."; + console.error(message); + process.exitCode = 1; +}); diff --git a/scripts/migrate-on-deploy.test.mjs b/scripts/migrate-on-deploy.test.mjs new file mode 100644 index 0000000..3f26113 --- /dev/null +++ b/scripts/migrate-on-deploy.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +function run(overrides) { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [fileURLToPath(new URL("./migrate-on-deploy.mjs", import.meta.url))], + { + env: { ...process.env, DATABASE_URL_UNPOOLED: "", DATABASE_URL: "", ...overrides }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let output = ""; + child.stdout.on("data", (data) => { + output += data; + }); + child.stderr.on("data", (data) => { + output += data; + }); + child.on("error", reject); + child.on("close", (code) => resolve({ code, output })); + }); +} + +test("local builds never require or connect to deployment storage", async () => { + for (const VERCEL_ENV of ["", "development"]) { + assert.equal((await run({ VERCEL_ENV, DATABASE_URL_UNPOOLED: "invalid" })).code, 0); + } +}); + +test("deployment builds fail closed without matching direct and runtime connections", async () => { + for (const VERCEL_ENV of ["production", "preview"]) { + assert.equal((await run({ VERCEL_ENV })).code, 1); + const result = await run({ + VERCEL_ENV, + DATABASE_URL_UNPOOLED: "postgres://user:secret@ep-example-pooler.neon.tech/neondb", + DATABASE_URL: "postgres://user:secret@ep-example-pooler.neon.tech/neondb", + }); + assert.equal(result.code, 1); + assert.ok(!result.output.includes("secret")); + const mismatch = await run({ + VERCEL_ENV, + DATABASE_URL: "postgres://user:secret@ep-preview-pooler.neon.tech/neondb", + DATABASE_URL_UNPOOLED: "postgres://user:secret@ep-main.neon.tech/neondb", + }); + assert.equal(mismatch.code, 1); + assert.match(mismatch.output, /same database branch/); + } +}); + +test( + "preview and production migration retries are idempotent", + { skip: !process.env.TEST_DATABASE_URL }, + async () => { + const env = { + DATABASE_URL: process.env.TEST_DATABASE_URL, + DATABASE_URL_UNPOOLED: process.env.TEST_DATABASE_URL, + }; + for (const result of await Promise.all([ + run({ ...env, VERCEL_ENV: "preview" }), + run({ ...env, VERCEL_ENV: "production" }), + ])) { + assert.equal(result.code, 0, result.output); + assert.match(result.output, /schema is up to date/); + } + }, +); diff --git a/src/contexts/BracketContext.tsx b/src/contexts/BracketContext.tsx index 5766370..afcbe71 100644 --- a/src/contexts/BracketContext.tsx +++ b/src/contexts/BracketContext.tsx @@ -65,20 +65,15 @@ function getMatchupRound(matchupId: string): RoundName | null { type ProviderProps = { children: ReactNode; initialBracket?: BracketState; persist?: boolean }; export function BracketProvider(props: ProviderProps) { - const { userId, isLoaded } = useAuth(); - // Mount editable state only after account identity is known. Remounting a - // temporary guest tree when Clerk loads can otherwise discard typed input. - if (!isLoaded) - return ( -

- Loading bracket… -

- ); + const { userId } = useAuth(); + // Public brackets must work even when Clerk cannot load. Keep the guest key + // stable as auth initializes, preserving typed input and browser saves. Only + // an actual account change mounts a separate, account-scoped state tree. return ( ); diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..cb6e297 --- /dev/null +++ b/vercel.json @@ -0,0 +1,3 @@ +{ + "buildCommand": "npm run build" +}