#!/usr/bin/env node import { execFileSync, spawnSync } from 'node:child_process' import { lstatSync, readFileSync } from 'node:fs' import { basename } from 'node:path' const migrationDirectory = 'apps/api/migrations/' const base = process.argv[2] || process.env.RELEASE_BASE_SHA || '' function fail(message) { console.error(`migration_safety=FAIL ${message}`) process.exit(1) } function git(args, options = {}) { try { return execFileSync('git', args, { encoding: options.encoding ?? 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }) } catch (error) { const stderr = error.stderr?.toString().trim() fail(stderr ? `git_error=${JSON.stringify(stderr)}` : 'git command failed') } } if (!/^[0-9a-f]{40}$/.test(base)) { fail('RELEASE_BASE_SHA must be a full lowercase commit SHA') } const root = git(['rev-parse', '--show-toplevel']).trim() process.chdir(root) const resolvedBase = git([ 'rev-parse', '--verify', '--end-of-options', `${base}^{commit}`, ]).trim() if (resolvedBase !== base) { fail('release base did not resolve exactly') } const ancestry = spawnSync('git', ['merge-base', '--is-ancestor', base, 'HEAD'], { stdio: 'ignore', }) if (ancestry.status !== 0) { fail('release base is not an ancestor of HEAD') } function changedMigrations(from) { const rawChanges = git( [ 'diff', '--name-status', '-z', '--no-renames', `${from}..HEAD`, '--', migrationDirectory, ], { encoding: null }, ) const fields = rawChanges.toString('utf8').split('\0') if (fields.at(-1) === '') fields.pop() if (fields.length % 2 !== 0) { fail('could not parse changed migration paths') } return fields } const fields = changedMigrations(base) function executableSql(sql, source = 'SQL input') { let output = '' for (let index = 0; index < sql.length; index += 1) { const current = sql[index] const next = sql[index + 1] if (current === '-' && next === '-') { index += 2 while (index < sql.length && sql[index] !== '\n') index += 1 output += '\n' continue } if (current === '/' && next === '*') { let depth = 1 index += 2 while (index < sql.length && depth > 0) { if (sql[index] === '/' && sql[index + 1] === '*') { depth += 1 index += 2 continue } if (sql[index] === '*' && sql[index + 1] === '/') { depth -= 1 index += 2 continue } index += 1 } if (depth !== 0) fail('unterminated SQL block comment') index -= 1 output += ' ' continue } if (current === "'" || current === '"') { const prefix = sql.slice(Math.max(0, index - 2), index).toUpperCase() const previous = sql[index - 1]?.toUpperCase() const beforePrevious = sql[index - 2] || '' const beforeUnicodePrefix = sql[index - 3] || '' const escapeString = current === "'" && previous === 'E' && !/[A-Za-z0-9_$]/.test(beforePrevious) const unicodeString = prefix === 'U&' && !/[A-Za-z0-9_$]/.test(beforeUnicodePrefix) if (escapeString || unicodeString) { fail(`${source}: PostgreSQL escape/unicode strings are not allowed in migrations`) } const quote = current index += 1 let closed = false while (index < sql.length) { if (sql[index] === quote && sql[index + 1] === quote) { index += 2 continue } if (sql[index] === quote) { closed = true break } index += 1 } if (!closed) fail('unterminated SQL quoted value') output += ' ' continue } if (current === '$') { const delimiter = sql.slice(index).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/)?.[0] if (delimiter) { const bodyStart = index + delimiter.length const bodyEnd = sql.indexOf(delimiter, bodyStart) if (bodyEnd === -1) fail('unterminated SQL dollar-quoted body') // PostgreSQL function bodies may contain executable DDL. Scan their // contents recursively instead of treating them as inert strings. const bodySql = executableSql(sql.slice(bodyStart, bodyEnd), source) // A bare END terminates a PL/pgSQL block inside a dollar-quoted body, // but at the top level PostgreSQL treats END as COMMIT. Mask only the // block terminator here so the top-level transaction rule stays exact. const bodyWithoutBlockEnd = bodySql .split(';') .map((statement) => (/^\s*END\s*$/i.test(statement) ? ' ' : statement)) .join(';') output += ` ${bodyWithoutBlockEnd} ` index = bodyEnd + delimiter.length - 1 continue } } output += current } return output } const destructiveRules = [ { name: 'procedural SQL body', pattern: /^\s*(?:DO\b|CREATE\s+(?:OR\s+REPLACE\s+)?(?:FUNCTION|PROCEDURE)\b)/i, }, { name: 'dynamic SQL execution', pattern: /\bEXECUTE\b/i, }, { name: 'destructive DROP operation', pattern: /\bDROP\b/i, }, { name: 'MERGE operation', pattern: /\bMERGE\b/i }, { name: 'CREATE OR REPLACE operation', pattern: /\bCREATE\s+OR\s+REPLACE\b/i, }, { name: 'TRUNCATE operation', pattern: /\bTRUNCATE(?:\s+TABLE)?\b/i }, { name: 'DELETE FROM operation', pattern: /\bDELETE\s+FROM\b/i }, { name: 'permission revocation', pattern: /\bREVOKE\b/i }, { name: 'transaction control operation', pattern: /\b(?:COMMIT|ROLLBACK|ABORT|SAVEPOINT)\b|\bRELEASE\s+SAVEPOINT\b|\bPREPARE\s+TRANSACTION\b|^\s*END(?:\s+(?:WORK|TRANSACTION))?(?:\s+AND\s+(?:NO\s+)?CHAIN)?\s*$/i, }, { name: 'SQL string semantics override', pattern: /\bSET\s+(?:LOCAL\s+|SESSION\s+)?STANDARD_CONFORMING_STRINGS\b/i, }, { name: 'object or column rename', pattern: /\bRENAME\b/i, }, { name: 'SET SCHEMA operation', pattern: /\bSET\s+SCHEMA\b/i }, { name: 'column type change', pattern: /\bALTER\s+TABLE\b[\s\S]*\bALTER\s+(?:COLUMN\s+)?[\s\S]*\b(?:SET\s+DATA\s+)?TYPE\b/i, }, { name: 'SET NOT NULL operation', pattern: /\bALTER\s+TABLE\b[\s\S]*\bALTER\s+(?:COLUMN\s+)?[\s\S]*\bSET\s+NOT\s+NULL\b/i, }, { name: 'non-null column addition', pattern: /\bALTER\s+TABLE\b[\s\S]*\bADD\s+(?:COLUMN\s+)?[\s\S]*\bNOT\s+NULL\b/i, }, { name: 'DROP DEFAULT operation', pattern: /\bALTER\s+TABLE\b[\s\S]*\bALTER\s+(?:COLUMN\s+)?[\s\S]*\bDROP\s+DEFAULT\b/i, }, ] const violations = [] let addedFiles = 0 const orderingCandidates = new Set() function migrationNamesAt(ref) { const output = git( ['ls-tree', '-r', '-z', '--name-only', ref, '--', migrationDirectory], { encoding: null }, ) return output .toString('utf8') .split('\0') .filter(Boolean) .filter((file) => { const relative = file.slice(migrationDirectory.length) return file.startsWith(migrationDirectory) && !relative.includes('/') }) .map((file) => basename(file)) .filter((name) => /^\d{4}_[a-z0-9][a-z0-9_]*\.sql$/.test(name)) .sort() } const existingNames = migrationNamesAt(base) const previousMaximumName = existingNames.at(-1) || '' for (let index = 0; index < fields.length; index += 2) { if (fields[index] === 'A') orderingCandidates.add(fields[index + 1]) } for (let index = 0; index < fields.length; index += 2) { const status = fields[index] const file = fields[index + 1] if (status !== 'A') { violations.push(`${file}: applied migrations are immutable (status ${status})`) continue } addedFiles += 1 const filename = basename(file) if ( file !== `${migrationDirectory}${filename}` || !/^\d{4}_[a-z0-9][a-z0-9_]*\.sql$/.test(filename) ) { violations.push(`${file}: new migration must use NNNN_lowercase_name.sql`) continue } if ( orderingCandidates.has(file) && previousMaximumName && filename <= previousMaximumName ) { violations.push( `${file}: new migration name must sort after ${previousMaximumName}`, ) continue } const treeEntry = git(['ls-tree', 'HEAD', '--', file]).trim() let fileState try { fileState = lstatSync(file) } catch { violations.push(`${file}: new migration is unavailable in the worktree`) continue } if ( !/^100644 blob [0-9a-f]+\t/.test(treeEntry) || !fileState.isFile() || fileState.isSymbolicLink() ) { violations.push(`${file}: new migration must be a mode 100644 regular file`) continue } const statements = executableSql(readFileSync(file, 'utf8'), file).split(';') for (const statement of statements) { for (const rule of destructiveRules) { if (rule.pattern.test(statement)) { violations.push(`${file}: ${rule.name}`) } } } } if (violations.length > 0) { for (const violation of [...new Set(violations)]) { console.error(`migration_safety_violation=${JSON.stringify(violation)}`) } fail(`base=${base} violations=${new Set(violations).size}`) } console.log(`migration_safety=PASS base=${base} added_files=${addedFiles}`)