Skip to content

Commit d89bf4e

Browse files
committed
Fail migrations correctly and resolve empty direct database URLs
1 parent 04389c9 commit d89bf4e

6 files changed

Lines changed: 141 additions & 3 deletions

File tree

.github/workflows/migrations.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ jobs:
6464
MIGRATION_DATABASE_URL: ${{ inputs.environment == 'production' && secrets.MIGRATION_DATABASE_URL || inputs.environment == 'staging' && secrets.STAGING_MIGRATION_DATABASE_URL || '' }}
6565
ENVIRONMENT: ${{ inputs.environment }}
6666
run: |
67+
set -euo pipefail
68+
6769
if [ -z "$DATABASE_URL" ]; then
6870
echo "ERROR: no database URL secret resolved for environment '${ENVIRONMENT}'" >&2
6971
exit 1
@@ -75,7 +77,7 @@ jobs:
7577
# covers data-loss). In CI it throws "Interactive prompts require a TTY
7678
# terminal" but still exits 0, so the job goes green without applying the
7779
# change. tee keeps the output live in the log; we then fail on drizzle's
78-
# own TTY error. A genuine non-zero exit already fails via `set -e`.
80+
# own TTY error. pipefail also preserves a non-zero db:push exit through tee.
7981
bun run db:push --force < /dev/null 2>&1 | tee /tmp/db-push.log
8082
if grep -q "Interactive prompts require a TTY terminal" /tmp/db-push.log; then
8183
echo "ERROR: db:push needs an interactive rename decision; land it as a versioned migration instead of relying on push." >&2

packages/db/script-migrations/0015_backfill_embedding_search.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { resolveMigrationDatabaseUrl } from '@sim/db/script-migrations/database-url'
12
import type { ScriptMigration } from '@sim/db/script-migrations/types'
23
import { createLogger } from '@sim/logger'
34
import postgres, { type Sql } from 'postgres'
@@ -94,7 +95,7 @@ export const backfillEmbeddingSearchMigration: ScriptMigration = {
9495

9596
/** db:push also installs database behavior that Drizzle's schema cannot express. */
9697
if (import.meta.main) {
97-
const url = process.env.MIGRATION_DATABASE_URL ?? process.env.DATABASE_URL
98+
const url = resolveMigrationDatabaseUrl()
9899
if (!url) throw new Error('DATABASE_URL is required to initialize embedding search')
99100
const sql = postgres(url, { max: 1, max_lifetime: null, onnotice: () => undefined })
100101
try {

packages/db/script-migrations/0016_backfill_search_vectors.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { resolveMigrationDatabaseUrl } from '@sim/db/script-migrations/database-url'
12
import type { ScriptMigration } from '@sim/db/script-migrations/types'
23
import { createLogger } from '@sim/logger'
34
import postgres, { type Sql } from 'postgres'
@@ -241,7 +242,7 @@ export const backfillSearchVectorsMigration: ScriptMigration = {
241242
}
242243

243244
if (import.meta.main) {
244-
const url = process.env.MIGRATION_DATABASE_URL ?? process.env.DATABASE_URL
245+
const url = resolveMigrationDatabaseUrl()
245246
if (!url) throw new Error('DATABASE_URL is required to initialize search vectors')
246247
const sql = postgres(url, { max: 1, max_lifetime: null, onnotice: () => undefined })
247248
try {
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { resolveMigrationDatabaseUrl } from '@sim/db/script-migrations/database-url'
2+
import { afterEach, describe, expect, it, vi } from 'vitest'
3+
4+
const applicationUrl = 'postgresql://application.invalid/application'
5+
const migrationUrl = 'postgresql://migration.invalid/migrations'
6+
7+
describe('standalone migration database URL', () => {
8+
afterEach(() => vi.unstubAllEnvs())
9+
10+
it.each([
11+
{ direct: undefined, application: applicationUrl, expected: applicationUrl },
12+
{ direct: '', application: applicationUrl, expected: applicationUrl },
13+
{ direct: migrationUrl, application: applicationUrl, expected: migrationUrl },
14+
{ direct: migrationUrl, application: undefined, expected: migrationUrl },
15+
{ direct: undefined, application: undefined, expected: undefined },
16+
{ direct: '', application: '', expected: '' },
17+
])(
18+
'resolves direct=$direct and application=$application',
19+
({ direct, application, expected }) => {
20+
vi.stubEnv('MIGRATION_DATABASE_URL', direct)
21+
vi.stubEnv('DATABASE_URL', application)
22+
23+
expect(resolveMigrationDatabaseUrl()).toBe(expected)
24+
}
25+
)
26+
})
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/** Matches the main migrator: an empty optional direct DSN falls back to DATABASE_URL. */
2+
export function resolveMigrationDatabaseUrl(
3+
env: { MIGRATION_DATABASE_URL?: string; DATABASE_URL?: string } = process.env
4+
): string | undefined {
5+
return env.MIGRATION_DATABASE_URL || env.DATABASE_URL
6+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { spawnSync } from 'node:child_process'
2+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { describe, expect, it } from 'vitest'
6+
7+
const workflow = readFileSync(
8+
new URL('../.github/workflows/migrations.yml', import.meta.url),
9+
'utf8'
10+
)
11+
const step = workflow.match(/^ {8}run: \|\r?\n((?: {10}.*(?:\r?\n|$)|\r?\n)+)/m)?.[1]
12+
if (!step) throw new Error('Migration workflow must contain its schema application shell step')
13+
const script = step.replace(/^ {10}/gm, '')
14+
15+
/** Execute the checked-in shell with fake database commands and an isolated scratch log. */
16+
function runMigration(env: Record<string, string> = {}) {
17+
const directory = mkdtempSync(join(tmpdir(), 'migration-workflow-'))
18+
try {
19+
return spawnSync(
20+
'bash',
21+
[
22+
'-e',
23+
'-c',
24+
`bun() {
25+
printf 'COMMAND: %s\n' "$*"
26+
case "$2" in
27+
db:push) printf '%s\n' "$PUSH_OUTPUT"; return "$PUSH_EXIT" ;;
28+
./scripts/apply-dev-workspace-file-size-cutover.ts) return "$CUTOVER_EXIT" ;;
29+
./scripts/migrate.ts) return "$MIGRATE_EXIT" ;;
30+
*) return 99 ;;
31+
esac
32+
}
33+
${script.replaceAll('/tmp/db-push.log', '"$MIGRATION_TEST_LOG"')}`,
34+
],
35+
{
36+
encoding: 'utf8',
37+
env: {
38+
PATH: process.env.PATH,
39+
DATABASE_URL: 'postgresql://example.invalid/unused',
40+
MIGRATION_DATABASE_URL: '',
41+
ENVIRONMENT: 'dev',
42+
MIGRATION_TEST_LOG: join(directory, 'push.log'),
43+
PUSH_EXIT: '0',
44+
PUSH_OUTPUT: 'Changes applied',
45+
CUTOVER_EXIT: '0',
46+
MIGRATE_EXIT: '0',
47+
...env,
48+
},
49+
}
50+
)
51+
} finally {
52+
rmSync(directory, { recursive: true, force: true })
53+
}
54+
}
55+
56+
describe('migration workflow exit propagation', () => {
57+
it('fails when post-schema initialization fails before tee succeeds', () => {
58+
const result = runMigration({
59+
PUSH_EXIT: '42',
60+
PUSH_OUTPUT: 'Changes applied\nDATABASE_URL is required to initialize search vectors',
61+
})
62+
expect(result.status).toBe(42)
63+
expect(result.stdout).toContain('DATABASE_URL is required')
64+
expect(result.stdout).not.toContain(
65+
'COMMAND: run ./scripts/apply-dev-workspace-file-size-cutover.ts'
66+
)
67+
})
68+
69+
it('runs the dev cutover only after a successful schema push', () => {
70+
const result = runMigration()
71+
expect(result.status).toBe(0)
72+
expect(result.stdout).toContain('COMMAND: run db:push --force')
73+
expect(result.stdout).toContain(
74+
'COMMAND: run ./scripts/apply-dev-workspace-file-size-cutover.ts'
75+
)
76+
})
77+
78+
it('still rejects drizzle interactive failures that exit zero', () => {
79+
const result = runMigration({ PUSH_OUTPUT: 'Interactive prompts require a TTY terminal' })
80+
expect(result.status).toBe(1)
81+
expect(result.stdout).not.toContain(
82+
'COMMAND: run ./scripts/apply-dev-workspace-file-size-cutover.ts'
83+
)
84+
})
85+
86+
it('propagates dev cutover failures', () => {
87+
expect(runMigration({ CUTOVER_EXIT: '43' }).status).toBe(43)
88+
})
89+
90+
it('keeps versioned migration failures fatal outside dev', () => {
91+
const result = runMigration({ ENVIRONMENT: 'staging', MIGRATE_EXIT: '44' })
92+
expect(result.status).toBe(44)
93+
expect(result.stdout).toContain('COMMAND: run ./scripts/migrate.ts')
94+
expect(result.stdout).not.toContain('COMMAND: run db:push')
95+
})
96+
97+
it('fails before invoking commands when no database URL is configured', () => {
98+
const result = runMigration({ DATABASE_URL: '' })
99+
expect(result.status).toBe(1)
100+
expect(result.stdout).not.toContain('COMMAND:')
101+
})
102+
})

0 commit comments

Comments
 (0)