Skip to content

Commit e606d03

Browse files
authored
fix(db): migrate isolated preview databases
Run migrations for each Vercel preview as well as production. Reject mismatched runtime and migration database targets so schema changes cannot silently land on a different branch.
1 parent 1809d53 commit e606d03

3 files changed

Lines changed: 51 additions & 24 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,13 @@ Account brackets use Postgres, Drizzle migrations, and Clerk user IDs. Set `DATA
7373
to a pooled connection for the app and `DATABASE_URL_UNPOOLED` to a direct connection
7474
for migrations. Keep both in your environment manager; never commit connection strings.
7575

76-
Vercel Production builds automatically apply committed migrations before building the app.
76+
Vercel Production and Preview builds automatically apply committed migrations before building the app.
7777
Configure both variables in Vercel's **Production** environment using the Neon `main` branch.
78+
For **Preview**, let the Neon integration supply both URLs for that preview's branch.
79+
The migration and runtime URLs must target the same database branch.
7880
Missing migration credentials or a failed migration stops deployment. A direct-connection
7981
advisory lock serializes concurrent builds; Drizzle records applied migrations for safe retries.
80-
Preview and local builds skip this step and use an independently migrated development branch.
82+
Local builds skip this step; run `bun run db:migrate` to update your local development database.
8183

8284
Migrations run before traffic switches, so schema changes must remain compatible with the
8385
currently deployed app. Use additive changes first; remove old columns in a later release.

scripts/migrate-on-deploy.mjs

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,30 @@ import { migrate } from "drizzle-orm/node-postgres/migrator";
55

66
class MigrationConfigurationError extends Error {}
77

8-
async function migrateProduction() {
9-
if (process.env.VERCEL_ENV !== "production") {
10-
console.log("Skipping production migrations outside a Vercel production build.");
8+
async function migrateDeployment() {
9+
const environment = process.env.VERCEL_ENV;
10+
if (!["production", "preview"].includes(environment)) {
11+
console.log("Skipping deployment migrations outside Vercel production and preview builds.");
1112
return;
1213
}
1314
const connectionString = process.env.DATABASE_URL_UNPOOLED;
14-
if (!connectionString) {
15+
if (!connectionString || !process.env.DATABASE_URL) {
1516
throw new MigrationConfigurationError(
16-
"Set DATABASE_URL_UNPOOLED in Vercel Production before deploying.",
17+
`Set DATABASE_URL and DATABASE_URL_UNPOOLED in Vercel ${environment} before deploying.`,
1718
);
1819
}
1920
const connection = new URL(connectionString);
2021
if (connection.hostname.includes("-pooler.")) {
2122
throw new MigrationConfigurationError(
22-
"Production migrations require a direct, unpooled connection.",
23+
"Deployment migrations require a direct, unpooled connection.",
24+
);
25+
}
26+
const runtime = new URL(process.env.DATABASE_URL);
27+
const databaseTarget = (url) =>
28+
`${url.hostname.replace(/-pooler(?=\.)/, "")}:${url.port || "5432"}${url.pathname}`;
29+
if (databaseTarget(connection) !== databaseTarget(runtime)) {
30+
throw new MigrationConfigurationError(
31+
"DATABASE_URL and DATABASE_URL_UNPOOLED must target the same database branch.",
2332
);
2433
}
2534
const client = new pg.Client({ connectionString, connectionTimeoutMillis: 10000 });
@@ -32,13 +41,13 @@ async function migrateProduction() {
3241
await migrate(drizzle(client), {
3342
migrationsFolder: fileURLToPath(new URL("../drizzle", import.meta.url)),
3443
});
35-
console.log("Production schema is up to date.");
44+
console.log(`${environment} schema is up to date.`);
3645
} finally {
3746
await client.end();
3847
}
3948
}
4049

41-
migrateProduction().catch((error) => {
50+
migrateDeployment().catch((error) => {
4251
// Driver errors can contain connection details; do not print credentials to
4352
// public build logs. Configuration errors above contain only our own text.
4453
const message =

scripts/migrate-on-deploy.test.mjs

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ function run(overrides) {
99
process.execPath,
1010
[fileURLToPath(new URL("./migrate-on-deploy.mjs", import.meta.url))],
1111
{
12-
env: { ...process.env, DATABASE_URL_UNPOOLED: "", ...overrides },
12+
env: { ...process.env, DATABASE_URL_UNPOOLED: "", DATABASE_URL: "", ...overrides },
1313
stdio: ["ignore", "pipe", "pipe"],
1414
},
1515
);
@@ -25,28 +25,44 @@ function run(overrides) {
2525
});
2626
}
2727

28-
test("local and preview builds never require or connect to production storage", async () => {
29-
for (const VERCEL_ENV of ["", "preview", "development"]) {
28+
test("local builds never require or connect to deployment storage", async () => {
29+
for (const VERCEL_ENV of ["", "development"]) {
3030
assert.equal((await run({ VERCEL_ENV, DATABASE_URL_UNPOOLED: "invalid" })).code, 0);
3131
}
3232
});
3333

34-
test("production fails closed without a direct connection", async () => {
35-
assert.equal((await run({ VERCEL_ENV: "production" })).code, 1);
36-
const result = await run({
37-
VERCEL_ENV: "production",
38-
DATABASE_URL_UNPOOLED: "postgres://user:secret@ep-example-pooler.neon.tech/neondb",
39-
});
40-
assert.equal(result.code, 1);
41-
assert.ok(!result.output.includes("secret"));
34+
test("deployment builds fail closed without matching direct and runtime connections", async () => {
35+
for (const VERCEL_ENV of ["production", "preview"]) {
36+
assert.equal((await run({ VERCEL_ENV })).code, 1);
37+
const result = await run({
38+
VERCEL_ENV,
39+
DATABASE_URL_UNPOOLED: "postgres://user:secret@ep-example-pooler.neon.tech/neondb",
40+
DATABASE_URL: "postgres://user:secret@ep-example-pooler.neon.tech/neondb",
41+
});
42+
assert.equal(result.code, 1);
43+
assert.ok(!result.output.includes("secret"));
44+
const mismatch = await run({
45+
VERCEL_ENV,
46+
DATABASE_URL: "postgres://user:secret@ep-preview-pooler.neon.tech/neondb",
47+
DATABASE_URL_UNPOOLED: "postgres://user:secret@ep-main.neon.tech/neondb",
48+
});
49+
assert.equal(mismatch.code, 1);
50+
assert.match(mismatch.output, /same database branch/);
51+
}
4252
});
4353

4454
test(
45-
"production migration retries and concurrent builds are idempotent",
55+
"preview and production migration retries are idempotent",
4656
{ skip: !process.env.TEST_DATABASE_URL },
4757
async () => {
48-
const env = { VERCEL_ENV: "production", DATABASE_URL_UNPOOLED: process.env.TEST_DATABASE_URL };
49-
for (const result of await Promise.all([run(env), run(env)])) {
58+
const env = {
59+
DATABASE_URL: process.env.TEST_DATABASE_URL,
60+
DATABASE_URL_UNPOOLED: process.env.TEST_DATABASE_URL,
61+
};
62+
for (const result of await Promise.all([
63+
run({ ...env, VERCEL_ENV: "preview" }),
64+
run({ ...env, VERCEL_ENV: "production" }),
65+
])) {
5066
assert.equal(result.code, 0, result.output);
5167
assert.match(result.output, /schema is up to date/);
5268
}

0 commit comments

Comments
 (0)