Skip to content

Commit 1d57208

Browse files
fix(db): make development schema pushes noninteractive (#7906)
* fix(db): resolve dev schema push column ambiguity * refactor(db): generalize push rename handling * docs(db): describe schema push reconciliation steps
1 parent 32c6429 commit 1d57208

12 files changed

Lines changed: 489 additions & 76 deletions

File tree

.github/CONTRIBUTING.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,8 @@ If you prefer not to use Docker. **All commands run from the repository root unl
256256

257257
For ad-hoc schema iteration during development you can also use `bun run db:push` from `packages/db`, but `db:migrate` is the canonical command for staging and production. `db:push` reconciles directly to the current schema without running versioned migration guards. For disposable local/dev databases, `bun run db:push --force` accepts Drizzle's data-loss prompts, including column drops.
258258

259+
`db:push` treats added and removed columns, tables, and other schema objects as separate creations and deletions. It never infers a rename. For an intentional rename during local development, run `bun run db:push --interactive-renames` in a terminal and select the old object in Drizzle's chooser. This flag does not approve data loss; `--force` controls that separately. After schema reconciliation succeeds, the wrapper reconciles credential policies and OAuth providers, then backfills search vectors. A failure stops subsequent steps. Staging and production changes still use reviewed versioned migrations with expand/contract deployment steps.
260+
259261
4. **Run the Development Servers:**
260262

261263
```bash

.github/workflows/migrations.yml

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,7 @@ jobs:
7575
echo "Dev environment — pushing schema directly (db:push)"
7676
# Dev deliberately forces direct schema reconciliation; staging and
7777
# production use guarded versioned migrations in the other branch.
78-
# drizzle-kit push needs a TTY to resolve ambiguous renames (--force only
79-
# covers data-loss). In CI it throws "Interactive prompts require a TTY
80-
# terminal" but still exits 0, so the job goes green without applying the
81-
# change. tee keeps the output live in the log; we then fail on drizzle's
82-
# own TTY error. pipefail also preserves a non-zero db:push exit through tee.
83-
SIM_DEV_DB_PUSH=1 bun run db:push --force < /dev/null 2>&1 | tee /tmp/db-push.log
84-
if grep -q "Interactive prompts require a TTY terminal" /tmp/db-push.log; then
85-
echo "ERROR: db:push needs an interactive rename decision; land it as a versioned migration instead of relying on push." >&2
86-
exit 1
87-
fi
78+
SIM_DEV_DB_PUSH=1 bun run db:push --force < /dev/null
8879
else
8980
echo "Applying versioned migrations (db:migrate)"
9081
bun run ./scripts/migrate.ts

bun.lock

Lines changed: 11 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@
192192
],
193193
"patchedDependencies": {
194194
"@better-auth/oauth-provider@1.6.27": "patches/@better-auth%2Foauth-provider@1.6.27.patch",
195+
"drizzle-kit@0.31.10": "patches/drizzle-kit@0.31.10.patch",
195196
"postgres@3.4.9": "patches/postgres@3.4.9.patch"
196197
}
197198
}

packages/db/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,9 @@
4848
},
4949
"devDependencies": {
5050
"@sim/tsconfig": "workspace:*",
51-
"drizzle-kit": "^0.31.4",
51+
"@types/bun": "1.4.1",
5252
"@types/node": "24.2.1",
53+
"drizzle-kit": "0.31.10",
5354
"typescript": "^7.0.2",
5455
"vitest": "^4.1.0"
5556
}
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
/** Matches the main migrator: an empty optional direct DSN falls back to DATABASE_URL. */
22
export function resolveMigrationDatabaseUrl(
3-
env: { MIGRATION_DATABASE_URL?: string; DATABASE_URL?: string } = process.env
3+
env: { MIGRATION_DATABASE_URL?: string; DATABASE_URL?: string } = {
4+
MIGRATION_DATABASE_URL: process.env.MIGRATION_DATABASE_URL,
5+
DATABASE_URL: process.env.DATABASE_URL,
6+
}
47
): string | undefined {
58
return env.MIGRATION_DATABASE_URL || env.DATABASE_URL
69
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { spawnSync } from 'node:child_process'
2+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { generateId } from '@sim/utils/id'
6+
import postgres, { type Sql } from 'postgres'
7+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
8+
9+
const databaseUrl = process.env.DB_PUSH_TEST_DATABASE_URL
10+
11+
describe.skipIf(!databaseUrl)('patched Drizzle push against PostgreSQL', () => {
12+
const databaseName = `push_policy_${generateId().replaceAll('-', '')}`
13+
let admin: Sql
14+
let sql: Sql
15+
let fixtureUrl: string
16+
let directory: string
17+
18+
beforeAll(async () => {
19+
admin = postgres(databaseUrl!, { max: 1, onnotice: () => {} })
20+
await admin`CREATE DATABASE ${admin(databaseName)}`
21+
const url = new URL(databaseUrl!)
22+
url.pathname = `/${databaseName}`
23+
fixtureUrl = url.toString()
24+
sql = postgres(fixtureUrl, { max: 1, onnotice: () => {} })
25+
directory = await mkdtemp(join(tmpdir(), 'push-policy-'))
26+
await writeFile(
27+
join(directory, 'drizzle.config.ts'),
28+
`export default {
29+
dialect: 'postgresql',
30+
schema: ${JSON.stringify(join(directory, 'schema.ts'))},
31+
schemaFilter: ['public', 'old_scope', 'new_scope'],
32+
tablesFilter: ['!script_migrations'],
33+
dbCredentials: { url: process.env.DATABASE_URL },
34+
}`
35+
)
36+
})
37+
38+
beforeEach(async () => {
39+
await sql`DROP SCHEMA IF EXISTS old_scope CASCADE`
40+
await sql`DROP SCHEMA IF EXISTS new_scope CASCADE`
41+
await sql`DROP SCHEMA public CASCADE`
42+
await sql`CREATE SCHEMA public`
43+
})
44+
45+
afterAll(async () => {
46+
await sql?.end()
47+
if (admin) {
48+
await admin`DROP DATABASE IF EXISTS ${admin(databaseName)}`
49+
await admin.end()
50+
}
51+
if (directory) await rm(directory, { recursive: true, force: true })
52+
})
53+
54+
async function schema(source: string) {
55+
await writeFile(
56+
join(directory, 'schema.ts'),
57+
`import { pgTable, pgSchema, pgEnum, text, integer, boolean, check } from ${JSON.stringify(import.meta.resolve('drizzle-orm/pg-core'))}
58+
import { sql } from ${JSON.stringify(import.meta.resolve('drizzle-orm'))}
59+
${source}`
60+
)
61+
}
62+
63+
/** Exercise the patched CLI with pipes, never a terminal or canned prompt answers. */
64+
function push(args = ['--force'], renameMode: string | undefined = 'create') {
65+
return spawnSync(
66+
'bunx',
67+
[
68+
'--no-install',
69+
'drizzle-kit',
70+
'push',
71+
'--config',
72+
join(directory, 'drizzle.config.ts'),
73+
...args,
74+
],
75+
{
76+
env: { ...process.env, DATABASE_URL: fixtureUrl, SIM_DB_PUSH_RENAME_MODE: renameMode },
77+
encoding: 'utf8',
78+
timeout: 30_000,
79+
}
80+
)
81+
}
82+
83+
async function legacyColumns() {
84+
await sql`CREATE TABLE records (id text PRIMARY KEY, old_label text, old_enabled boolean)`
85+
await sql`INSERT INTO records VALUES ('existing', 'original value', true)`
86+
await schema(`export const records = pgTable('records', {
87+
id: text('id').primaryKey(),
88+
newLabel: text('new_label').default('new default'),
89+
newEnabled: boolean('new_enabled').notNull().default(false),
90+
})`)
91+
}
92+
93+
it('initializes a fresh database', async () => {
94+
await schema(`export const records = pgTable('records', {
95+
id: text('id').primaryKey(), enabled: boolean('enabled').notNull().default(false),
96+
})`)
97+
const result = push()
98+
expect(result.error).toBeUndefined()
99+
expect(result.status).toBe(0)
100+
await sql`INSERT INTO records (id) VALUES ('new-row')`
101+
expect(await sql`SELECT * FROM records`).toEqual([{ id: 'new-row', enabled: false }])
102+
}, 30_000)
103+
104+
it('creates independent columns across ambiguous pairs and can be rerun', async () => {
105+
await legacyColumns()
106+
const result = push()
107+
expect(result.error).toBeUndefined()
108+
expect(result.status).toBe(0)
109+
expect(result.stdout + result.stderr).not.toContain(
110+
'Interactive prompts require a TTY terminal'
111+
)
112+
expect(await sql`SELECT * FROM records`).toEqual([
113+
{ id: 'existing', new_label: 'new default', new_enabled: false },
114+
])
115+
const repeated = push()
116+
expect(repeated.status).toBe(0)
117+
expect(repeated.stdout).toContain('No changes detected')
118+
}, 60_000)
119+
120+
it('creates independent tables and enums while preserving the excluded script ledger', async () => {
121+
await sql`CREATE TYPE old_status AS ENUM ('active')`
122+
await sql`CREATE TABLE old_records (id text PRIMARY KEY, status old_status)`
123+
await sql`INSERT INTO old_records VALUES ('old-row', 'active')`
124+
await sql`CREATE TABLE script_migrations (name text PRIMARY KEY)`
125+
await sql`INSERT INTO script_migrations VALUES ('completed-fixture-migration')`
126+
await schema(`export const status = pgEnum('new_status', ['active'])
127+
export const records = pgTable('new_records', { id: text('id').primaryKey(), status: status('status') })`)
128+
const result = push()
129+
expect(result.error).toBeUndefined()
130+
expect(result.status).toBe(0)
131+
expect(await sql`SELECT * FROM new_records`).toEqual([])
132+
expect(
133+
await sql`SELECT to_regclass('old_records') AS old_table, to_regtype('old_status') AS old_type`
134+
).toEqual([{ old_table: null, old_type: null }])
135+
expect(await sql`SELECT * FROM script_migrations`).toEqual([
136+
{ name: 'completed-fixture-migration' },
137+
])
138+
}, 30_000)
139+
140+
it('creates a new schema instead of moving a removed schema', async () => {
141+
await sql`CREATE SCHEMA old_scope`
142+
await sql`CREATE TABLE old_scope.records (id text PRIMARY KEY)`
143+
await sql`INSERT INTO old_scope.records VALUES ('old-row')`
144+
await schema(`export const scope = pgSchema('new_scope')
145+
export const records = scope.table('records', { id: text('id').primaryKey() })`)
146+
const result = push()
147+
expect(result.error).toBeUndefined()
148+
expect(result.status, result.stdout + result.stderr).toBe(0)
149+
expect(await sql`SELECT * FROM new_scope.records`).toEqual([])
150+
expect(await sql`SELECT to_regnamespace('old_scope') AS old_schema`).toEqual([
151+
{ old_schema: null },
152+
])
153+
}, 30_000)
154+
155+
it('keeps the data-loss approval independent of rename resolution', async () => {
156+
await legacyColumns()
157+
const result = push([])
158+
expect(result.error).toBeUndefined()
159+
expect(result.status).toBe(1)
160+
expect(result.stdout).toContain('Found data-loss statements')
161+
expect(await sql`SELECT * FROM records`).toEqual([
162+
{ id: 'existing', old_label: 'original value', old_enabled: true },
163+
])
164+
}, 30_000)
165+
166+
it('retains native rename prompts when the policy is not enabled', async () => {
167+
await legacyColumns()
168+
const result = push(['--force'], 'prompt')
169+
expect(result.error).toBeUndefined()
170+
expect(result.status).toBe(1)
171+
expect(result.stdout + result.stderr).toContain('Interactive prompts require a TTY terminal')
172+
expect(await sql`SELECT old_label FROM records`).toEqual([{ old_label: 'original value' }])
173+
}, 30_000)
174+
175+
it('propagates a database DDL error instead of reporting success', async () => {
176+
await sql`CREATE TABLE records (id text PRIMARY KEY, value integer)`
177+
await sql`INSERT INTO records VALUES ('invalid-row', -1)`
178+
await schema(`export const records = pgTable('records', {
179+
id: text('id').primaryKey(), value: integer('value'),
180+
}, (table) => [check('nonnegative_value', sql\`\${table.value} >= 0\`)])`)
181+
const result = push()
182+
expect(result.error).toBeUndefined()
183+
expect(result.status).toBe(1)
184+
expect(result.stderr).toContain('23514')
185+
expect(await sql`SELECT value FROM records`).toEqual([{ value: -1 }])
186+
}, 30_000)
187+
})

packages/db/scripts/push.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { runPush } from '@sim/db/scripts/push'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
vi.mock('@sim/logger', () => ({ createLogger: () => ({ info: vi.fn(), error: vi.fn() }) }))
5+
6+
interface SpawnOptions {
7+
env?: NodeJS.ProcessEnv
8+
stdin: string
9+
stdout: string
10+
stderr: string
11+
}
12+
13+
const spawn = vi.fn<(command: string[], options: SpawnOptions) => { exited: Promise<number> }>()
14+
const stdinTty = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY')
15+
const stdoutTty = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY')
16+
17+
function setTerminal(enabled: boolean) {
18+
Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: enabled })
19+
Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: enabled })
20+
}
21+
22+
beforeEach(() => {
23+
spawn.mockReset().mockImplementation(() => ({ exited: Promise.resolve(0) }))
24+
vi.stubGlobal('Bun', { spawn })
25+
setTerminal(false)
26+
})
27+
28+
afterEach(() => {
29+
vi.unstubAllGlobals()
30+
vi.unstubAllEnvs()
31+
if (stdinTty) Object.defineProperty(process.stdin, 'isTTY', stdinTty)
32+
else Reflect.deleteProperty(process.stdin, 'isTTY')
33+
if (stdoutTty) Object.defineProperty(process.stdout, 'isTTY', stdoutTty)
34+
else Reflect.deleteProperty(process.stdout, 'isTTY')
35+
})
36+
37+
describe('db:push policy and process boundaries', () => {
38+
it('sets create/drop only on the Drizzle child and forwards force independently', async () => {
39+
vi.stubEnv('SIM_DB_PUSH_RENAME_MODE', undefined)
40+
expect(await runPush(['--force'])).toBe(0)
41+
expect(spawn).toHaveBeenCalledTimes(4)
42+
expect(spawn.mock.calls[0][0]).toEqual([
43+
'bunx',
44+
'--no-install',
45+
'drizzle-kit',
46+
'push',
47+
'--config=./drizzle.config.ts',
48+
'--force',
49+
])
50+
expect(spawn.mock.calls[0][1].env?.SIM_DB_PUSH_RENAME_MODE).toBe('create')
51+
expect(process.env.SIM_DB_PUSH_RENAME_MODE).toBeUndefined()
52+
for (const [, options] of spawn.mock.calls.slice(1)) expect(options.env).toBeUndefined()
53+
})
54+
55+
it('does not implicitly approve data loss', async () => {
56+
expect(await runPush([])).toBe(0)
57+
expect(spawn.mock.calls[0][0]).not.toContain('--force')
58+
})
59+
60+
it('stops reconciliation when Drizzle fails', async () => {
61+
spawn.mockReturnValueOnce({ exited: Promise.resolve(42) })
62+
expect(await runPush(['--force'])).toBe(42)
63+
expect(spawn).toHaveBeenCalledTimes(1)
64+
})
65+
66+
it('stops after the first failed reconciliation', async () => {
67+
spawn.mockReturnValueOnce({ exited: Promise.resolve(0) })
68+
spawn.mockReturnValueOnce({ exited: Promise.resolve(43) })
69+
expect(await runPush([])).toBe(43)
70+
expect(spawn).toHaveBeenCalledTimes(2)
71+
})
72+
73+
it('passes intentional renames to the native chooser in a terminal', async () => {
74+
setTerminal(true)
75+
vi.stubEnv('SIM_DB_PUSH_RENAME_MODE', 'create')
76+
expect(await runPush(['--interactive-renames', '--verbose'])).toBe(0)
77+
expect(spawn.mock.calls[0][0]).not.toContain('--interactive-renames')
78+
expect(spawn.mock.calls[0][0]).toContain('--verbose')
79+
expect(spawn.mock.calls[0][1].env?.SIM_DB_PUSH_RENAME_MODE).toBe('prompt')
80+
})
81+
82+
it('rejects interactive renames without a terminal before any database commands', async () => {
83+
expect(await runPush(['--interactive-renames', '--force'])).toBe(1)
84+
expect(spawn).not.toHaveBeenCalled()
85+
})
86+
87+
it('does not reconcile the database when requesting help', async () => {
88+
expect(await runPush(['--help'])).toBe(0)
89+
expect(spawn).toHaveBeenCalledTimes(1)
90+
})
91+
})

0 commit comments

Comments
 (0)