export const DESTRUCTIVE_MIGRATION_ACK_REQUIRED = 'DESTRUCTIVE_MIGRATION_ACK_REQUIRED' as const; /** * Deploy rejection message for unacknowledged destructive migrations. The * client only receives the message text (the classified error code does not * survive HTTP error serialization), so the UI matches on this to know a * blocked publish can be retried with an acknowledgment. */ export const DESTRUCTIVE_MIGRATION_ACK_REQUIRED_MESSAGE = 'Destructive schema changes require acknowledgment before migrations can run' as const; /** Publish plan: migration files could not be scanned for destructive SQL. */ export const DESTRUCTIVE_MIGRATION_SCAN_UNAVAILABLE = 'DESTRUCTIVE_MIGRATION_SCAN_UNAVAILABLE' as const; export type DestructiveMigrationKind = 'drop_table' | 'drop_database' | 'drop_schema' | 'truncate' | 'drop_column'; export type DestructiveMigrationFinding = { fileName: string; kind: DestructiveMigrationKind; excerpt: string; }; export type DestructiveMigrationFile = { fileName: string; sql: string; }; const MAX_EXCERPT_LENGTH = 160; /** * Schema-destroying statements that require explicit user acknowledgment. * * Intentionally out of scope (do not expand without a product decision): * - `DELETE FROM` / other DML data loss * - `DROP MATERIALIZED VIEW` / `DROP SEQUENCE` / `DROP TYPE` / `DROP VIEW` * - Dynamic SQL inside dollar-quoted function bodies * - Comment/string-literal edge cases in `stripSqlComments` (markers inside * string literals can hide a following statement on the same line) */ /** Patterns are checked in order; first match wins per statement. */ const DESTRUCTIVE_PATTERNS: Array<{ kind: DestructiveMigrationKind; re: RegExp }> = [ { kind: 'drop_table', re: /^\s*DROP\s+TABLE\b/i }, { kind: 'drop_database', re: /^\s*DROP\s+DATABASE\b/i }, { kind: 'drop_schema', re: /^\s*DROP\s+SCHEMA\b/i }, { kind: 'truncate', re: /^\s*TRUNCATE\b/i }, // ALTER … DROP [COLUMN] col — COLUMN is optional in Postgres. Exclude // DROP CONSTRAINT / INDEX / TRIGGER / RULE / POLICY / STATISTICS / IDENTITY // and ALTER COLUMN … DROP NOT NULL / DEFAULT / EXPRESSION (not column drops). { kind: 'drop_column', re: /^\s*ALTER\s+TABLE\b[\s\S]*?\bDROP\s+(?:COLUMN\s+)?(?!CONSTRAINT\b|INDEX\b|TRIGGER\b|RULE\b|POLICY\b|STATISTICS\b|IDENTITY\b|NOT\b|DEFAULT\b|EXPRESSION\b)["\w]/i }, { kind: 'drop_column', re: /^\s*DROP\s+COLUMN\b/i } ]; function stripSqlComments(sql: string): string { // Block comments first, then line comments. Good enough for migration files; // does not try to handle comment markers inside string literals. return sql.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n]*/g, ' '); } function splitStatements(sql: string): string[] { return sql .split(';') .map((part) => part.trim()) .filter((part) => part.length > 0); } function excerptFor(statement: string): string { const collapsed = statement.replace(/\s+/g, ' ').trim(); if (collapsed.length <= MAX_EXCERPT_LENGTH) { return collapsed; } return `${collapsed.slice(0, MAX_EXCERPT_LENGTH - 1)}…`; } function classifyStatement(statement: string): DestructiveMigrationKind | undefined { for (const { kind, re } of DESTRUCTIVE_PATTERNS) { if (re.test(statement)) { return kind; } } return undefined; } /** * Scans migration SQL for schema-destroying statements that require an * explicit user acknowledgment before apply/deploy. */ export function analyzeDestructiveMigrations(files: DestructiveMigrationFile[]): DestructiveMigrationFinding[] { const findings: DestructiveMigrationFinding[] = []; for (const file of files) { const statements = splitStatements(stripSqlComments(file.sql)); for (const statement of statements) { const kind = classifyStatement(statement); if (kind === undefined) { continue; } findings.push({ fileName: file.fileName, kind, excerpt: excerptFor(statement) }); } } return findings; }