|
| 1 | +/** |
| 2 | + * Vitest globalSetup — applies SQL migrations against DATABASE_URL before any |
| 3 | + * integration tests run, so specs hitting a fresh Neon branch don't fail |
| 4 | + * `beforeAll` with `relation "cc_*" does not exist`. |
| 5 | + * |
| 6 | + * The repo's migrations directory mixes two histories: |
| 7 | + * 1. drizzle-kit-generated migrations tracked in `migrations/meta/_journal.json` |
| 8 | + * — the canonical schema as deployed. |
| 9 | + * 2. hand-rolled SQL files (0001_command_*, 0002_command_*, ..., 0015_*) that |
| 10 | + * represent an earlier, alternative history of the same tables. These |
| 11 | + * conflict with the drizzle path (e.g., 0005_schema_alignment.sql |
| 12 | + * references the pre-consolidation `source_tx_id` column). |
| 13 | + * 3. Additive, post-consolidation hand-rolled files (0017+ onwards) that |
| 14 | + * patch the drizzle schema with idempotency / decay columns the |
| 15 | + * integration suite relies on. |
| 16 | + * |
| 17 | + * Strategy: apply (1) in journal order, then (3) in name order. We tolerate |
| 18 | + * "already exists" Postgres error codes so partially migrated branches |
| 19 | + * (e.g., parent branches where some journaled migrations were applied out of |
| 20 | + * band) converge cleanly. Everything else fails loudly. |
| 21 | + * |
| 22 | + * Skips entirely when DATABASE_URL is unset or SKIP_INTEGRATION=1. |
| 23 | + */ |
| 24 | +import { readFileSync, readdirSync } from 'node:fs'; |
| 25 | +import { join, dirname } from 'node:path'; |
| 26 | +import { fileURLToPath } from 'node:url'; |
| 27 | +import { Pool } from '@neondatabase/serverless'; |
| 28 | + |
| 29 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 30 | +const MIGRATIONS_DIR = join(__dirname, '..', '..', 'migrations'); |
| 31 | + |
| 32 | +// Post-consolidation, additive hand-rolled migrations that complement the |
| 33 | +// journaled drizzle schema (not part of the alternative-history set). |
| 34 | +const ADDITIVE_PREFIXES = ['0017_', '0018_']; |
| 35 | + |
| 36 | +// Postgres SQLSTATE codes for "this object already exists" — safe to skip |
| 37 | +// when re-applying overlapping migration sets across branches. |
| 38 | +const ALREADY_EXISTS_CODES = new Set([ |
| 39 | + '42P07', // duplicate_table |
| 40 | + '42710', // duplicate_object (constraint, type, trigger, etc.) |
| 41 | + '42701', // duplicate_column |
| 42 | + '42P06', // duplicate_schema |
| 43 | + '42723', // duplicate_function |
| 44 | + '42P16', // invalid_object_definition (e.g., duplicate index name) |
| 45 | +]); |
| 46 | + |
| 47 | +/** |
| 48 | + * Split a SQL script on top-level `;` terminators, respecting `$$ ... $$` |
| 49 | + * dollar-quoted blocks so semicolons inside PL/pgSQL function bodies don't |
| 50 | + * prematurely terminate a statement. |
| 51 | + */ |
| 52 | +function splitOnSemicolons(sql: string): string[] { |
| 53 | + const out: string[] = []; |
| 54 | + let buf = ''; |
| 55 | + let inDollar = false; |
| 56 | + for (let i = 0; i < sql.length; i++) { |
| 57 | + const ch = sql[i]; |
| 58 | + if (ch === '$' && sql[i + 1] === '$') { |
| 59 | + inDollar = !inDollar; |
| 60 | + buf += '$$'; |
| 61 | + i++; |
| 62 | + continue; |
| 63 | + } |
| 64 | + if (ch === ';' && !inDollar) { |
| 65 | + const trimmed = buf.trim(); |
| 66 | + if (trimmed) out.push(trimmed); |
| 67 | + buf = ''; |
| 68 | + continue; |
| 69 | + } |
| 70 | + buf += ch; |
| 71 | + } |
| 72 | + const tail = buf.trim(); |
| 73 | + if (tail) out.push(tail); |
| 74 | + return out; |
| 75 | +} |
| 76 | + |
| 77 | +async function applyFile(pool: Pool, file: string): Promise<void> { |
| 78 | + const path = join(MIGRATIONS_DIR, file); |
| 79 | + const body = readFileSync(path, 'utf8'); |
| 80 | + |
| 81 | + // drizzle-kit migrations use `--> statement-breakpoint`; hand-rolled files |
| 82 | + // use bare `;` terminators. |
| 83 | + const statements = body.includes('--> statement-breakpoint') |
| 84 | + ? body.split('--> statement-breakpoint').map((s) => s.trim()).filter(Boolean) |
| 85 | + : splitOnSemicolons(body); |
| 86 | + |
| 87 | + let applied = 0; |
| 88 | + let skipped = 0; |
| 89 | + for (const stmt of statements) { |
| 90 | + if (!stmt.replace(/--.*$/gm, '').trim()) continue; |
| 91 | + try { |
| 92 | + await pool.query(stmt); |
| 93 | + applied++; |
| 94 | + } catch (err) { |
| 95 | + const code = (err as { code?: string }).code; |
| 96 | + if (code && ALREADY_EXISTS_CODES.has(code)) { |
| 97 | + skipped++; |
| 98 | + continue; |
| 99 | + } |
| 100 | + console.error(`[vitest globalSetup] failed applying ${file}:`, err); |
| 101 | + throw err; |
| 102 | + } |
| 103 | + } |
| 104 | + console.log( |
| 105 | + `[vitest globalSetup] ${file} — ${applied} applied, ${skipped} skipped (already exists)`, |
| 106 | + ); |
| 107 | +} |
| 108 | + |
| 109 | +export default async function globalSetup(): Promise<void> { |
| 110 | + const databaseUrl = process.env.DATABASE_URL; |
| 111 | + const skip = process.env.SKIP_INTEGRATION === '1'; |
| 112 | + |
| 113 | + if (!databaseUrl || skip) { |
| 114 | + console.log( |
| 115 | + `[vitest globalSetup] Skipping migrations (DATABASE_URL=${databaseUrl ? 'set' : 'unset'}, SKIP_INTEGRATION=${process.env.SKIP_INTEGRATION ?? 'unset'})`, |
| 116 | + ); |
| 117 | + return; |
| 118 | + } |
| 119 | + |
| 120 | + const pool = new Pool({ connectionString: databaseUrl }); |
| 121 | + |
| 122 | + try { |
| 123 | + // 1. Apply journaled drizzle migrations in journal order. |
| 124 | + const journalPath = join(MIGRATIONS_DIR, 'meta', '_journal.json'); |
| 125 | + const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as { |
| 126 | + entries: Array<{ tag: string }>; |
| 127 | + }; |
| 128 | + console.log( |
| 129 | + `[vitest globalSetup] Applying ${journal.entries.length} journaled migrations…`, |
| 130 | + ); |
| 131 | + for (const entry of journal.entries) { |
| 132 | + await applyFile(pool, `${entry.tag}.sql`); |
| 133 | + } |
| 134 | + |
| 135 | + // 2. Apply additive post-consolidation hand-rolled migrations. |
| 136 | + const additive = readdirSync(MIGRATIONS_DIR) |
| 137 | + .filter((f) => f.endsWith('.sql') && ADDITIVE_PREFIXES.some((p) => f.startsWith(p))) |
| 138 | + .sort(); |
| 139 | + if (additive.length > 0) { |
| 140 | + console.log( |
| 141 | + `[vitest globalSetup] Applying ${additive.length} additive migration(s): ${additive.join(', ')}`, |
| 142 | + ); |
| 143 | + for (const file of additive) { |
| 144 | + await applyFile(pool, file); |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + console.log('[vitest globalSetup] migrations applied.'); |
| 149 | + } finally { |
| 150 | + await pool.end(); |
| 151 | + } |
| 152 | +} |
0 commit comments