id, connection_id: (int) $row->connection_id, // A row written when pushes existed reads as a pull; Installer retires its in-flight ones, and the history list reads the raw column. direction: MigrationDirection::Pull, status: MigrationStatus::from( (string) $row->status ), total_rows: (int) $row->total_rows, processed_rows: (int) $row->processed_rows, tables_json: isset( $row->tables_json ) ? (string) $row->tables_json : (string) ( $row->tables ?? '[]' ), find_replace_json: isset( $row->find_replace_json ) ? (string) $row->find_replace_json : ( isset( $row->find_replace_rules ) ? (string) $row->find_replace_rules : null ), stages_json: isset( $row->stages_json ) ? (string) $row->stages_json : null, table_offsets_json: isset( $row->table_offsets_json ) ? (string) $row->table_offsets_json : null, staged_tables_json: isset( $row->staged_tables_json ) ? (string) $row->staged_tables_json : null, dry_run: isset( $row->dry_run ) ? (bool) $row->dry_run : false, last_heartbeat_at: isset( $row->last_heartbeat_at ) ? (string) $row->last_heartbeat_at : null, error_message: isset( $row->error_message ) ? (string) $row->error_message : null, created_at: (string) $row->created_at, updated_at: (string) $row->updated_at, recovery_status: isset( $row->recovery_status ) ? (string) $row->recovery_status : null, recovery_error: isset( $row->recovery_error ) ? (string) $row->recovery_error : null, remote_finalize_status: isset( $row->remote_finalize_status ) ? (string) $row->remote_finalize_status : null, remote_finalize_tables_json: isset( $row->remote_finalize_tables_json ) ? (string) $row->remote_finalize_tables_json : null, total_files: isset( $row->total_files ) ? (int) $row->total_files : 0, transferred_files: isset( $row->transferred_files ) ? (int) $row->transferred_files : 0, bytes_total: isset( $row->bytes_total ) ? (int) $row->bytes_total : 0, bytes_transferred: isset( $row->bytes_transferred ) ? (int) $row->bytes_transferred : 0, file_offsets_json: isset( $row->file_offsets_json ) ? (string) $row->file_offsets_json : null, current_stage: isset( $row->current_stage ) ? (string) $row->current_stage : null, stage_progress: isset( $row->stage_progress ) ? (int) $row->stage_progress : 0, total_tables: isset( $row->total_tables ) ? (int) $row->total_tables : 0, processed_tables: isset( $row->processed_tables ) ? (int) $row->processed_tables : 0, stage_plan_json: isset( $row->stage_plan_json ) ? (string) $row->stage_plan_json : null, completed_stages_json: isset( $row->completed_stages_json ) ? (string) $row->completed_stages_json : null, transfer_uuid: isset( $row->transfer_uuid ) ? (string) $row->transfer_uuid : null, remote_max_post_bytes: isset( $row->remote_max_post_bytes ) && null !== $row->remote_max_post_bytes ? (int) $row->remote_max_post_bytes : null ); } /** * @return string[] */ public function tables(): array { return $this->_tables ??= (static function ( string $json ): array { $decoded = json_decode( $json, true ); return is_array( $decoded ) ? $decoded : []; })( $this->tables_json ); } /** * Find/replace rules as a raw array. * * @return array */ public function find_replace_rules(): array { return $this->_find_replace_rules ??= (static function ( ?string $json ): array { if ( empty( $json ) ) { return []; } $decoded = json_decode( $json, true ); return is_array( $decoded ) ? $decoded : []; })( $this->find_replace_json ); } /** * @return array */ public function stages(): array { return $this->_stages ??= (static function ( ?string $json ): array { if ( empty( $json ) ) { return []; } $decoded = json_decode( $json, true ); return is_array( $decoded ) ? $decoded : []; })( $this->stages_json ); } /** * The durable stage plan. * * @return array> */ public function stage_plan(): array { return $this->_stage_plan ??= (static function ( ?string $json ): array { if ( empty( $json ) ) { return []; } $decoded = json_decode( $json, true ); return is_array( $decoded ) ? array_values( array_filter( $decoded, 'is_array' ) ) : []; })( $this->stage_plan_json ); } /** * The durable completed-stage map. * * @return array */ public function completed_stages(): array { return $this->_completed_stages ??= (static function ( ?string $json ): array { if ( empty( $json ) ) { return []; } $decoded = json_decode( $json, true ); return is_array( $decoded ) ? $decoded : []; })( $this->completed_stages_json ); } /** * Ordered stage keys. Falls back to legacy stage config for older rows. * * @return string[] */ public function planned_stage_order(): array { $plan = $this->stage_plan(); if ( ! empty( $plan ) ) { return array_values( array_filter( array_map( static fn( array $stage ): string => (string) ( $stage['key'] ?? '' ), $plan ) ) ); } $stage_order = []; $stages = $this->stages(); foreach ( FileStages::keys() as $stage ) { if ( ! empty( $stages[ $stage ]['enabled'] ) ) { $stage_order[] = $stage; } } if ( ! empty( $this->tables() ) || 'database' === ( $this->current_stage ?? '' ) ) { $stage_order[] = 'database'; } return $stage_order; } /** * Progress as a percentage (0-100), weighted across the enabled stages. * * Uses the configured stage order so progress does not jump backward between file * stages. A failed or canceled run reports the work it did, matching the counters. */ public function progress_percent(): int { if ( $this->status === MigrationStatus::Completed && $this->completed_state_is_truthful() ) { return 100; } $stage_order = $this->planned_stage_order(); if ( ! empty( $stage_order ) ) { return $this->weighted_stage_progress( $stage_order ); } if ( $this->total_files > 0 || $this->bytes_total > 0 ) { return $this->current_stage_display_progress(); } return $this->database_progress_percent(); } /** * Calculate the truthful display percentage for the current stage. */ public function current_stage_display_progress(): int { $current_stage = (string) ( $this->current_stage ?? '' ); if ( 'database' === $current_stage ) { return $this->database_progress_percent(); } if ( FileStages::has( $current_stage ) || $this->total_files > 0 || $this->bytes_total > 0 ) { return $this->file_stage_progress_percent(); } return max( 0, min( 100, $this->stage_progress ) ); } /** * Determine whether a completed status is supported by the stored counters. */ private function completed_state_is_truthful(): bool { $stage_order = ! empty( $this->stage_plan() ) ? $this->planned_stage_order() : []; if ( ! empty( $stage_order ) ) { foreach ( $stage_order as $stage ) { if ( ! $this->stage_is_completed( $stage ) ) { return false; } } return true; } if ( null !== $this->current_stage && 'database' !== $this->current_stage ) { if ( $this->total_files > 0 && $this->transferred_files < $this->total_files ) { return false; } if ( $this->bytes_total > 0 && $this->bytes_transferred < $this->bytes_total ) { return false; } } if ( ! empty( $this->table_offsets_json ) && ! empty( $this->tables() ) && $this->database_progress_percent() < 100 ) { return false; } return true; } /** * Weighted progress over the active stage order. * * @param string[] $stage_order Ordered enabled stage keys. */ private function weighted_stage_progress( array $stage_order ): int { $current_stage = (string) ( $this->current_stage ?? '' ); $current_index = array_search( $current_stage, $stage_order, true ); $db_percent = $this->database_progress_percent(); if ( false === $current_index && $db_percent > 0 ) { $current_index = array_search( 'database', $stage_order, true ); } $total = 0; $count = count( $stage_order ); foreach ( $stage_order as $index => $stage ) { if ( $this->stage_is_completed( $stage ) || ( false !== $current_index && $index < $current_index ) ) { $total += 100; continue; } if ( false !== $current_index && $index === $current_index ) { $total += 'database' === $stage ? $db_percent : $this->current_stage_display_progress(); } } // Nothing in the plan could be credited, which happens when the current // stage is not among the planned ones (for example a pull that moved past // its last planned stage). The file counters hold the real progress then. if ( 0 === $total && ( $this->total_files > 0 || $this->bytes_total > 0 ) ) { return $this->file_stage_progress_percent(); } return $count > 0 ? (int) round( $total / $count ) : 0; } /** * Calculate truthful file-stage progress from the stored transfer counters. */ private function file_stage_progress_percent(): int { if ( $this->bytes_total > 0 ) { return max( 0, min( 100, (int) round( ( $this->bytes_transferred / $this->bytes_total ) * 100 ) ) ); } if ( $this->total_files > 0 ) { return max( 0, min( 100, (int) round( ( $this->transferred_files / $this->total_files ) * 100 ) ) ); } return max( 0, min( 100, $this->stage_progress ) ); } private function stage_is_completed( string $stage ): bool { $completed = $this->completed_stages(); if ( isset( $completed[ $stage ] ) ) { return true; } foreach ( $this->stage_plan() as $entry ) { if ( $stage === (string) ( $entry['key'] ?? '' ) && 'completed' === (string) ( $entry['status'] ?? '' ) ) { return true; } } return false; } /** * Database-only progress from the stored table offsets. */ private function database_progress_percent(): int { // Priority: bytes, then rows (only while actively processing), then // table count. floor() keeps progress from being overstated. if ( $this->bytes_total > 0 && $this->bytes_transferred > 0 ) { $pct = (int) floor( min( 100, ( $this->bytes_transferred / $this->bytes_total ) * 100 ) ); return max( $this->stage_progress, $pct ); } if ( $this->total_rows > 0 && $this->processed_rows > 0 ) { $pct = (int) floor( min( 100, ( $this->processed_rows / $this->total_rows ) * 100 ) ); return max( $this->stage_progress, $pct ); } // Fallback: completed table count. $tables = $this->tables(); $total = count( $tables ); if ( 0 === $total ) { return 0; } $offsets = []; if ( ! empty( $this->table_offsets_json ) ) { $decoded = json_decode( $this->table_offsets_json, true ); if ( is_array( $decoded ) ) { $offsets = $decoded; } } $done = 0; foreach ( $tables as $table ) { $entry = $offsets[ md5( $table ) ] ?? [ 'done' => false ]; if ( $entry['done'] ?? false ) { $done++; } } $pct = (int) floor( min( 100, ( $done / max( 1, $total ) ) * 100 ) ); return max( $this->stage_progress, $pct ); } }