Skip to content

Commit 48e542c

Browse files
committed
fix(db): resolve dev schema push column ambiguity
1 parent 32c6429 commit 48e542c

5 files changed

Lines changed: 240 additions & 20 deletions

File tree

‎.github/workflows/migrations.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ jobs:
7272
fi
7373
7474
if [ "${ENVIRONMENT}" = "dev" ]; then
75+
SIM_DEV_DB_PUSH=1 bun run ./scripts/prepare-dev-schema.ts
7576
echo "Dev environment — pushing schema directly (db:push)"
7677
# Dev deliberately forces direct schema reconciliation; staging and
7778
# production use guarded versioned migrations in the other branch.
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: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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 { fileURLToPath } from 'node:url'
6+
import { prepareDevSchema } from '@sim/db/scripts/prepare-dev-schema'
7+
import { generateId } from '@sim/utils/id'
8+
import postgres, { type Sql } from 'postgres'
9+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
10+
11+
const databaseUrl = process.env.DEV_SCHEMA_TEST_DATABASE_URL
12+
13+
describe.skipIf(!databaseUrl)('dev schema preparation', () => {
14+
const databaseName = `dev_schema_${generateId().replaceAll('-', '')}`
15+
let admin: Sql
16+
let sql: Sql
17+
let fixtureUrl: string
18+
let directory: string
19+
20+
beforeAll(async () => {
21+
admin = postgres(databaseUrl!, { max: 1, onnotice: () => {} })
22+
await admin`CREATE DATABASE ${admin(databaseName)}`
23+
const url = new URL(databaseUrl!)
24+
url.pathname = `/${databaseName}`
25+
fixtureUrl = url.toString()
26+
sql = postgres(fixtureUrl, { max: 1, onnotice: () => {} })
27+
directory = await mkdtemp(join(tmpdir(), 'dev-schema-'))
28+
await writeFile(
29+
join(directory, 'schema.ts'),
30+
`import { pgTable, text, boolean } from ${JSON.stringify(import.meta.resolve('drizzle-orm/pg-core'))}
31+
export const organization = pgTable('organization', {
32+
id: text('id').primaryKey(),
33+
requireSso: boolean('require_sso').notNull().default(false),
34+
})`
35+
)
36+
await writeFile(
37+
join(directory, 'drizzle.config.ts'),
38+
`export default {
39+
dialect: 'postgresql',
40+
schema: ${JSON.stringify(join(directory, 'schema.ts'))},
41+
dbCredentials: { url: process.env.DATABASE_URL },
42+
}`
43+
)
44+
})
45+
46+
beforeEach(async () => {
47+
await sql`DROP TABLE IF EXISTS organization`
48+
})
49+
50+
afterAll(async () => {
51+
await sql?.end()
52+
if (admin) {
53+
await admin`DROP DATABASE IF EXISTS ${admin(databaseName)}`
54+
await admin.end()
55+
}
56+
if (directory) await rm(directory, { recursive: true, force: true })
57+
})
58+
59+
async function createLegacyOrganization() {
60+
await sql`CREATE TABLE organization (id text PRIMARY KEY, departed_member_usage numeric NOT NULL DEFAULT 0)`
61+
await sql`INSERT INTO organization (id, departed_member_usage) VALUES ('existing-org', 12.5)`
62+
}
63+
64+
/** Run the actual CLI without a TTY, matching the deployment failure. */
65+
function push() {
66+
return spawnSync(
67+
'bunx',
68+
[
69+
'--no-install',
70+
'drizzle-kit',
71+
'push',
72+
'--config',
73+
join(directory, 'drizzle.config.ts'),
74+
'--force',
75+
],
76+
{
77+
env: { ...process.env, DATABASE_URL: fixtureUrl },
78+
encoding: 'utf8',
79+
timeout: 30_000,
80+
}
81+
)
82+
}
83+
84+
it('resolves the real noninteractive rename failure without renaming existing data', async () => {
85+
await createLegacyOrganization()
86+
const before = push()
87+
expect(before.error).toBeUndefined()
88+
expect(before.stdout + before.stderr).toContain('Interactive prompts require a TTY terminal')
89+
90+
expect(await prepareDevSchema(sql)).toBe(true)
91+
expect(await sql`SELECT * FROM organization`).toEqual([
92+
{ id: 'existing-org', departed_member_usage: '12.5', require_sso: false },
93+
])
94+
95+
const after = push()
96+
expect(after.error).toBeUndefined()
97+
expect(after.status).toBe(0)
98+
expect(after.stdout + after.stderr).not.toContain('Interactive prompts require a TTY terminal')
99+
expect(await sql`SELECT * FROM organization`).toEqual([
100+
{ id: 'existing-org', require_sso: false },
101+
])
102+
}, 60_000)
103+
104+
it('preserves an enabled SSO policy when rerun', async () => {
105+
await createLegacyOrganization()
106+
await prepareDevSchema(sql)
107+
await sql`UPDATE organization SET require_sso = true`
108+
expect(await prepareDevSchema(sql)).toBe(false)
109+
expect(await sql`SELECT require_sso, departed_member_usage FROM organization`).toEqual([
110+
{ require_sso: true, departed_member_usage: '12.5' },
111+
])
112+
})
113+
114+
it('runs the CI entry point with an empty optional direct URL', async () => {
115+
await createLegacyOrganization()
116+
const result = spawnSync(
117+
'bun',
118+
['run', fileURLToPath(new URL('./prepare-dev-schema.ts', import.meta.url))],
119+
{
120+
env: {
121+
...process.env,
122+
SIM_DEV_DB_PUSH: '1',
123+
DATABASE_URL: fixtureUrl,
124+
MIGRATION_DATABASE_URL: '',
125+
},
126+
encoding: 'utf8',
127+
timeout: 30_000,
128+
}
129+
)
130+
expect(result.error).toBeUndefined()
131+
expect(result.status).toBe(0)
132+
expect(await sql`SELECT require_sso FROM organization`).toEqual([{ require_sso: false }])
133+
}, 30_000)
134+
135+
it('leaves fresh databases for push to initialize', async () => {
136+
expect(await prepareDevSchema(sql)).toBe(false)
137+
const result = push()
138+
expect(result.error).toBeUndefined()
139+
expect(result.status).toBe(0)
140+
await sql`INSERT INTO organization (id) VALUES ('new-org')`
141+
expect(await sql`SELECT require_sso FROM organization`).toEqual([{ require_sso: false }])
142+
}, 30_000)
143+
144+
it('serializes overlapping preparation attempts', async () => {
145+
await createLegacyOrganization()
146+
const other = postgres(fixtureUrl, { max: 1, onnotice: () => {} })
147+
try {
148+
const results = await Promise.all([prepareDevSchema(sql), prepareDevSchema(other)])
149+
expect(results.sort()).toEqual([false, true])
150+
expect(await sql`SELECT require_sso FROM organization`).toEqual([{ require_sso: false }])
151+
} finally {
152+
await other.end()
153+
}
154+
})
155+
})
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { readFile } from 'node:fs/promises'
2+
import { resolveMigrationDatabaseUrl } from '@sim/db/script-migrations/database-url'
3+
import { createLogger } from '@sim/logger'
4+
import { getPostgresErrorCode } from '@sim/utils/errors'
5+
import postgres, { type Sql } from 'postgres'
6+
7+
const logger = createLogger('DevSchemaPreparation')
8+
const REQUIRE_SSO_MIGRATION = new URL(
9+
'../migrations/0350_organization_require_sso.sql',
10+
import.meta.url
11+
)
12+
13+
/**
14+
* Apply the existing additive migration before push compares require_sso with
15+
* departed_member_usage. Otherwise Drizzle asks whether the latter was renamed.
16+
* Fresh databases still get the entire organization table from push.
17+
*/
18+
export async function prepareDevSchema(sql: Sql): Promise<boolean> {
19+
const migration = await readFile(REQUIRE_SSO_MIGRATION, 'utf8')
20+
return sql.begin(async (tx) => {
21+
await tx`SET LOCAL lock_timeout = '5s'`
22+
await tx`SET LOCAL statement_timeout = '30s'`
23+
await tx`SET LOCAL search_path = public`
24+
const [table] = await tx<{ exists: boolean }[]>`
25+
SELECT to_regclass('public.organization') IS NOT NULL AS exists
26+
`
27+
if (!table.exists) return false
28+
29+
await tx`LOCK TABLE public.organization IN ACCESS EXCLUSIVE MODE`
30+
const [column] = await tx<{ exists: boolean }[]>`
31+
SELECT EXISTS (
32+
SELECT 1 FROM pg_attribute
33+
WHERE attrelid = 'public.organization'::regclass
34+
AND attname = 'require_sso' AND NOT attisdropped
35+
) AS exists
36+
`
37+
if (column.exists) return false
38+
39+
await tx.unsafe(migration)
40+
return true
41+
})
42+
}
43+
44+
if (import.meta.main) {
45+
if (process.env.SIM_DEV_DB_PUSH !== '1') {
46+
throw new Error('Dev schema preparation requires SIM_DEV_DB_PUSH=1')
47+
}
48+
const url = resolveMigrationDatabaseUrl()
49+
if (!url) throw new Error('Missing database URL for dev schema preparation')
50+
51+
const sql = postgres(url, { max: 1, connect_timeout: 10 })
52+
try {
53+
logger.info('Dev schema preparation completed', { applied: await prepareDevSchema(sql) })
54+
} catch (error) {
55+
logger.error('Dev schema preparation failed', { code: getPostgresErrorCode(error) })
56+
process.exitCode = 1
57+
} finally {
58+
await sql.end()
59+
}
60+
}

‎scripts/migrations-workflow.test.ts‎

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ function runMigration(env: Record<string, string> = {}) {
2525
printf 'COMMAND: %s\n' "$*"
2626
case "$2" in
2727
db:push) printf '%s\n' "$PUSH_OUTPUT"; return "$PUSH_EXIT" ;;
28-
./scripts/apply-dev-workspace-file-size-cutover.ts) return "$CUTOVER_EXIT" ;;
28+
./scripts/prepare-dev-schema.ts) return "$PREPARE_EXIT" ;;
2929
./scripts/migrate.ts) return "$MIGRATE_EXIT" ;;
3030
*) return 99 ;;
3131
esac
@@ -42,7 +42,7 @@ function runMigration(env: Record<string, string> = {}) {
4242
MIGRATION_TEST_LOG: join(directory, 'push.log'),
4343
PUSH_EXIT: '0',
4444
PUSH_OUTPUT: 'Changes applied',
45-
CUTOVER_EXIT: '0',
45+
PREPARE_EXIT: '0',
4646
MIGRATE_EXIT: '0',
4747
...env,
4848
},
@@ -61,39 +61,40 @@ describe('migration workflow exit propagation', () => {
6161
})
6262
expect(result.status).toBe(42)
6363
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-
)
6764
})
6865

69-
it('runs the dev cutover only after a successful schema push', () => {
66+
it('prepares the dev schema before pushing it', () => {
7067
const result = runMigration()
7168
expect(result.status).toBe(0)
7269
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'
70+
expect(result.stdout.indexOf('COMMAND: run ./scripts/prepare-dev-schema.ts')).toBeLessThan(
71+
result.stdout.indexOf('COMMAND: run db:push --force')
7572
)
73+
expect(result.stdout).toContain('COMMAND: run ./scripts/prepare-dev-schema.ts')
7674
})
7775

7876
it('still rejects drizzle interactive failures that exit zero', () => {
7977
const result = runMigration({ PUSH_OUTPUT: 'Interactive prompts require a TTY terminal' })
8078
expect(result.status).toBe(1)
81-
expect(result.stdout).not.toContain(
82-
'COMMAND: run ./scripts/apply-dev-workspace-file-size-cutover.ts'
83-
)
8479
})
8580

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')
81+
it('stops before push when preparation fails', () => {
82+
const result = runMigration({ PREPARE_EXIT: '43' })
83+
expect(result.status).toBe(43)
9484
expect(result.stdout).not.toContain('COMMAND: run db:push')
9585
})
9686

87+
it.each(['staging', 'production'])(
88+
'keeps versioned migration failures fatal in %s',
89+
(environment) => {
90+
const result = runMigration({ ENVIRONMENT: environment, MIGRATE_EXIT: '44' })
91+
expect(result.status).toBe(44)
92+
expect(result.stdout).toContain('COMMAND: run ./scripts/migrate.ts')
93+
expect(result.stdout).not.toContain('COMMAND: run db:push')
94+
expect(result.stdout).not.toContain('COMMAND: run ./scripts/prepare-dev-schema.ts')
95+
}
96+
)
97+
9798
it('fails before invoking commands when no database URL is configured', () => {
9899
const result = runMigration({ DATABASE_URL: '' })
99100
expect(result.status).toBe(1)

0 commit comments

Comments
 (0)