false, 'type' => 'object', 'validate_callback' => [ self::class, 'validate_stages_param' ], 'sanitize_callback' => [ self::class, 'sanitize_stages_param' ], ]; /** @param mixed $value Raw stage configuration: an array from a JSON body, a string from a query string. */ public static function validate_stages_param( $value ): bool { if ( null === $value ) { return true; // Not sent: the route prices the full scope. } if ( is_array( $value ) ) { return true; } if ( is_string( $value ) ) { if ( '' === trim( $value ) ) { return true; // Sent empty, which prices the full scope too. } return is_array( json_decode( $value, true ) ); } return false; } /** * A string that does not decode was refused upstream; an empty array means no stages enabled. * * @param mixed $value Raw stage configuration. * * @return array */ public static function sanitize_stages_param( $value ): array { if ( is_array( $value ) ) { return $value; } if ( is_string( $value ) ) { $decoded = json_decode( $value, true ); if ( is_array( $decoded ) ) { return $decoded; } } return []; } public function register(): void { register_rest_route( 'shiftora/v1', '/migration/scannables', [ 'methods' => 'GET', 'callback' => [ $this, 'scannables' ], 'permission_callback' => [ $this->permission, 'is_admin' ], ] ); register_rest_route( 'shiftora/v1', '/migration/start', [ 'methods' => 'POST', 'callback' => [ $this, 'start' ], 'permission_callback' => [ $this->permission, 'is_admin' ], 'args' => [ 'connection_id' => [ 'required' => true, 'type' => 'integer', 'minimum' => 1, ], 'direction' => [ 'required' => false, 'type' => 'string', 'default' => 'pull', ], 'stages' => self::STAGES_ARGS, 'find_replace' => [ 'required' => false, 'type' => 'array', 'items' => [ 'type' => 'object' ], ], 'dry_run' => [ 'required' => false, 'type' => 'boolean', 'default' => false, ], ], ] ); register_rest_route( 'shiftora/v1', '/migration/(?P\d+)/status', [ 'methods' => 'GET', 'callback' => [ $this, 'status' ], 'permission_callback' => [ $this->permission, 'is_admin' ], 'args' => [ 'id' => [ 'required' => true, 'type' => 'integer', 'minimum' => 1, ], ], ] ); register_rest_route( 'shiftora/v1', '/migration/(?P\d+)/logs', [ 'methods' => 'GET', 'callback' => [ $this, 'logs' ], 'permission_callback' => [ $this->permission, 'is_admin' ], 'args' => [ 'id' => [ 'required' => true, 'type' => 'integer', 'minimum' => 1, ], 'limit' => [ 'required' => false, 'type' => 'integer', 'default' => 80, 'minimum' => 1, 'maximum' => 500, ], ], ] ); register_rest_route( 'shiftora/v1', '/migration/(?P\d+)/cancel', [ 'methods' => 'POST', 'callback' => [ $this, 'cancel' ], 'permission_callback' => [ $this->permission, 'is_admin' ], 'args' => [ 'id' => [ 'required' => true, 'type' => 'integer', 'minimum' => 1, ], ], ] ); // Carries no session: the page that calls it has just lost its own, because the // pull replaced the accounts. The one-time secret issued at start is the permission. register_rest_route( 'shiftora/v1', '/migration/(?P\d+)/reclaim-session', [ 'methods' => 'POST', 'callback' => [ $this, 'reclaim_session' ], 'permission_callback' => [ $this, 'authorize_reclaim' ], 'args' => [ 'id' => [ 'required' => true, 'type' => 'integer', 'minimum' => 1, ], 'handoff' => [ 'required' => true, 'type' => 'string', 'pattern' => '^[0-9a-f]{64}$', ], ], ] ); register_rest_route( 'shiftora/v1', '/migration/(?P\d+)/pause', [ 'methods' => 'POST', 'callback' => [ $this, 'pause' ], 'permission_callback' => [ $this->permission, 'is_admin' ], 'args' => [ 'id' => [ 'required' => true, 'type' => 'integer', 'minimum' => 1, ], ], ] ); register_rest_route( 'shiftora/v1', '/migration/(?P\d+)/resume', [ 'methods' => 'POST', 'callback' => [ $this, 'resume' ], 'permission_callback' => [ $this->permission, 'is_admin' ], 'args' => [ 'id' => [ 'required' => true, 'type' => 'integer', 'minimum' => 1, ], ], ] ); register_rest_route( 'shiftora/v1', '/migration/force-stop', [ 'methods' => 'POST', 'callback' => [ $this, 'force_stop' ], 'permission_callback' => [ $this->permission, 'is_admin' ], 'args' => [ 'connection_id' => [ 'required' => true, 'type' => 'integer', 'minimum' => 1, ], ], ] ); register_rest_route( 'shiftora/v1', '/migration/remote-tables', [ 'methods' => 'GET', 'callback' => [ $this, 'remote_tables' ], 'permission_callback' => [ $this->permission, 'is_admin' ], 'args' => [ 'connection_id' => [ 'required' => true, 'type' => 'integer', 'minimum' => 1, ], 'include_uploads_size' => [ 'required' => false, 'type' => 'boolean', 'default' => false, ], 'include_scannables' => [ 'required' => false, 'type' => 'boolean', 'default' => false, ], ], ] ); } public function scannables( WP_REST_Request $request ): WP_REST_Response { $scanner = new SiteScanner(); return new WP_REST_Response( $scanner->scan(), 200 ); } public function start( WP_REST_Request $request ): WP_REST_Response|WP_Error { $connection_id = (int) $request->get_param( 'connection_id' ); $direction_str = (string) ( $request->get_param( 'direction' ) ?? MigrationDirection::Pull->value ); $stages = $request->get_param( 'stages' ); $stages = is_array( $stages ) ? $stages : []; $dry_run = (bool) $request->get_param( 'dry_run' ); // Checked here rather than by the route's schema so a client still sending a push // reads what to do instead of a bare invalid-parameter error. if ( MigrationDirection::Pull->value !== $direction_str ) { return new WP_Error( 'shiftora_invalid_direction', __( 'This site only pulls. To send this site somewhere, log into the destination and pull.', 'shiftora' ), [ 'status' => 400 ] ); } // Enforced here as well as in discovery: a client posting to /migration/start // directly skips the wizard's preflight. Only this site is checked; the // remote's shape arrives with the table list. $multisite_refusal = $this->multisite_refusal(); if ( null !== $multisite_refusal ) { return $multisite_refusal; } // The stage list says what runs. An absent database stage means enabled. $has_database = empty( $stages ) || ! isset( $stages['database'] ) || ! empty( $stages['database']['enabled'] ); $has_file_stages = false; foreach ( FileStages::keys() as $key ) { if ( ! empty( $stages[ $key ]['enabled'] ) ) { $has_file_stages = true; break; } } if ( ! $has_database && ! $has_file_stages ) { return new WP_Error( 'shiftora_no_tables', __( 'Select at least one table or stage to migrate.', 'shiftora' ), [ 'status' => 400 ] ); } try { $custom_rules = CustomRuleValidator::validate( $request->get_param( 'find_replace' ) ); } catch ( \InvalidArgumentException $e ) { return new WP_Error( 'shiftora_find_replace_invalid', $e->getMessage(), [ 'status' => 400 ] ); } /** * Filters whether a start request is refused. Return a WP_Error to refuse it; * the request is the place to read any fields of your own before the run is * set up. * * @param WP_Error|null $error Null: carry on. * @param WP_REST_Request $request The start request, unvalidated beyond core's schema. */ $refusal = apply_filters( 'shiftora_migration_start_error', null, $request ); if ( $refusal instanceof WP_Error ) { return $refusal; } try { $direction = MigrationDirection::Pull; global $wpdb; $active_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $wpdb->prepare( "SELECT id FROM %i WHERE connection_id = %d AND status IN ('running', 'pending', 'paused') ORDER BY id DESC LIMIT 1", $wpdb->prefix . 'shiftora_migrations', $connection_id ) ); if ( $active_id ) { return new WP_Error( 'shiftora_migration_active', sprintf( /* translators: %d = migration ID */ __( 'A migration is already active for this connection (ID %d). Cancel or wait for it to finish before starting another.', 'shiftora' ), (int) $active_id ), [ 'status' => 409 ] ); } $engine = $this->build_engine(); $record = $engine->initialise( $connection_id, $direction, $stages, $dry_run, $custom_rules ); /** * Fires once the migration row exists and before its worker is scheduled, * so an extension can attach per-run options taken from the request. * * @param int $migration_id The new migration. * @param string $direction Always 'pull'. * @param WP_REST_Request $request The start request, unvalidated beyond core's schema. */ do_action( 'shiftora_migration_initialised', (int) $record->id, $direction->value, $request ); $scheduled = MigrationJob::schedule( $record->id ); if ( ! $scheduled ) { $engine->fail( $record->id, 'Action Scheduler is unavailable. Migration cannot start without a background worker.' ); return new WP_Error( 'shiftora_scheduler_unavailable', __( 'Migration could not start: the background task scheduler is unavailable.', 'shiftora' ), [ 'status' => 503 ] ); } // Run a few chunks now so the first status poll shows progress and a heartbeat. MigrationJob::kick_now( $record->id, 3 ); } catch ( \RuntimeException $e ) { $remote_error = RemoteErrorExplainer::to_wp_error( $e, $connection_id ); if ( null !== $remote_error ) { ServerLog::write( sprintf( 'Shiftora start(): remote failure for connection %d, %s', $connection_id, $e->getMessage() ) ); return $remote_error; } $message = $e->getMessage(); if ( str_starts_with( $message, 'Preflight failed: ' ) ) { $preflight_msg = substr( $message, 18 ); ServerLog::write( sprintf( 'Shiftora start(): preflight failed for connection %d (direction=%s, file_stages=%s), %s', $connection_id, $direction_str, $has_file_stages ? 'yes' : 'no', $preflight_msg ) ); return new WP_Error( 'shiftora_preflight_failed', $preflight_msg, [ 'status' => 422 ] ); } ServerLog::write( sprintf( 'Shiftora start(): RuntimeException for connection %d (direction=%s, file_stages=%s), %s', $connection_id, $direction_str, $has_file_stages ? 'yes' : 'no', $message ) ); $details = $this->normalize_error( $e, [ 'stage' => 'start', 'connection_id' => $connection_id ] ); return new WP_Error( 'shiftora_migration_error', (string) $details['user_message'], [ 'status' => 422, 'error_data' => $details ] ); } finally { /** * Fires when a start request that passed the refusal filter is done, however * it ended, so a long-lived process can drop what it kept for the request. * * @param WP_REST_Request $request The start request. */ do_action( 'shiftora_migration_start_finished', $request ); } $response_data = [ 'migration_id' => $record->id ]; if ( [] === $record->tables() && $has_file_stages ) { $response_data['file_only_migration'] = true; } // A pull over the account tables signs the operator out when it lands; this // lets their page sign back in as the same username (SessionHandoff). if ( SessionHandoff::replaces_accounts( $record->tables() ) ) { $handoff = SessionHandoff::issue( (int) $record->id, (string) wp_get_current_user()->user_login ); if ( null !== $handoff ) { $response_data['session_handoff'] = $handoff; } } return new WP_REST_Response( $response_data, 201 ); } public function status( WP_REST_Request $request ): WP_REST_Response|WP_Error { $id = (int) $request->get_param( 'id' ); global $wpdb; // Explicit columns keep the per-file offset blobs off the wire; only debug builds read them, in a second query. // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $row = $wpdb->get_row( $wpdb->prepare( 'SELECT id, connection_id, direction, status, total_rows, processed_rows, tables_json, find_replace_json, stages_json, staged_tables_json, dry_run, last_heartbeat_at, error_message, created_at, updated_at, recovery_status, recovery_error, remote_finalize_status, remote_finalize_tables_json, total_files, transferred_files, bytes_total, bytes_transferred, current_stage, stage_progress, total_tables, processed_tables, stage_plan_json, completed_stages_json, `transfer_uuid`, `backup_id` FROM %i WHERE id = %d', $wpdb->prefix . 'shiftora_migrations', $id ) ); if ( $row && $this->dev_debug_enabled() ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $offsets = $wpdb->get_row( $wpdb->prepare( 'SELECT file_offsets_json, table_offsets_json FROM %i WHERE id = %d', $wpdb->prefix . 'shiftora_migrations', $id ), ARRAY_A ); foreach ( (array) $offsets as $column => $value ) { $row->{$column} = $value; } } if ( ! $row ) { return new WP_Error( 'shiftora_not_found', "Migration #{$id} not found.", [ 'status' => 404 ] ); } $record = \Shiftora\Migration\MigrationRecord::from_row( $row ); $heartbeat_age = $this->heartbeat_age_seconds( $record->last_heartbeat_at ); $stall_detected = $record->status->value === 'running' && $record->progress_percent() === 0 && empty( $record->current_stage ) && null !== $heartbeat_age && $heartbeat_age >= 30; $error_details = ! empty( $record->error_message ) ? $this->normalize_error( $record->error_message, [ 'stage' => $record->current_stage ?? 'migration', 'migration_id' => $id, ] ) : null; $recovery_guidance = ( $record->status === MigrationStatus::Failed && null !== $error_details ) ? RecoveryGuidance::from_error_and_record( $error_details, $record ) : null; $debug_payload = $this->dev_debug_enabled() ? $this->build_debug_payload( $record ) : null; $response = [ 'status' => $record->status->value, 'status_label' => $record->status->label(), 'progress' => $record->progress_percent(), 'current_stage' => $record->current_stage, 'stage_progress' => $record->current_stage_display_progress(), 'started_at_ms' => $this->timestamp_ms( $record->created_at ), 'finished_at_ms' => $record->status->is_terminal() ? $this->timestamp_ms( $record->updated_at ) : 0, 'error_message' => $record->error_message, 'error_details' => $error_details, 'recovery_status' => $record->recovery_status, 'recovery_error' => $record->recovery_error, 'recovery_guidance' => $recovery_guidance, 'heartbeat_age' => $heartbeat_age, 'stall_detected' => $stall_detected, 'total_files' => $record->total_files, 'transferred_files' => $record->transferred_files, 'bytes_total' => $record->bytes_total, 'bytes_transferred' => $record->bytes_transferred, 'stage_plan' => $record->stage_plan(), 'completed_stages' => $record->completed_stages(), ]; if ( null !== $debug_payload ) { $response['_debug'] = $debug_payload; } $scope = $this->pull_scope_disclosure( $record ); if ( null !== $scope ) { $response = array_merge( $response, $scope ); } /** * Filters the status payload, so an extension can report on work it did. * * @param array $response The payload. * @param \Shiftora\Migration\MigrationRecord $record The migration. */ $response = (array) apply_filters( 'shiftora_migration_status', $response, $record ); return new WP_REST_Response( $response, 200 ); } /** * Tables the source omitted keep their old rows, and a later push would send them over * the source's real data, which a row count doesn't reveal. Only the finalize receipt * proves the rename ran, so it caps the transferred set. Null unless this is a * completed pull with a database stage. * * @return array{transferred_tables:string[],untouched_tables:string[]}|null */ private function pull_scope_disclosure( \Shiftora\Migration\MigrationRecord $record ): ?array { if ( MigrationDirection::Pull !== $record->direction || MigrationStatus::Completed !== $record->status ) { return null; } $planned = array_values( array_unique( array_map( 'strval', (array) $record->tables() ) ) ); if ( [] === $planned ) { return null; } $committed = $this->committed_table_count( $record ); $transferred = $committed > 0 ? array_slice( $planned, 0, $committed ) : []; $exporter = new DatabaseExporter( $GLOBALS['wpdb'] ); $eligible = $exporter->get_wp_tables(); // A pull carries bare names and this site holds prefixed ones; compare bare to bare. $touched = array_map( [ $exporter, 'strip_prefix' ], $transferred ); $untouched = array_values( array_filter( $eligible, static function ( string $table ) use ( $exporter, $touched ): bool { return ! in_array( $exporter->strip_prefix( $table ), $touched, true ); } ) ); return [ 'transferred_tables' => $transferred, 'untouched_tables' => $untouched, ]; } /** * Tables the finalize receipt says were committed, or 0 without one. A read failure * answers 0 so the status poll doesn't 500; "nothing replaced" is the safe reading. */ private function committed_table_count( \Shiftora\Migration\MigrationRecord $record ): int { $importer = new DatabaseImporter( $GLOBALS['wpdb'] ); try { return (int) $this->committed_receipt_rows( $importer, $record ); } catch ( \Throwable $e ) { return 0; } } /** @return int|null Receipt value, or null when nothing is committed. */ private function committed_receipt_rows( DatabaseImporter $importer, \Shiftora\Migration\MigrationRecord $record ): ?int { // Read the scope the writer used. A record with no identity has its receipt in // the '' namespace; one with an identity never reads ''. $uuid = (string) ( $record->transfer_uuid ?? '' ); return $this->finalize_receipt_value( $importer, $record, $uuid ); } private function finalize_receipt_value( DatabaseImporter $importer, \Shiftora\Migration\MigrationRecord $record, string $transfer_uuid ): ?int { return $importer->committed_finalize_tables( $record->id, $transfer_uuid ); } /** @param string|null $heartbeat_at Last heartbeat, a UTC mysql datetime. */ private function heartbeat_age_seconds( ?string $heartbeat_at ): ?int { if ( empty( $heartbeat_at ) ) { return null; } // Stored as GMT. Without the explicit zone strtotime() uses PHP's default // timezone, which another plugin can change, skewing every heartbeat. $ts = strtotime( $heartbeat_at . ' UTC' ); if ( false === $ts ) { return null; } return max( 0, time() - $ts ); } private function timestamp_ms( ?string $datetime ): int { if ( empty( $datetime ) ) { return 0; } $timestamp = strtotime( $datetime ); return false === $timestamp ? 0 : (int) $timestamp * 1000; } public function logs( WP_REST_Request $request ): WP_REST_Response { $id = (int) $request->get_param( 'id' ); $limit = (int) $request->get_param( 'limit' ); $logger = new MigrationLogger( $id ); $entries = $logger->get_entries( $limit ); return new WP_REST_Response( [ 'logs' => $entries ], 200 ); } public function cancel( WP_REST_Request $request ): WP_REST_Response|WP_Error { $id = (int) $request->get_param( 'id' ); try { $this->build_engine()->cancel( $id ); } catch ( \RuntimeException $e ) { return new WP_Error( 'shiftora_not_found', $e->getMessage(), [ 'status' => 404 ] ); } return new WP_REST_Response( [ 'cancelled' => true ], 200 ); } /** The secret issued to the page that started this migration, still unspent and current. */ public function authorize_reclaim( WP_REST_Request $request ): bool|WP_Error { if ( SessionHandoff::verify( (int) $request->get_param( 'id' ), (string) $request->get_param( 'handoff' ) ) ) { return true; } return new WP_Error( 'shiftora_forbidden', __( 'This page cannot be signed back in. Sign in again to see the migration.', 'shiftora' ), [ 'status' => 403 ] ); } public function reclaim_session( WP_REST_Request $request ): WP_REST_Response { return new WP_REST_Response( SessionHandoff::claim( (int) $request->get_param( 'id' ), (string) $request->get_param( 'handoff' ) ), 200 ); } public function pause( WP_REST_Request $request ): WP_REST_Response|WP_Error { $id = (int) $request->get_param( 'id' ); try { $this->build_engine()->pause( $id ); } catch ( \RuntimeException $e ) { return new WP_Error( 'shiftora_migration_error', $e->getMessage(), [ 'status' => 422 ] ); } return new WP_REST_Response( [ 'paused' => true ], 200 ); } public function resume( WP_REST_Request $request ): WP_REST_Response|WP_Error { $id = (int) $request->get_param( 'id' ); try { $this->build_engine()->resume( $id ); } catch ( \RuntimeException $e ) { return new WP_Error( 'shiftora_migration_error', $e->getMessage(), [ 'status' => 422 ] ); } return new WP_REST_Response( [ 'resumed' => true ], 200 ); } /** Cancels every active migration for one connection. */ public function force_stop( WP_REST_Request $request ): WP_REST_Response { global $wpdb; $connection_id = (int) $request->get_param( 'connection_id' ); $ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $wpdb->prepare( "SELECT id FROM %i WHERE connection_id = %d AND status IN ('running', 'pending', 'paused')", $wpdb->prefix . 'shiftora_migrations', $connection_id ) ); $stopped = 0; $engine = $this->build_engine(); foreach ( $ids as $id ) { $id = (int) $id; try { $engine->cancel( $id ); if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( 'shiftora_process_file_chunk', [ $id ], 'shiftora' ); } ++$stopped; } catch ( \RuntimeException $e ) { // An invalid row must not stop the others. } } return new WP_REST_Response( [ 'success' => true, 'stopped' => $stopped, 'connection_id' => $connection_id, ], 200 ); } /** * Server-side proxy for the remote table list (the browser can't call it because of CORS). * * Only the two scan flags are forwarded: more query parameters would change * the string the signature covers. */ public function remote_tables( WP_REST_Request $request ): WP_REST_Response|WP_Error { $connection_id = (int) $request->get_param( 'connection_id' ); $include_uploads_size = (bool) $request->get_param( 'include_uploads_size' ); $include_scannables = (bool) $request->get_param( 'include_scannables' ); $connection = $this->connections->find_by_id( $connection_id ); if ( ! $connection ) { return new WP_Error( 'shiftora_not_found', 'Connection not found.', [ 'status' => 404 ] ); } $route_path = '/shiftora/v1/export/tables'; $params = []; if ( $include_uploads_size ) { $params['include_uploads_size'] = 'true'; } if ( $include_scannables ) { $params['include_scannables'] = 'true'; } $query = ! empty( $params ) ? '?' . http_build_query( $params ) : ''; $full_url = rtrim( $connection->remote_url, '/' ) . '/wp-json' . $route_path . $query; $headers = $this->request_authenticator->create_auth_headers_for_connection( $connection, 'GET', $route_path . $query ); try { $response = $this->http->get( $full_url, [ ...$headers, ] ); } catch ( \RuntimeException $e ) { $error_msg = $e->getMessage(); ServerLog::write( sprintf( '[Shiftora] remote_tables failed for connection %d, %s', $connection_id, $error_msg ) ); $remote_error = RemoteErrorExplainer::to_wp_error( $e, $connection_id ); if ( null !== $remote_error ) { return $remote_error; } return new WP_Error( 'shiftora_remote_error', sprintf( /* translators: %s: underlying HTTP error message */ __( 'Could not fetch tables from remote site: %s', 'shiftora' ), $error_msg ), [ 'status' => 502, 'connection_id' => $connection_id, 'remote_url' => (string) ( $connection->remote_url ?? '' ), ] ); } $data = json_decode( $response['body'], true ); if ( ! is_array( $data ) || ! isset( $data['tables'] ) ) { ServerLog::write( sprintf( '[Shiftora] remote_tables unexpected response from connection %d, body: %s', $connection_id, substr( $response['body'] ?? '', 0, 500 ) ) ); return new WP_Error( 'shiftora_remote_error', __( 'Unexpected response from remote site.', 'shiftora' ), [ 'status' => 502 ] ); } $data['remote_url'] = (string) ( $connection->remote_url ?? '' ); // Refused in the wizard's preflight, before anything is selected or measured. // `shape` is already in $data. $refusal = $this->multisite_refusal( $data ); if ( null !== $refusal ) { return $refusal; } return new WP_REST_Response( $data, 200 ); } // Private helpers /** * Returns the WP_Error rather than a bool so the reason can't be reported differently * from the refusal itself. * * @param array $remote_data The remote's decoded response. */ private function multisite_refusal( array $remote_data = [] ): ?WP_Error { $remote_is_multisite = (bool) ( $remote_data['shape']['is_multisite'] ?? false ); $permitted = MultisiteRefusal::permitted( is_multisite(), $remote_is_multisite ); if ( $permitted ) { return null; } return new WP_Error( MultisiteRefusal::code(), MultisiteRefusal::message(), [ 'status' => 403 ] ); } private function build_engine(): \Shiftora\Migration\MigrationEngine { if ( is_callable( $this->engine_factory ) ) { $engine = ( $this->engine_factory )(); if ( $engine instanceof \Shiftora\Migration\MigrationEngine ) { return $engine; } } return MigrationEngineFactory::make(); } /** @return array */ private function normalize_error( string|\Throwable|WP_Error $error, array $context = [] ): array { return ( new ErrorReporter() )->normalize( $error, $context ); } /** Delegates so the status route, log table and browser console agree on verbose output. */ private function dev_debug_enabled(): bool { return ShiftoraDebug::enabled(); } /** * @param mixed[] $extra * @return mixed[] */ private function build_debug_payload( \Shiftora\Migration\MigrationRecord $record, array $extra = [] ): array { $file_offsets = []; $table_offsets = []; if ( ! empty( $record->file_offsets_json ) ) { $decoded = json_decode( $record->file_offsets_json, true ); if ( is_array( $decoded ) ) { $file_offsets = $decoded; } } if ( ! empty( $record->table_offsets_json ) ) { $decoded = json_decode( $record->table_offsets_json, true ); if ( is_array( $decoded ) ) { $table_offsets = $decoded; } } $file_done = count( array_filter( $file_offsets, static fn( $m ): bool => ! empty( $m['done'] ) && empty( $m['failed'] ) ) ); $file_total = count( $file_offsets ); $table_done = count( array_filter( $table_offsets, static fn( $m ): bool => ! empty( $m['done'] ) ) ); $table_total = count( $table_offsets ); $payload = [ 'current_stage' => $record->current_stage, 'status' => $record->status->value, 'stage_progress' => $record->current_stage_display_progress(), 'overall_progress' => $record->progress_percent(), 'counters' => [ 'total_files' => $record->total_files, 'transferred_files' => $record->transferred_files, 'bytes_total' => $record->bytes_total, 'bytes_transferred' => $record->bytes_transferred, 'processed_tables' => $record->processed_tables, 'processed_rows' => $record->processed_rows, ], 'offset_snapshot' => [ 'file_offsets_total' => $file_total, 'file_offsets_done' => $file_done, 'table_offsets_total'=> $table_total, 'table_offsets_done' => $table_done, ], 'stage_plan' => $record->stage_plan(), 'completed_stages' => $record->completed_stages(), 'counter_mismatch_flags' => [ 'files_exceed_total' => $record->transferred_files > $record->total_files, 'bytes_exceed_total' => $record->bytes_total > 0 && $record->bytes_transferred > $record->bytes_total, 'offset_file_exceed' => $file_total > 0 && $file_done > $file_total, ], 'last_heartbeat_at' => $record->last_heartbeat_at, 'updated_at' => $record->updated_at, 'extra' => $extra, ]; return $payload; } }