Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions e2e/tests/auth-resilience.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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();
}
});
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
},
Expand Down
57 changes: 57 additions & 0 deletions scripts/migrate-on-deploy.mjs
Original file line number Diff line number Diff line change
@@ -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;
});
70 changes: 70 additions & 0 deletions scripts/migrate-on-deploy.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
}
},
);
15 changes: 5 additions & 10 deletions src/contexts/BracketContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<p role="status" className="p-6 text-gray-400">
Loading bracket…
</p>
);
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 (
<BracketProviderState
key={userId ?? "guest"}
{...props}
persist={isLoaded && props.persist !== false}
persist={props.persist !== false}
ownerId={userId ?? undefined}
/>
);
Expand Down
3 changes: 3 additions & 0 deletions vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"buildCommand": "npm run build"
}
Loading