Files
rspade_system/app/RSpade/Commands/Migrate/Maint_Migrate.php
root a5e1c604ab Framework updates
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 04:33:43 +00:00

765 lines
26 KiB
PHP

<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Commands\Migrate;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Symfony\Component\Console\Output\BufferedOutput;
use Illuminate\Database\Console\Migrations\MigrateCommand;
use App\Providers\AppServiceProvider;
use App\RSpade\Core\Database\MigrationValidator;
use App\RSpade\Core\Database\SqlQueryTransformer;
use App\RSpade\Core\Rsx;
use App\RSpade\SchemaQuality\SchemaQualityChecker;
/**
* Unified migration command with mode-aware behavior
*
* In DEVELOPMENT mode:
* - Automatically creates database snapshot before migrations
* - Runs migrations with validation and normalization
* - On success: commits changes, removes snapshot, regenerates constants/bundles
* - On failure: automatically rolls back to snapshot and exits migration mode
*
* In DEBUG/PRODUCTION mode:
* - Runs migrations without snapshot protection
* - Runs schema normalization
* - Does NOT update source code (constants, bundles) - source is read-only
*
* This command is automatically used when running 'php artisan migrate' due to
* a modification in the artisan script.
*/
class Maint_Migrate extends Command
{
use PrivilegedCommandTrait;
protected $signature = 'migrate {--force} {--seed} {--step} {--path=*} {--framework-only : Run only framework migrations (system/database/migrations)}';
protected $description = 'Run migrations with automatic snapshot protection in development mode';
protected $flag_file = '/var/www/html/.migrating';
protected $mysql_data_dir = '/var/lib/mysql';
protected $backup_dir = '/var/lib/mysql_backup';
public function handle()
{
// Determine mode
$is_development = Rsx::is_development();
$is_framework_only = $this->option('framework-only');
// In development mode, use snapshot strategy (unless framework-only)
$use_snapshot = $is_development && !$is_framework_only;
if ($use_snapshot) {
return $this->run_with_snapshot();
} else {
return $this->run_without_snapshot();
}
}
/**
* Run migrations with automatic snapshot protection (development mode)
*/
protected function run_with_snapshot(): int
{
// Check if running in Docker environment (required for snapshots)
if (!file_exists('/.dockerenv')) {
$this->error('[ERROR] Snapshot-based migrations require Docker environment!');
$this->info('In non-Docker development, set RSX_MODE=debug to run without snapshots.');
return 1;
}
$this->info(' Development mode: Using automatic snapshot protection');
$this->info('');
// Step 1: Create snapshot (migrate:begin logic)
$this->info('[1/4] Creating database snapshot...');
if (!$this->create_snapshot()) {
return 1;
}
// Step 2: Run migrations
$this->info('');
$this->info('[2/4] Running migrations...');
$migration_result = $this->execute_migrations();
if ($migration_result !== 0) {
// Migration failed - rollback and exit migration mode
$this->error('');
$this->error('[ERROR] Migration failed!');
$this->warn(' Automatically rolling back to snapshot...');
$this->info('');
$this->rollback_snapshot();
$this->cleanup_migration_mode();
$this->info('');
$this->info('[OK] Database restored to pre-migration state.');
$this->info('');
$this->line('Fix your migration files and run "php artisan migrate" again.');
return 1;
}
// Step 3: Run schema quality check
$this->info('');
$this->info('[3/4] Running schema quality check...');
$checker = new SchemaQualityChecker();
$checker->check();
if ($checker->has_violations()) {
$this->error('[ERROR] Schema standards check failed with ' . $checker->get_violation_count() . ' violation(s):');
$this->info('');
// Display violations
$grouped = $checker->get_violations_by_severity();
foreach (['critical', 'high', 'medium', 'low'] as $severity) {
if (!empty($grouped[$severity])) {
$this->line(strtoupper($severity) . ' VIOLATIONS:');
foreach ($grouped[$severity] as $violation) {
$this->line($violation->format_output());
}
}
}
$this->info('');
$this->warn(' Rolling back due to schema violations...');
$this->rollback_snapshot();
$this->cleanup_migration_mode();
$this->info('');
$this->warn('[WARNING] Migration has been rolled back. Fix the schema violations and try again.');
return 1;
}
$this->info('[OK] Schema quality check passed.');
// Step 4: Commit - cleanup snapshot and run post-migration tasks
$this->info('');
$this->info('[4/4] Committing changes...');
$this->commit_snapshot();
// Run post-migration tasks (development only)
$this->info('');
$this->info('Running post-migration tasks...');
// Regenerate model constants
$this->call('rsx:constants:regenerate');
// Recompile bundles
$this->newLine();
$this->info('Recompiling bundles...');
passthru('php artisan rsx:bundle:compile');
$this->info('');
$this->info('[OK] Migration completed successfully!');
return 0;
}
/**
* Run migrations without snapshot protection (debug/production mode)
*/
protected function run_without_snapshot(): int
{
$mode_label = Rsx::get_mode_label();
$is_framework_only = $this->option('framework-only');
if ($is_framework_only) {
$this->info(" Framework-only migrations (no snapshot protection)");
} else {
$this->info(" {$mode_label} mode: Running without snapshot protection");
}
$this->info(' Source code is read-only - constants/bundles will not be regenerated.');
$this->info('');
// Run migrations
$migration_result = $this->execute_migrations();
if ($migration_result !== 0) {
$this->error('');
$this->error('[ERROR] Migration failed!');
$this->warn('[WARNING] No snapshot available - database may be in inconsistent state.');
return 1;
}
// In debug/production mode, check manifest consistency with database
if (!$is_framework_only) {
AppServiceProvider::disable_query_echo();
$this->info('');
$consistency_check_exit = $this->call('rsx:migrate:check_consistency');
if ($consistency_check_exit !== 0) {
$this->warn('[WARNING] Manifest-database consistency check failed.');
$this->warn('Source code may be out of sync with database schema.');
}
}
$this->info('');
$this->info('[OK] Migration completed!');
return 0;
}
/**
* Execute the actual migration process
*/
protected function execute_migrations(): int
{
// Enable SQL query transformation for migrations
SqlQueryTransformer::enable();
$this->register_query_transformer();
// Enable full query logging to stdout for migrations
AppServiceProvider::set_query_log_mode(AppServiceProvider::QUERY_LOG_ALL_STDOUT);
// Ensure migrations table exists (create it if needed)
$this->ensure_migrations_table_exists();
$is_framework_only = $this->option('framework-only');
$is_development = Rsx::is_development();
// Get all the options
$force = $this->option('force');
$seed = $this->option('seed');
$step = $this->option('step');
$paths = $this->option('path');
// Check if path option is used and throw exception
if (!empty($paths)) {
$this->error('[ERROR] Migration by path is disabled!');
$this->error('');
$this->line('This command enforces running all pending migrations in order.');
$this->line('Please run migrations without the --path option.');
SqlQueryTransformer::disable();
return 1;
}
// Determine which migration paths to use for whitelist check
$paths_to_check = $is_framework_only
? [database_path('migrations')]
: MigrationPaths::get_all_paths();
// Check migration whitelist
if (!$this->checkMigrationWhitelist($paths_to_check)) {
SqlQueryTransformer::disable();
return 1;
}
// Validate migration files for Schema builder usage (only in development)
if ($is_development && !$this->_validate_schema_rules()) {
SqlQueryTransformer::disable();
return 1;
}
// Run normalize_schema BEFORE migrations to fix existing tables
$requiredColumnsArgs = $is_development ? [] : ['--production' => true];
$this->info("\n Pre-migration normalization (fixing existing tables)...\n");
$normalizeExitCode = $this->call('migrate:normalize_schema', $requiredColumnsArgs);
if ($normalizeExitCode !== 0) {
$this->error('Pre-migration normalization failed');
SqlQueryTransformer::disable();
return $normalizeExitCode;
}
// Use a buffered output to capture migration output
$bufferedOutput = new BufferedOutput();
try {
// Run the standard migrations and capture the output
$originalSqlMode = DB::selectOne('SELECT @@sql_mode as sql_mode')->sql_mode;
DB::statement("SET sql_mode = REPLACE(@@sql_mode, 'NO_ZERO_DATE', '');");
// Run migrations directly via migrator to avoid recursion
$migrator = app('migrator');
$migrator->setOutput($bufferedOutput);
// Use all migration paths (framework + user), or just framework if --framework-only
$migrationPaths = $is_framework_only
? [database_path('migrations')]
: MigrationPaths::get_all_paths();
// Run migrations one-by-one with normalization after each
$this->run_migrations_with_normalization($migrator, $migrationPaths, $step, $requiredColumnsArgs);
$exitCode = 0;
// Handle seeding if requested
if ($seed) {
$this->call('db:seed', ['--force' => $force]);
}
$migrationOutput = $bufferedOutput->fetch();
// Show the output from the migration command
$this->output->write($migrationOutput);
DB::statement('SET sql_mode = ?', [$originalSqlMode]);
} catch (\Exception $e) {
// Restore SQL mode
try {
if (isset($originalSqlMode)) {
DB::statement('SET sql_mode = ?', [$originalSqlMode]);
}
} catch (\Exception $sqlEx) {
// Ignore SQL mode restore errors
}
$this->error('');
$this->error('Error: ' . $e->getMessage());
// Disable query logging before returning
AppServiceProvider::disable_query_echo();
SqlQueryTransformer::disable();
return 1;
}
// Run normalize_schema AFTER migrations to add framework columns to new tables
$this->info("\n Post-migration normalization (adding framework columns to new tables)...\n");
// Switch to destructive-only query logging for normalize_schema
AppServiceProvider::set_query_log_mode(AppServiceProvider::QUERY_LOG_DESTRUCTIVE_STDOUT);
$normalizeExitCode = $this->call('migrate:normalize_schema', $requiredColumnsArgs);
if ($normalizeExitCode !== 0) {
$this->error('Post-migration normalization failed');
SqlQueryTransformer::disable();
return $normalizeExitCode;
}
// Disable query logging
AppServiceProvider::disable_query_echo();
SqlQueryTransformer::disable();
return $exitCode;
}
/**
* Create a database snapshot
*/
protected function create_snapshot(): bool
{
// Check if already in migration mode (shouldn't happen with new unified command)
if (file_exists($this->flag_file)) {
// Clean up stale migration mode
$this->warn('[WARNING] Found stale migration mode flag. Cleaning up...');
$this->cleanup_migration_mode();
}
try {
// Stop MySQL
$this->shell_exec_privileged('supervisorctl stop mysql 2>&1');
sleep(3);
// Remove old backup if exists
if (is_dir($this->backup_dir)) {
$this->run_privileged_command(['rm', '-rf', $this->backup_dir]);
}
// Copy MySQL data directory
$this->run_privileged_command(['cp', '-r', $this->mysql_data_dir, $this->backup_dir]);
// Start MySQL
$this->shell_exec_privileged('mkdir -p /var/run/mysqld');
$this->shell_exec_privileged('supervisorctl start mysql 2>&1');
// Wait for MySQL to be ready
$this->wait_for_mysql_ready();
// Create migration flag file
file_put_contents($this->flag_file, json_encode([
'started_at' => now()->toIso8601String(),
'started_by' => get_current_user(),
'backup_dir' => $this->backup_dir,
], JSON_PRETTY_PRINT));
$this->info('[OK] Snapshot created successfully.');
return true;
} catch (\Exception $e) {
$this->error('[ERROR] Failed to create snapshot: ' . $e->getMessage());
// Try to restart MySQL
$this->shell_exec_privileged('supervisorctl start mysql 2>&1');
return false;
}
}
/**
* Rollback to snapshot
*/
protected function rollback_snapshot(): bool
{
if (!is_dir($this->backup_dir)) {
$this->error('[ERROR] Backup directory not found!');
return false;
}
try {
// Stop MySQL
$this->shell_exec_privileged('supervisorctl stop mysql 2>&1');
sleep(3);
// Clear current MySQL data
$this->shell_exec_privileged("rm -rf {$this->mysql_data_dir}/* 2>/dev/null");
$this->shell_exec_privileged("rm -rf {$this->mysql_data_dir}/.* 2>/dev/null");
// Restore backup
$this->shell_exec_privileged("cp -r {$this->backup_dir}/* {$this->mysql_data_dir}/");
$this->shell_exec_privileged("cp -r {$this->backup_dir}/.[^.]* {$this->mysql_data_dir}/ 2>/dev/null");
// Fix permissions
$this->run_privileged_command(['chown', '-R', 'mysql:mysql', $this->mysql_data_dir]);
// Start MySQL
$this->shell_exec_privileged('mkdir -p /var/run/mysqld');
$this->shell_exec_privileged('supervisorctl start mysql 2>&1');
// Wait for MySQL to be ready
$this->wait_for_mysql_ready();
return true;
} catch (\Exception $e) {
$this->error('[ERROR] Rollback failed: ' . $e->getMessage());
// Try to restart MySQL
$this->shell_exec_privileged('supervisorctl start mysql 2>&1');
return false;
}
}
/**
* Commit snapshot (remove backup and flag)
*/
protected function commit_snapshot(): void
{
// Remove backup directory
if (is_dir($this->backup_dir)) {
$this->run_privileged_command(['rm', '-rf', $this->backup_dir]);
}
// Remove migration flag
if (file_exists($this->flag_file)) {
unlink($this->flag_file);
}
$this->info('[OK] Snapshot committed - backup removed.');
}
/**
* Cleanup migration mode (remove flag and backup)
*/
protected function cleanup_migration_mode(): void
{
if (is_dir($this->backup_dir)) {
$this->run_privileged_command(['rm', '-rf', $this->backup_dir]);
}
if (file_exists($this->flag_file)) {
unlink($this->flag_file);
}
}
/**
* Wait for MySQL to be ready for connections
*/
protected function wait_for_mysql_ready(): void
{
$max_attempts = 120;
$attempt = 0;
while ($attempt < $max_attempts) {
$result = shell_exec("echo \"SELECT 'test';\" | mysql -urspade -prspadepass 2>/dev/null | grep test");
if ($result !== null && str_contains($result, 'test')) {
return;
}
sleep(1);
$attempt++;
}
throw new \Exception('MySQL did not start within 120 seconds');
}
/**
* Register query transformation listener
*/
protected function register_query_transformer(): void
{
$original_statement = \Closure::bind(function () {
return $this->connection->statement(...func_get_args());
}, DB::getFacadeRoot(), DB::getFacadeRoot());
DB::macro('statement', function ($query, $bindings = []) use ($original_statement) {
$transformed = SqlQueryTransformer::transform($query);
return DB::connection()->statement($transformed, $bindings);
});
}
/**
* Check if all pending migrations are whitelisted
*/
protected function checkMigrationWhitelist(array $paths): bool
{
$whitelistPaths = [
database_path('migrations/.migration_whitelist'),
base_path('rsx/resource/migrations/.migration_whitelist'),
];
$whitelistedMigrations = [];
$foundAtLeastOne = false;
foreach ($whitelistPaths as $whitelistPath) {
if (file_exists($whitelistPath)) {
$foundAtLeastOne = true;
$whitelist = json_decode(file_get_contents($whitelistPath), true);
$migrations = array_keys($whitelist['migrations'] ?? []);
$whitelistedMigrations = array_merge($whitelistedMigrations, $migrations);
}
}
if (!$foundAtLeastOne) {
$this->warn('[WARNING] No migration whitelist found. Creating one with existing migrations...');
$this->createInitialWhitelist();
return true;
}
$migrationFiles = [];
foreach ($paths as $path) {
if (is_dir($path)) {
foreach (glob($path . '/*.php') as $file) {
$migrationFiles[] = basename($file);
}
}
}
$unauthorizedMigrations = array_diff($migrationFiles, $whitelistedMigrations);
if (!empty($unauthorizedMigrations)) {
$this->error('[ERROR] Unauthorized migrations detected!');
$this->error('');
$this->line('The following migrations were not created via php artisan make:migration:');
foreach ($unauthorizedMigrations as $migration) {
$this->line(' - ' . $migration);
}
$this->error('');
$this->warn('[WARNING] Manually created migrations can cause timestamp conflicts.');
$this->error('');
$this->line('To fix: Create migrations using "php artisan make:migration [name]"');
return false;
}
return true;
}
/**
* Create initial whitelist with existing migrations
*/
protected function createInitialWhitelist(): void
{
$whitelistLocations = [
database_path('migrations/.migration_whitelist') => database_path('migrations'),
base_path('rsx/resource/migrations/.migration_whitelist') => base_path('rsx/resource/migrations'),
];
$totalMigrations = 0;
foreach ($whitelistLocations as $whitelistPath => $migrationPath) {
if (!is_dir($migrationPath)) {
continue;
}
$whitelist = [
'description' => 'This file tracks migrations created via php artisan make:migration',
'purpose' => 'Prevents manually created migrations from running to avoid timestamp conflicts',
'migrations' => [],
];
foreach (glob($migrationPath . '/*.php') as $file) {
$filename = basename($file);
$whitelist['migrations'][$filename] = [
'created_at' => 'pre-whitelist',
'created_by' => 'system',
'command' => 'existing migration',
];
}
if (!empty($whitelist['migrations'])) {
file_put_contents($whitelistPath, json_encode($whitelist, JSON_PRETTY_PRINT));
$count = count($whitelist['migrations']);
$totalMigrations += $count;
$location = str_replace(base_path() . '/', '', dirname($whitelistPath));
$this->info("[OK] Created whitelist in {$location} with {$count} migration(s).");
}
}
if ($totalMigrations === 0) {
$this->info('[OK] No existing migrations found. Empty whitelists created.');
}
}
/**
* Validate migration files for Schema builder usage
*/
protected function _validate_schema_rules(): bool
{
$repository = app('migration.repository');
$pending_migrations = MigrationValidator::get_pending_migrations($repository);
if (empty($pending_migrations)) {
return true;
}
$this->info('Validating migration files for Schema builder usage...');
$has_violations = false;
foreach ($pending_migrations as $migration_path) {
try {
MigrationValidator::validate_migration_file($migration_path);
$this->info(" [OK] " . basename($migration_path));
} catch (\RuntimeException $e) {
$has_violations = true;
break;
}
}
if ($has_violations) {
$this->error('');
$this->error('Migration validation failed!');
$this->info('');
$this->line('Please update your migration to use raw SQL instead of Schema builder.');
$this->line('See: php artisan rsx:man migrations');
return false;
}
// Remove down() methods
$processed_migrations = [];
foreach ($pending_migrations as $migration_path) {
if (MigrationValidator::remove_down_method($migration_path)) {
$processed_migrations[] = $migration_path;
}
}
if (!empty($processed_migrations)) {
$this->info('');
$this->warn('[WARNING] Modified migration files:');
foreach ($processed_migrations as $path) {
$this->line(' - ' . basename($path) . ' - down() method removed');
}
$this->info('');
}
return true;
}
/**
* Ensure the migrations table exists
*/
protected function ensure_migrations_table_exists(): void
{
try {
$table = config('database.migrations', 'migrations');
DB::select("SELECT 1 FROM {$table} LIMIT 1");
} catch (\Exception $e) {
$table = config('database.migrations', 'migrations');
$this->info('Creating migrations table...');
DB::statement("
CREATE TABLE IF NOT EXISTS {$table} (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
migration VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
batch BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
$this->info('[OK] Migrations table created');
}
}
/**
* Run migrations one-by-one with normalization after each
*/
protected function run_migrations_with_normalization($migrator, array $migrationPaths, bool $step, array $requiredColumnsArgs): void
{
$files = [];
foreach ($migrationPaths as $path) {
if (is_dir($path)) {
$path_files = glob($path . '/*.php');
if ($path_files) {
foreach ($path_files as $file) {
$files[] = $file;
}
}
}
}
sort($files);
$repository = $migrator->getRepository();
$ran = $repository->getRan();
$pending = [];
foreach ($files as $file) {
$migrationName = $migrator->getMigrationName($file);
if (!in_array($migrationName, $ran)) {
$pending[] = $file;
}
}
if (empty($pending)) {
$this->info(' INFO Nothing to migrate.');
return;
}
$totalMigrations = count($pending);
$currentMigration = 1;
foreach ($pending as $file) {
$migrationName = $migrator->getMigrationName($file);
$this->info("\n[$currentMigration/$totalMigrations] Running: $migrationName");
$this->newLine();
$migrator->runPending([$file], [
'pretend' => false,
'step' => $step
]);
if ($currentMigration < $totalMigrations) {
$this->info("\n Normalizing schema after migration...\n");
AppServiceProvider::set_query_log_mode(AppServiceProvider::QUERY_LOG_DESTRUCTIVE_STDOUT);
$normalizeExitCode = $this->call('migrate:normalize_schema', $requiredColumnsArgs);
AppServiceProvider::set_query_log_mode(AppServiceProvider::QUERY_LOG_ALL_STDOUT);
if ($normalizeExitCode !== 0) {
throw new \Exception("Normalization failed after migration: $migrationName");
}
}
$currentMigration++;
}
$this->newLine();
$this->info("[OK] All $totalMigrations migration" . ($totalMigrations > 1 ? 's' : '') . " completed successfully");
}
}