|
| 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 | +}) |
0 commit comments