id ); $tables = $record->tables(); if ( empty( $tables ) || $record->dry_run ) { $logger->info( 'Skipped restore point: dry-run or no tables.' ); return null; } // Backups use full prefixed names. $backup_tables = array_values( array_unique( array_map( fn( string $table ): string => str_starts_with( $table, (string) $this->wpdb->prefix ) ? $table : (string) $this->wpdb->prefix . $table, $tables ) ) ); $backup_tables = $this->backup_manager->prepare_backup_tables( $backup_tables ); if ( empty( $backup_tables ) ) { $logger->info( 'Skipped restore point: selected tables do not exist on destination yet.' ); return null; } try { $backup = $this->backup_manager->create( $record->id, $backup_tables ); // The label marks it as a rollback snapshot for retention. $this->wpdb->update( $this->wpdb->prefix . 'shiftora_backups', [ 'label' => sprintf( 'rollback-%s-%s', $record->direction->value, gmdate( 'Ymd-His' ) ), ], [ 'id' => $backup->id ], [ '%s' ], [ '%d' ] ); $logger->info( 'Restore point created.', [ 'backup_id' => $backup->id, 'tables' => count( $backup_tables ), 'size' => $backup->file_size, ] ); return $backup; } catch ( RuntimeException $e ) { $logger->error( 'Restore point creation failed: ' . $e->getMessage() ); throw $e; } } /** * Lists restore points, newest first, with migration context for display. * * @return array */ public function list_restore_points( int $limit = 50 ): array { $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT b.*, m.id AS m_id, m.connection_id, m.direction AS m_direction, m.status AS m_status, m.error_message AS m_error_message, m.created_at AS m_created_at FROM %i b LEFT JOIN %i m ON m.id = b.migration_id WHERE b.label LIKE %s ORDER BY b.created_at DESC LIMIT %d', $wpdb->prefix . 'shiftora_backups', $wpdb->prefix . 'shiftora_migrations', 'rollback-%', $limit ) ); if ( empty( $rows ) ) { return []; } $connection_ids = array_unique( array_map( fn( $r ) => (int) ( $r->connection_id ?? 0 ), $rows ) ); $connection_ids = array_filter( $connection_ids ); $connections = []; if ( $connection_ids ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $conn_rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE id IN (' . implode( ', ', array_fill( 0, count( $connection_ids ), '%d' ) ) . ')', array_merge( [ $wpdb->prefix . 'shiftora_connections' ], array_values( $connection_ids ) ) ) ); foreach ( $conn_rows ?? [] as $cr ) { $connections[ (int) $cr->id ] = $cr; } } $results = []; foreach ( $rows as $row ) { $backup = BackupRecord::from_row( $row ); $migration = null; if ( $row->m_id ) { $migration = (object) [ 'id' => (int) $row->m_id, 'connection_id' => (int) ( $row->connection_id ?? 0 ), 'direction' => (string) ( $row->m_direction ?? '' ), 'status' => (string) ( $row->m_status ?? '' ), 'error_message' => (string) ( $row->m_error_message ?? '' ), 'created_at' => (string) ( $row->m_created_at ?? '' ), ]; } $connection = null; if ( $migration && $migration->connection_id ) { $connection = $connections[ $migration->connection_id ] ?? null; } $results[] = [ 'backup' => $backup, 'migration' => $migration, 'connection' => $connection, ]; } return $results; } /** * Restores a snapshot by backup ID through BackupManager's staged restore. * * @param bool $again Re-apply a restore point that already ran; set for an * operator's request, left off for automatic recovery. * @return array{success: bool, message: string, stats: array|null} * @throws RuntimeException On restore failure. */ public function restore_snapshot( int $backup_id, bool $again = false ): array { $backup = $this->backup_manager->find( $backup_id ); if ( ! $backup ) { throw new RuntimeException( __( 'Restore point not found.', 'shiftora' ) ); } $logger = new MigrationLogger( $backup->migration_id ); $logger->info( 'Rollback restore started.', [ 'backup_id' => $backup_id ] ); $start = microtime( true ); try { $outcome = $this->backup_manager->restore( $backup, $again ); $duration_ms = (int) ( ( microtime( true ) - $start ) * 1000 ); $skipped = BackupManager::OUTCOME_SKIPPED === $outcome; // A skip is neither a failure nor a restore, so it must not be reported as "completed". if ( $skipped ) { $logger->info( 'Rollback restore skipped: this restore point is already recorded as applied.', [ 'backup_id' => $backup_id ] ); } else { $logger->info( 'Rollback restore completed.', [ 'backup_id' => $backup_id, 'duration_ms' => $duration_ms, ] ); } return [ 'success' => true, 'outcome' => $outcome, 'message' => $skipped ? __( 'This restore point is already applied. The database was not changed.', 'shiftora' ) : __( 'Database restored successfully.', 'shiftora' ), 'stats' => [ 'duration_ms' => $duration_ms, 'backup_size' => $backup->file_size, ], ]; } catch ( RuntimeException $e ) { $logger->error( 'Rollback restore failed: ' . $e->getMessage(), [ 'backup_id' => $backup_id ] ); throw $e; } } /** Deletes a restore point and its dump file. */ public function delete_restore_point( int $backup_id ): void { $this->backup_manager->delete( $backup_id ); } /** Finds a backup record by ID. */ public function find_backup( int $backup_id ): ?BackupRecord { return $this->backup_manager->find( $backup_id ); } /** * Deletes old restore points, keeping the latest N. Goes through BackupCleaner * so a snapshot pinned by a live recovery lock survives the prune. * * @return int Number of deleted restore points. */ public function cleanup( int $keep = self::DEFAULT_RETENTION ): int { $cleaner = new BackupCleaner( $this->backup_manager, $this->wpdb ); return $cleaner->clean( $keep, BackupCleaner::POOL_ROLLBACK ); } /** * Summarizes storage used by all restore points. * * @return array{ * total_count: int, * total_size: int, * total_size_formatted: string, * newest_at: string|null, * oldest_at: string|null, * missing_count: int, * available_count: int * } */ public function storage_summary(): array { $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, file_path, file_size, created_at, status FROM %i WHERE label LIKE %s ORDER BY created_at DESC', $wpdb->prefix . 'shiftora_backups', 'rollback-%' ) ); $total_size = 0; $missing_count = 0; $newest_at = null; $oldest_at = null; $count = 0; foreach ( $rows ?? [] as $row ) { $count++; $total_size += (int) ( $row->file_size ?? 0 ); if ( ! file_exists( (string) $row->file_path ) ) { $missing_count++; } if ( null === $newest_at ) { $newest_at = (string) $row->created_at; } $oldest_at = (string) $row->created_at; } $record = new BackupRecord( 0, 0, '', $total_size, '[]', '' ); return [ 'total_count' => $count, 'total_size' => $total_size, 'total_size_formatted' => $record->formatted_size(), 'newest_at' => $newest_at, 'oldest_at' => $oldest_at, 'missing_count' => $missing_count, 'available_count' => $count - $missing_count, ]; } /** * Determines the display status of a restore point. * * @return string One of: available, restored, failed, expired, missing, recovery_triggered. */ public function determine_restore_point_status( BackupRecord $backup, ?object $migration ): string { // A dump whose size no longer matches its record is refused by the restorer, so do not offer it. if ( ! $backup->is_restorable() ) { return 'missing'; } if ( $backup->is_expired() ) { return 'expired'; } if ( $migration ) { $migration_status = (string) ( $migration->status ?? '' ); if ( 'failed' === $migration_status ) { return 'failed'; } if ( 'completed' === $migration_status ) { return 'available'; } if ( 'running' === $migration_status ) { return 'recovery_triggered'; } } return 'available'; } // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped }